datasette/datasette/telemetry_registry.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

646 lines
25 KiB
Python
Raw Normal View History

Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
"""
The single source of truth for every span and span attribute that Datasette
core emits.
Three things read this module, which is the point of it existing:
1. **The instrumentation itself.** `Attribute` and `SpanName` subclass `str`,
so a registry entry *is* the string OpenTelemetry wants. Call sites pass
`DB_NAMESPACE` where they used to pass `"db.namespace"` - no wrapper API
over the OTel calls, no parallel structure to keep in step, and a typo is
now an `ImportError` instead of a silently misnamed attribute.
2. **The documentation.** `docs/telemetry_doc.py` renders the span reference
in `docs/internals.rst` from these definitions using cog, and
`cog --check` runs in CI - so the docs cannot drift from the code.
3. **A conformance test.** `tests/test_telemetry_registry.py` makes real
requests, collects every span and attribute actually emitted, and compares
both directions: emitted-but-unregistered catches instrumentation added
without documentation, registered-but-never-emitted catches documentation
describing something that no longer exists. Neither the type system nor
the generated docs can catch that second case.
"""
from opentelemetry.trace import SpanKind
class Attribute(str):
"""
A span attribute key, carrying its own documentation.
Subclasses `str` so it can be handed straight to `set_attribute()`.
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
Part of Datasette's public plugin API - plugins declare their own
telemetry registries with these classes. See the "Telemetry for plugin
authors" documentation.
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
"""
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
__slots__ = ("description", "optional", "values")
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
def __new__(cls, name, description, optional=False, values=None):
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
self = super().__new__(cls, name)
self.description = description
self.optional = optional
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
# A closed enum vocabulary for the attribute's values, or None for
# an open value set. Declaring one does two things: the conformance
# helpers assert every emitted value is a member, and it marks the
# attribute as bounded - safe to use as a metric dimension, where an
# open value set would be a cardinality hazard.
self.values = frozenset(values) if values is not None else None
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
return self
def __reduce__(self):
# Copies and pickles collapse to a plain str. Without this, `copy` has
# to reconstruct a str subclass through `cls.__new__(cls)`, which these
# classes reject - their `__new__` requires the metadata arguments. It
# is not a theoretical problem: the SDK's ConsoleMetricExporter renders
# data points with `dataclasses.asdict()`, which deepcopies mappings,
# and registry entries are used as metric attribute keys - so every
# console metrics dump would crash. Collapsing is also the honest
# answer, not a workaround. On the wire and in a copy an entry *is*
# its string; the description, values and buckets describe the single
# registered instance in this module, and nothing reads them off a
# copy.
return (str, (str(self),))
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
def __repr__(self):
return f"Attribute({str(self)!r})"
class SpanName(str):
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
"""A span name, carrying its documentation and the attributes it may set.
Part of Datasette's public plugin API, like `Attribute`.
"""
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
__slots__ = ("attributes", "description", "dynamic", "kind", "prefix")
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
def __new__(
cls,
name,
description,
attributes=(),
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
prefix=False,
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
dynamic=False,
kind=SpanKind.INTERNAL,
):
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
self = super().__new__(cls, name)
self.description = description
self.attributes = tuple(attributes)
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
# True for a span family whose emitted names carry a variable suffix
# after a fixed prefix - e.g. a plugin's `chat {model}` registered as
# SpanName("chat ", ..., prefix=True) - so `span_for()` matches by
# prefix rather than equality. Core registers none itself; the flag
# exists for plugin registries.
self.prefix = prefix
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
# True when the emitted name is composed at runtime and shares no
# fixed prefix with the registry entry - the HTTP request span, whose
Name the request span after the route it matched The request span was created at the ASGI edge, before anything knew which route would match, so it carried nothing but the method: every request in a trace UI showed up as "GET", and the only URL on it was url.path, which is unbounded on a public instance and useless as a grouping key. Routing resolves in DatasetteRouter, so that is where the span gets http.route and its semconv `{method} {route}` name. http.route is the compiled route pattern, not a prettified /{database}/{table} template. Datasette routes with compiled regexes and the route table is fixed when the app is built, so the pattern is exact, bounded and needs no parsing; the transform into something prettier accretes edge cases, and Django's instrumentation ships regex-flavoured routes for the same reason. A request that matches no route gets no http.route and keeps its bare method name, which is what semantic conventions ask for. Two things the obvious implementation gets wrong, both found by testing it: - The router must not read `get_current_span()`. A plugin asgi_wrapper() runs *inside* the request middleware, so an instrumented plugin makes its own span current for the whole request - and the route then lands on that plugin's INTERNAL span, renaming it, while the actual request span never gets the one attribute a trace UI groups by. It reproduces with a five-line plugin. The span is passed through the ASGI scope instead, falling back to the current span so an externally-created SERVER span is still enriched. - The method has to be clamped again here. The middleware clamps it for the attribute, but the name is rebuilt from request.method, which is the raw client string - so an unclamped rename put `FROB /(?P<database>...` back into the span name that the middleware had just kept it out of. Both guards are `is_recording()`, not `get_span_context().is_valid`: with no provider but an inbound traceparent the API returns a NonRecordingSpan carrying the remote context, which is valid and records nothing, so an is_valid guard would do the work on every request from a traced caller. Tests cover the route and name, the unrouted 404 fallback, the full attribute set, db.query spans reaching the request span by parent walk, a 500, an inbound traceparent becoming a remote parent, ?sql= never reaching a span attribute, and - in a subprocess, because the suite's provider fixture is session-scoped and unavoidable - the no-provider fast path handing the app the original `send`. The streaming test uses a table larger than one page so the export genuinely issues queries during the body send; without that it passes however early the span ends. Measured on this branch against fixtures.db: a faceted table page went from 112 spans in 56 traces to 113 spans in 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:52:12 -07:00
# name is the request method followed by the matched route. There is
# no substring of the entry that could be matched against the wire, so
# `span_for()` resolves these by span kind instead, and the entry's own
# string is a template written for a human reading the generated
# reference.
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
self.dynamic = dynamic
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
# SpanKind.INTERNAL by default - every span Datasette emits describes
# its own internal work. db.query is the one exception: it is a real
# database call, so semantic conventions (and trace UIs, which key
# their database styling off this) expect SpanKind.CLIENT.
self.kind = kind
return self
def __reduce__(self):
# See Attribute.__reduce__.
return (str, (str(self),))
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
def __repr__(self):
return f"SpanName({str(self)!r})"
class MetricName(str):
"A metric name, carrying its instrument kind, unit and attributes."
__slots__ = ("attributes", "buckets", "description", "kind", "unit")
def __new__(cls, name, kind, unit, description, attributes=(), buckets=None):
self = super().__new__(cls, name)
self.kind = kind
self.unit = unit
self.description = description
self.attributes = tuple(attributes)
# Explicit histogram bucket boundaries, for histograms only. Passed to
# create_histogram() as explicit_bucket_boundaries_advisory and
# published in the generated docs, since an operator writing a
# histogram_quantile() query needs to know them.
self.buckets = tuple(buckets) if buckets is not None else None
return self
def __reduce__(self):
# See Attribute.__reduce__.
return (str, (str(self),))
def __repr__(self):
return f"MetricName({str(self)!r})"
COUNTER = "Counter"
Capstone review fixes: per-test reset, rename, provider guard, UpDownCounter, naming rules, privacy walk Outcome of a whole-stack review with the kit visible as one system: - otel_reset: an autouse fixture draining the span exporter and metric reader after every test. Without it a large suite accumulates hundreds of thousands of recorded spans in the session-scoped exporter - the likeliest amplifier of the slow-runner CI flakes - and plugins would inherit the same leak. - assert_registry_covered renamed to assert_spans_covered: the old name read as covering the whole registry, which is exactly wrong next to assert_metrics_covered. Public API is forever; renamed before anything ships, no alias. - The installers now verify their provider actually took: with a provider installed first (opentelemetry-instrument, an embedding app), set_*_provider() is silently ignored, and fixtures would assert against an exporter wired to nothing. They skip clearly instead. - UPDOWN_COUNTER registry kind, mapped to Sum with monotonicity checked both ways - a Counter must collect monotonic, an UpDownCounter must not. Previously an UpDownCounter's kind check was silently skipped. - The docs page now prescribes naming: scope = import package name (underscores), signal prefix = a name you own, never bare datasette.*; its own examples no longer teach the hyphenated outlier. Plus an observable-gauges pattern section and a prefix-overlap note. - assert_no_forbidden_values(): the enforcement half of the privacy rules - plant sentinel secrets in a workload and assert they never appear in any span name, attribute, event, status description or metric attribute, across all scopes by default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 14:26:45 -07:00
UPDOWN_COUNTER = "UpDownCounter"
HISTOGRAM = "Histogram"
GAUGE = "Observable gauge"
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
# --- Attributes -----------------------------------------------------------
#
# Shared attributes are defined once and referenced by every span that sets
# them, so "which spans carry db.namespace?" is answerable by grep.
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
HTTP_REQUEST_METHOD = Attribute(
"http.request.method",
"The HTTP method, clamped to the nine methods RFC 9110 and RFC 5789 "
"define. Anything else is reported as ``_OTHER``: the method is a "
"client-controlled string, so echoing it back unbounded would be a "
"cardinality hazard.",
)
HTTP_RESPONSE_STATUS_CODE = Attribute(
"http.response.status_code",
"The status of the response, read from the ASGI ``http.response.start`` "
"message rather than from a :ref:`internals_response` object - several "
"views, including static files, file downloads and streaming CSV, send "
"that message themselves and never build one. Omitted if the connection "
"closed before anything was sent.",
optional=True,
)
Name the request span after the route it matched The request span was created at the ASGI edge, before anything knew which route would match, so it carried nothing but the method: every request in a trace UI showed up as "GET", and the only URL on it was url.path, which is unbounded on a public instance and useless as a grouping key. Routing resolves in DatasetteRouter, so that is where the span gets http.route and its semconv `{method} {route}` name. http.route is the compiled route pattern, not a prettified /{database}/{table} template. Datasette routes with compiled regexes and the route table is fixed when the app is built, so the pattern is exact, bounded and needs no parsing; the transform into something prettier accretes edge cases, and Django's instrumentation ships regex-flavoured routes for the same reason. A request that matches no route gets no http.route and keeps its bare method name, which is what semantic conventions ask for. Two things the obvious implementation gets wrong, both found by testing it: - The router must not read `get_current_span()`. A plugin asgi_wrapper() runs *inside* the request middleware, so an instrumented plugin makes its own span current for the whole request - and the route then lands on that plugin's INTERNAL span, renaming it, while the actual request span never gets the one attribute a trace UI groups by. It reproduces with a five-line plugin. The span is passed through the ASGI scope instead, falling back to the current span so an externally-created SERVER span is still enriched. - The method has to be clamped again here. The middleware clamps it for the attribute, but the name is rebuilt from request.method, which is the raw client string - so an unclamped rename put `FROB /(?P<database>...` back into the span name that the middleware had just kept it out of. Both guards are `is_recording()`, not `get_span_context().is_valid`: with no provider but an inbound traceparent the API returns a NonRecordingSpan carrying the remote context, which is valid and records nothing, so an is_valid guard would do the work on every request from a traced caller. Tests cover the route and name, the unrouted 404 fallback, the full attribute set, db.query spans reaching the request span by parent walk, a 500, an inbound traceparent becoming a remote parent, ?sql= never reaching a span attribute, and - in a subprocess, because the suite's provider fixture is session-scoped and unavoidable - the no-provider fast path handing the app the original `send`. The streaming test uses a table larger than one page so the export genuinely issues queries during the body send; without that it passes however early the span ends. Measured on this branch against fixtures.db: a faceted table page went from 112 spans in 56 traces to 113 spans in 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:52:12 -07:00
HTTP_ROUTE = Attribute(
"http.route",
"The route the request matched, as the compiled regular expression "
"pattern Datasette routes with - for example "
"``/(?P<database>[^\\/\\.]+)/(?P<table>[^\\/\\.]+)(\\.(?P<format>\\w+))?$`` "
"for a table page. It is deliberately the pattern rather than a prettified "
"``/{database}/{table}`` template: the route table is fixed when the app "
"is built, so the pattern is exact, bounded and needs no parsing, whereas "
"the transform into something prettier accretes edge cases. Unlike "
"``url.path`` this is low cardinality, so it is the attribute to group by. "
"Omitted when no route matched - a 404 - which is also when the span name "
"falls back to the bare method.",
optional=True,
)
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
URL_PATH = Attribute(
"url.path",
"The path portion of the URL. The query string is deliberately **not** "
"recorded, on this or any other span: Datasette puts user-supplied SQL in "
"``?sql=`` and canned query parameters in the query string, so exporting "
"it by default would export exactly the data the rest of this "
"instrumentation is careful with.",
)
URL_SCHEME = Attribute("url.scheme", "``http`` or ``https``.")
SERVER_ADDRESS = Attribute(
"server.address",
"The ``Host`` header, verbatim - including any ``:port`` suffix, a "
"deliberate deviation from semantic conventions' ``server.address`` / "
"``server.port`` split. Client-controlled, so treat it as untrusted input "
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
"rather than as the identity of the server.",
optional=True,
)
USER_AGENT_ORIGINAL = Attribute(
"user_agent.original",
"The ``User-Agent`` header, verbatim. Omitted if the client sent none.",
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
optional=True,
)
INTERNAL_CLIENT = Attribute(
"datasette.internal_client",
"``True`` when the request was made in-process through "
"``datasette.client`` rather than arriving over the network. Such a "
"sub-request runs the full ASGI stack, so it emits its own nested "
"``SERVER`` span inside the outer request's - filter on this attribute "
"to keep kind-based dashboards from double-counting requests. Omitted "
"for real inbound requests.",
optional=True,
)
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
ERROR_TYPE = Attribute(
"error.type",
"Set when the request failed: the exception class name if one escaped the "
"application, otherwise the status code as a string for a 5xx response. "
"A 4xx does **not** set this and does not set an error status - per "
"semantic conventions a client error is not a server span's failure.",
optional=True,
)
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
DB_SYSTEM = Attribute("db.system", "Always ``sqlite``.")
DB_NAMESPACE = Attribute("db.namespace", "Name of the database being queried.")
Extend the kit with metric-side conformance: kind, unit and enum checks The five surveyed plugin plans all kept a hand-rolled metrics-vs-registry diff because the kit's conformance helpers covered spans only. This adds the metric side: - metric_for() in the registry (the span_for analogue - no prefix/dynamic machinery, metric names are static), and the attribute helpers are documented as accepting MetricName entries. - MetricsCollector.collect() now retains the instrumentation scope per collected metric, so a plugin is judged against its own meter only. - assert_metrics_conform(): every collected metric in scope is registered, was created as the instrument kind and unit its registry entry declares (drift between the registry entry and the meter.create_*() call was previously caught by nothing, in core or any plugin), sets only registered attributes, and respects values= enums - the check that makes a metric dimension provably bounded. - assert_metrics_covered(): every registered metric collected at least once with every non-optional attribute seen. Both *_covered helpers now exempt optional=True attributes, so a workload is not forced to manufacture every error path; pin those with targeted tests instead. - datasette.operation declares values={"read", "write"} - core dogfoods the enum enforcement on the dimension where it matters most. - Core's generic metric conformance tests are now calls to the kit helpers with scope_name="datasette"; the stricter literal-pinning and optional-attribute-coverage tests stay hand-written on purpose. - The metric reference docs render attributes through the same helper as spans, so *(optional)* markers and enum values now appear there too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:55:47 -07:00
OPERATION = Attribute(
"datasette.operation",
"Whether the operation was a read or a write.",
values={"read", "write"},
)
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
DB_QUERY_TEXT = Attribute(
"db.query.text",
Trace callback-style calls: execute_fn, execute_write_fn, execute_isolated_fn The database instrumentation covered the four SQL-string entry points but not the callback entry points, which are the documented way for plugins to run arbitrary SQL - so the JSON write API's inserts and deletes, the startup catalog scan, and every plugin built on execute_fn/execute_write_fn were invisible to a trace, or worse, showed orphan-looking db.write.* spans with no db.query above them. Each callback method now opens the same db.query CLIENT span as its SQL-string sibling, carrying a new optional datasette.callback attribute (the callable's qualified name, captured before _wrap_fn_with_hooks() can rename it) in place of db.query.text, which is now marked optional. A bare execute_fn() also wraps the callback in a db.query.execute child, so the "gap between the spans is thread-wait" story holds for plugin callbacks too. No db.operation.name: there is no statement to take a keyword from, and the registry says that attribute is omitted rather than guessed. The previous bodies move to private _execute_fn()/_execute_write_fn() and the SQL-string methods call those, so an execute() emits exactly the spans it did before - pinned by test_execute_does_not_double_wrap. Database's own introspection helpers stay on the public method deliberately: they are real SQLite round trips, which lifts a table page from ~58 to ~100 (no-op) spans. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 11:48:56 -07:00
"The SQL, truncated to 2048 characters. Never the parameter values. "
"Absent for a callback-style call (``execute_fn()`` and friends), where "
"there is no SQL string to record - ``datasette.callback`` is set "
"instead.",
optional=True,
)
CALLBACK = Attribute(
"datasette.callback",
"The qualified name of the Python callable passed to ``execute_fn()``, "
"``execute_write_fn()`` or ``execute_isolated_fn()`` - for example "
"``TableInsertView.post.<locals>.insert_or_upsert_rows``. Set instead of "
"``db.query.text``, which does not exist for a callback: the SQL is "
"whatever the function chooses to run. A lambda reports ``<lambda>``, "
"which is why callers wanting a recognisable span should pass a named "
"function. Bounded cardinality: the set of callables is fixed by the "
"installed code, not by request input.",
optional=True,
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
)
DB_OPERATION_NAME = Attribute(
"db.operation.name",
"The statement's leading keyword - ``SELECT``, ``INSERT``, ``CREATE``, and "
"so on - matched against a small fixed allowlist. Omitted rather than set "
"to an arbitrary value: the attribute must stay safe to use as a metric "
"dimension, and echoing an unrecognised first token from user-supplied "
"SQL would be an unbounded-cardinality hazard. Also omitted for "
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
"``execute_write_script()``, which runs multiple statements - per "
"semantic conventions, the operation name should not be extracted from "
"query text that can contain more than one operation. Note that a "
"statement beginning with a CTE reports ``WITH``, not the operation "
"inside it - a substantial share of Datasette's own reads take that "
"form. Resolving it further would mean parsing.",
optional=True,
)
DB_COLLECTION_NAME = Attribute(
"db.collection.name",
"The primary table, set only where the view already knows it - the table "
"and row pages. Omitted for arbitrary ``?sql=`` queries, where determining "
"the table would mean parsing the query.",
optional=True,
)
PARAM_COUNT = Attribute(
"datasette.param_count",
"Number of bound parameters. Recorded instead of the values themselves.",
optional=True,
)
PARAM_SETS = Attribute(
"datasette.param_sets",
"Number of parameter sets consumed by ``execute_write_many()``. Not a row "
"count - ``executemany()`` returns no rows. The parameter values "
"themselves are never recorded: that sequence can hold thousands of rows.",
optional=True,
)
TIME_LIMIT_MS = Attribute(
"datasette.time_limit_ms",
"The :ref:`setting_sql_time_limit_ms` value this query ran under. Set on "
"reads, which are the queries that time limit applies to.",
optional=True,
)
ROWS_RETURNED = Attribute(
"datasette.rows_returned",
"Number of rows a read returned. Set on the read path only, and only when "
"the read succeeded.",
optional=True,
)
TRUNCATED = Attribute(
"datasette.truncated",
"True if the result was cut short by :ref:`setting_max_returned_rows`.",
optional=True,
)
INTERRUPTED = Attribute(
"datasette.interrupted",
"True if the query was cancelled for exceeding the time limit. The span "
Stop marking a deliberately-short query budget as a span error Datasette has a family of callers that run a query under a tiny time limit and treat "did not finish" as a usable answer. table_counts() is the loudest: the homepage counts every table with a 10ms budget and stores None for the ones that blow it. The QueryInterrupted handler on the db.query span was unconditional, so on a two-table database that produced four ERROR spans - two db.query and two db.query.execute - on every homepage hit. Measured on a 30MB two-table database: 4 red spans before, 0 after. Honouring log_sql_errors here would have silenced none of it. Only the three ArrayFacet json_type() probes pass log_sql_errors=False, and they are not the queries that time out; table_counts() and ColumnFacet.suggest both leave it at its True default. The signal that does separate the two cases is the budget itself: a caller asking for less time than sql_time_limit_ms is saying the query may not finish. Keying off that needs no new API and no changes outside database.py. A query that runs out the instance-wide limit is still an error. datasette.interrupted is still set in every case - it is the signal worth having, and only the ERROR status becomes conditional. Its registry description said the status is "also set to ERROR" full stop, which is now wrong, and that string is published in docs/internals.rst. The inner db.query.execute span carried the same bug through set_status_on_exception=log_sql_errors, so its exception handling is now explicit, matching the db.query span above it. The context manager's flags apply to every exception type alike and this span has to tell two apart. test_query_interrupted_sets_error_status forced its timeout with ?_timelimit=5, which is exactly the signal now reclassified as expected. It now forces one via sql_time_limit_ms so it still tests what it was written to test. Also documents, at the copy_context() sites, that context propagation carries Datasette's non-OTel ContextVars into worker threads too. Verified harmless: nothing reads _skip_permission_checks, _permission_check_cache or _in_datasette_client off the event loop, and Context.run() restores the thread's previous context on return, so no value can reach the next task on the shared pool. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:58:53 -07:00
"status is also set to ``ERROR``, unless the caller asked for a budget "
"shorter than :ref:`setting_sql_time_limit_ms` - as table counts, facet "
"suggestion and autocomplete all do - in which case running out of time "
"is an expected answer rather than a failure and the status is left "
"unset.",
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
optional=True,
)
SQL_ERROR_SUPPRESSED = Attribute(
"datasette.sql_error_suppressed",
"True when the query failed but the caller passed ``log_sql_errors=False``, "
"meaning it was probing and treats failure as an expected answer. Facet "
"suggestion does this against every column.",
optional=True,
)
EXECUTESCRIPT = Attribute(
"datasette.executescript",
"True for ``execute_write_script()``, which runs multiple statements.",
optional=True,
)
EXECUTEMANY = Attribute(
"datasette.executemany",
"True for ``execute_write_many()``, which runs one statement against many "
"parameter sets.",
optional=True,
)
ISOLATED_CONNECTION = Attribute(
"datasette.isolated_connection",
"True if the write ran on its own connection rather than the shared write "
"connection.",
)
TRANSACTION = Attribute(
"datasette.transaction",
"False for statements such as ``VACUUM`` that cannot run inside a transaction.",
)
# --- Spans ----------------------------------------------------------------
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
HTTP_REQUEST = SpanName(
Name the request span after the route it matched The request span was created at the ASGI edge, before anything knew which route would match, so it carried nothing but the method: every request in a trace UI showed up as "GET", and the only URL on it was url.path, which is unbounded on a public instance and useless as a grouping key. Routing resolves in DatasetteRouter, so that is where the span gets http.route and its semconv `{method} {route}` name. http.route is the compiled route pattern, not a prettified /{database}/{table} template. Datasette routes with compiled regexes and the route table is fixed when the app is built, so the pattern is exact, bounded and needs no parsing; the transform into something prettier accretes edge cases, and Django's instrumentation ships regex-flavoured routes for the same reason. A request that matches no route gets no http.route and keeps its bare method name, which is what semantic conventions ask for. Two things the obvious implementation gets wrong, both found by testing it: - The router must not read `get_current_span()`. A plugin asgi_wrapper() runs *inside* the request middleware, so an instrumented plugin makes its own span current for the whole request - and the route then lands on that plugin's INTERNAL span, renaming it, while the actual request span never gets the one attribute a trace UI groups by. It reproduces with a five-line plugin. The span is passed through the ASGI scope instead, falling back to the current span so an externally-created SERVER span is still enriched. - The method has to be clamped again here. The middleware clamps it for the attribute, but the name is rebuilt from request.method, which is the raw client string - so an unclamped rename put `FROB /(?P<database>...` back into the span name that the middleware had just kept it out of. Both guards are `is_recording()`, not `get_span_context().is_valid`: with no provider but an inbound traceparent the API returns a NonRecordingSpan carrying the remote context, which is valid and records nothing, so an is_valid guard would do the work on every request from a traced caller. Tests cover the route and name, the unrouted 404 fallback, the full attribute set, db.query spans reaching the request span by parent walk, a 500, an inbound traceparent becoming a remote parent, ?sql= never reaching a span attribute, and - in a subprocess, because the suite's provider fixture is session-scoped and unavoidable - the no-provider fast path handing the app the original `send`. The streaming test uses a table larger than one page so the export genuinely issues queries during the body send; without that it passes however early the span ends. Measured on this branch against fixtures.db: a faceted table page went from 112 spans in 56 traces to 113 spans in 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:52:12 -07:00
"{http.request.method} {http.route}",
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
"One span per HTTP request, created by the outermost layer of the ASGI "
"stack - so plugin ``asgi_wrapper()`` middleware, CSRF protection and "
"every database span raised while serving the request all nest inside "
"it. Without it each of those would be its own root trace. The span name "
Name the request span after the route it matched The request span was created at the ASGI edge, before anything knew which route would match, so it carried nothing but the method: every request in a trace UI showed up as "GET", and the only URL on it was url.path, which is unbounded on a public instance and useless as a grouping key. Routing resolves in DatasetteRouter, so that is where the span gets http.route and its semconv `{method} {route}` name. http.route is the compiled route pattern, not a prettified /{database}/{table} template. Datasette routes with compiled regexes and the route table is fixed when the app is built, so the pattern is exact, bounded and needs no parsing; the transform into something prettier accretes edge cases, and Django's instrumentation ships regex-flavoured routes for the same reason. A request that matches no route gets no http.route and keeps its bare method name, which is what semantic conventions ask for. Two things the obvious implementation gets wrong, both found by testing it: - The router must not read `get_current_span()`. A plugin asgi_wrapper() runs *inside* the request middleware, so an instrumented plugin makes its own span current for the whole request - and the route then lands on that plugin's INTERNAL span, renaming it, while the actual request span never gets the one attribute a trace UI groups by. It reproduces with a five-line plugin. The span is passed through the ASGI scope instead, falling back to the current span so an externally-created SERVER span is still enriched. - The method has to be clamped again here. The middleware clamps it for the attribute, but the name is rebuilt from request.method, which is the raw client string - so an unclamped rename put `FROB /(?P<database>...` back into the span name that the middleware had just kept it out of. Both guards are `is_recording()`, not `get_span_context().is_valid`: with no provider but an inbound traceparent the API returns a NonRecordingSpan carrying the remote context, which is valid and records nothing, so an is_valid guard would do the work on every request from a traced caller. Tests cover the route and name, the unrouted 404 fallback, the full attribute set, db.query spans reaching the request span by parent walk, a 500, an inbound traceparent becoming a remote parent, ?sql= never reaching a span attribute, and - in a subprocess, because the suite's provider fixture is session-scoped and unavoidable - the no-provider fast path handing the app the original `send`. The streaming test uses a table larger than one page so the export genuinely issues queries during the body send; without that it passes however early the span ends. Measured on this branch against fixtures.db: a faceted table page went from 112 spans in 56 traces to 113 spans in 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:52:12 -07:00
"is not a fixed string: it is the method followed by the matched route, "
"and just the method for a request that matched no route. The span starts "
"at the ASGI edge, before routing has happened, so it is named for the "
"method there and renamed once the route is known. "
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
"W3C ``traceparent`` and ``baggage`` headers are extracted using the "
"global propagator, so a request arriving from an already-traced caller "
"continues that trace; set ``OTEL_PROPAGATORS=none`` to turn that off, "
"and strip those headers at your proxy if your instance is public.",
(
HTTP_REQUEST_METHOD,
Name the request span after the route it matched The request span was created at the ASGI edge, before anything knew which route would match, so it carried nothing but the method: every request in a trace UI showed up as "GET", and the only URL on it was url.path, which is unbounded on a public instance and useless as a grouping key. Routing resolves in DatasetteRouter, so that is where the span gets http.route and its semconv `{method} {route}` name. http.route is the compiled route pattern, not a prettified /{database}/{table} template. Datasette routes with compiled regexes and the route table is fixed when the app is built, so the pattern is exact, bounded and needs no parsing; the transform into something prettier accretes edge cases, and Django's instrumentation ships regex-flavoured routes for the same reason. A request that matches no route gets no http.route and keeps its bare method name, which is what semantic conventions ask for. Two things the obvious implementation gets wrong, both found by testing it: - The router must not read `get_current_span()`. A plugin asgi_wrapper() runs *inside* the request middleware, so an instrumented plugin makes its own span current for the whole request - and the route then lands on that plugin's INTERNAL span, renaming it, while the actual request span never gets the one attribute a trace UI groups by. It reproduces with a five-line plugin. The span is passed through the ASGI scope instead, falling back to the current span so an externally-created SERVER span is still enriched. - The method has to be clamped again here. The middleware clamps it for the attribute, but the name is rebuilt from request.method, which is the raw client string - so an unclamped rename put `FROB /(?P<database>...` back into the span name that the middleware had just kept it out of. Both guards are `is_recording()`, not `get_span_context().is_valid`: with no provider but an inbound traceparent the API returns a NonRecordingSpan carrying the remote context, which is valid and records nothing, so an is_valid guard would do the work on every request from a traced caller. Tests cover the route and name, the unrouted 404 fallback, the full attribute set, db.query spans reaching the request span by parent walk, a 500, an inbound traceparent becoming a remote parent, ?sql= never reaching a span attribute, and - in a subprocess, because the suite's provider fixture is session-scoped and unavoidable - the no-provider fast path handing the app the original `send`. The streaming test uses a table larger than one page so the export genuinely issues queries during the body send; without that it passes however early the span ends. Measured on this branch against fixtures.db: a faceted table page went from 112 spans in 56 traces to 113 spans in 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:52:12 -07:00
HTTP_ROUTE,
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
URL_PATH,
URL_SCHEME,
SERVER_ADDRESS,
USER_AGENT_ORIGINAL,
HTTP_RESPONSE_STATUS_CODE,
ERROR_TYPE,
INTERNAL_CLIENT,
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
),
dynamic=True,
kind=SpanKind.SERVER,
)
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
DB_QUERY = SpanName(
"db.query",
"A SQL operation issued by Datasette, covering the full round trip "
Trace callback-style calls: execute_fn, execute_write_fn, execute_isolated_fn The database instrumentation covered the four SQL-string entry points but not the callback entry points, which are the documented way for plugins to run arbitrary SQL - so the JSON write API's inserts and deletes, the startup catalog scan, and every plugin built on execute_fn/execute_write_fn were invisible to a trace, or worse, showed orphan-looking db.write.* spans with no db.query above them. Each callback method now opens the same db.query CLIENT span as its SQL-string sibling, carrying a new optional datasette.callback attribute (the callable's qualified name, captured before _wrap_fn_with_hooks() can rename it) in place of db.query.text, which is now marked optional. A bare execute_fn() also wraps the callback in a db.query.execute child, so the "gap between the spans is thread-wait" story holds for plugin callbacks too. No db.operation.name: there is no statement to take a keyword from, and the registry says that attribute is omitted rather than guessed. The previous bodies move to private _execute_fn()/_execute_write_fn() and the SQL-string methods call those, so an execute() emits exactly the spans it did before - pinned by test_execute_does_not_double_wrap. Database's own introspection helpers stay on the public method deliberately: they are real SQLite round trips, which lifts a table page from ~58 to ~100 (no-op) spans. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 11:48:56 -07:00
"including any time spent queued for a thread. Callback-style calls - "
"``execute_fn()``, ``execute_write_fn()`` and ``execute_isolated_fn()`` - "
"appear here too, distinguished by ``datasette.callback`` in place of "
"``db.query.text``.",
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
(
DB_SYSTEM,
DB_NAMESPACE,
DB_QUERY_TEXT,
Trace callback-style calls: execute_fn, execute_write_fn, execute_isolated_fn The database instrumentation covered the four SQL-string entry points but not the callback entry points, which are the documented way for plugins to run arbitrary SQL - so the JSON write API's inserts and deletes, the startup catalog scan, and every plugin built on execute_fn/execute_write_fn were invisible to a trace, or worse, showed orphan-looking db.write.* spans with no db.query above them. Each callback method now opens the same db.query CLIENT span as its SQL-string sibling, carrying a new optional datasette.callback attribute (the callable's qualified name, captured before _wrap_fn_with_hooks() can rename it) in place of db.query.text, which is now marked optional. A bare execute_fn() also wraps the callback in a db.query.execute child, so the "gap between the spans is thread-wait" story holds for plugin callbacks too. No db.operation.name: there is no statement to take a keyword from, and the registry says that attribute is omitted rather than guessed. The previous bodies move to private _execute_fn()/_execute_write_fn() and the SQL-string methods call those, so an execute() emits exactly the spans it did before - pinned by test_execute_does_not_double_wrap. Database's own introspection helpers stay on the public method deliberately: they are real SQLite round trips, which lifts a table page from ~58 to ~100 (no-op) spans. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 11:48:56 -07:00
CALLBACK,
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
DB_OPERATION_NAME,
DB_COLLECTION_NAME,
PARAM_COUNT,
PARAM_SETS,
TIME_LIMIT_MS,
ROWS_RETURNED,
TRUNCATED,
INTERRUPTED,
SQL_ERROR_SUPPRESSED,
EXECUTESCRIPT,
EXECUTEMANY,
),
kind=SpanKind.CLIENT,
)
DB_QUERY_EXECUTE = SpanName(
"db.query.execute",
"The read executing inside a SQL worker thread. Child of ``db.query``; the "
"gap between the two is time spent waiting for a thread.",
)
DB_WRITE_QUEUE_WAIT = SpanName(
"db.write.queue_wait",
"Time a write spent waiting in its database's write queue before the write "
"thread picked it up. Child of ``db.query`` for a ``block=True`` write, "
"where the caller awaits the write and containment is accurate. For a "
"``block=False`` write the caller does not await it - the enqueueing "
"request *caused* the write without *containing* it, and the write's "
"spans can outlive the request's own - so this is a root span instead, "
"carrying an OpenTelemetry link back to the enqueueing span rather than "
"a parent. A link records causation without asserting containment, which "
"is exactly the distinction here.",
)
DB_WRITE_EXECUTE = SpanName(
"db.write.execute",
"The write executing on the write thread. Child of ``db.query`` for a "
"``block=True`` write; for ``block=False`` a root span with a link back "
"to the enqueueing span instead - see ``db.write.queue_wait`` above.",
(ISOLATED_CONNECTION, TRANSACTION),
)
STARTUP = SpanName(
"datasette.startup",
"``invoke_startup()`` running: ``register_events``, ``register_actions``, "
"``register_column_types``, ``prepare_jinja2_environment``, internal-database "
"schema catalog refresh (including the ``prepare_connection`` warm-up this "
"triggers for each database touched for the first time), saved queries, "
"column type config and the ``startup`` hook. Runs once per process, before "
"any request exists, so without this span every child it creates would be "
"its own orphan root trace. A connection warmed later - lazily, the first "
"time a *request* touches a new database or thread - nests under that "
"request's own span instead, not under this one, since this span has "
"already ended by then.",
)
SPANS = (
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
HTTP_REQUEST,
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
DB_QUERY,
DB_QUERY_EXECUTE,
DB_WRITE_QUEUE_WAIT,
DB_WRITE_EXECUTE,
STARTUP,
)
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
def span_for(emitted_name, kind=None, spans=None):
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
"""
Resolve an emitted span name to its registry entry, or None.
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
Handles the two entry kinds whose emitted names are not knowable in
advance:
- `prefix=True` - the name carries a variable suffix after a fixed
prefix, matched by prefix. Core registers none; plugin registries use
it for names like ``chat {model}``.
- `dynamic=True` - the name has no fixed part at all, so it is matched
on `kind` instead and the caller has to supply one.
Exact matches win over prefix matches, and both win over dynamic, so a
looser entry can never shadow a span with a registered name.
`spans` defaults to core's own registry; the plugin testing kit passes a
plugin's tuple instead.
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
"""
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
if spans is None:
spans = SPANS
for span in spans:
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
if span.dynamic:
continue
if emitted_name == span:
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
return span
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
for span in spans:
if span.prefix and emitted_name.startswith(span):
return span
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
if kind is not None:
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
for span in spans:
Give every span a request to belong to Nothing in Datasette created a span for the HTTP request itself, so every span the database layer emits was a root span. Measured on this branch: one faceted table page produces 70 spans in 36 separate traces, none of which carries a URL. A trace UI shows that as dozens of unrelated single-span traces per page, interleaved across concurrent requests - worse than ?_trace=1 at the exact job people reach for tracing to do. With the request span it is 71 spans in 1 trace. `opentelemetry-instrument` does not fix this on its own: auto-instrumentation only picks up frameworks that ship an instrumentor entry point, and Datasette's raw ASGI app is not one. TelemetryMiddleware is mounted outermost in Datasette.app(), after the asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run *inside* the span. Putting it in DatasetteRouter instead would leave a span created by an instrumented plugin as an orphan root - reintroducing the problem for exactly the code most likely to be instrumented. It stays at ~90 lines, against roughly 700 for opentelemetry-instrumentation-asgi, because Datasette's app does not return before its body is sent: route_path awaits response.asgi_send(send), and a streaming CSV export runs its generator inline inside AsgiStream.asgi_send. So a plain `finally` covers the response body and no deferred-end machinery is needed. Two decisions worth flagging for review: - Inbound W3C traceparent and baggage are extracted, using the *global* propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI instrumentation), and going through the global propagator leaves the operator in control with no Datasette setting to invent: OTEL_PROPAGATORS=none disables it entirely. A public instance that does not want client-influenced traces should strip those headers at the proxy. - url.query is not recorded, anywhere. Datasette query strings carry user-supplied SQL in ?sql= and canned query parameters. client.address is not recorded either. The status code is sniffed from the ASGI http.response.start message rather than read off a Response, because asgi_static, the favicon route, AsgiStream and AsgiFileDownload all send that message themselves and never build one. Only a >= 500 sets an error status - per semantic conventions a 4xx is the client's mistake, and Datasette 404s are routine enough that treating them as errors would bury a real 500. The registry gains a `dynamic` flag, because this span's name is composed at runtime and so can never equal a fixed registry string. Dynamic entries resolve by span kind instead, and only after exact and prefix matching has failed, so they cannot shadow a span that does have a registered name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
if span.dynamic and span.kind == kind:
return span
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
return None
Extend the kit with metric-side conformance: kind, unit and enum checks The five surveyed plugin plans all kept a hand-rolled metrics-vs-registry diff because the kit's conformance helpers covered spans only. This adds the metric side: - metric_for() in the registry (the span_for analogue - no prefix/dynamic machinery, metric names are static), and the attribute helpers are documented as accepting MetricName entries. - MetricsCollector.collect() now retains the instrumentation scope per collected metric, so a plugin is judged against its own meter only. - assert_metrics_conform(): every collected metric in scope is registered, was created as the instrument kind and unit its registry entry declares (drift between the registry entry and the meter.create_*() call was previously caught by nothing, in core or any plugin), sets only registered attributes, and respects values= enums - the check that makes a metric dimension provably bounded. - assert_metrics_covered(): every registered metric collected at least once with every non-optional attribute seen. Both *_covered helpers now exempt optional=True attributes, so a workload is not forced to manufacture every error path; pin those with targeted tests instead. - datasette.operation declares values={"read", "write"} - core dogfoods the enum enforcement on the dimension where it matters most. - Core's generic metric conformance tests are now calls to the kit helpers with scope_name="datasette"; the stricter literal-pinning and optional-attribute-coverage tests stay hand-written on purpose. - The metric reference docs render attributes through the same helper as spans, so *(optional)* markers and enum values now appear there too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:55:47 -07:00
def metric_for(emitted_name, metrics=None):
"""
Resolve an emitted metric name to its registry entry, or None.
The `span_for()` analogue - simpler, because metric names are always
static strings. `metrics` defaults to core's own registry; the plugin
testing kit passes a plugin's tuple instead.
"""
if metrics is None:
metrics = METRICS
for metric in metrics:
if emitted_name == metric:
return metric
return None
def attribute_allowed(entry, emitted_key):
"""
Whether `emitted_key` is a registered attribute of `entry`.
`entry` is a `SpanName` or a `MetricName` - both carry `.attributes`.
"""
if entry is None:
Name every span and attribute once, in a registry the docs are built from The span and attribute names were string literals spread across four call sites in database.py and one in app.py, with a hand-written reference page that would have been true only on the day it was written. That drift is not hypothetical: an earlier iteration of this work carried a README asserting parameter values were never recorded for two branches after that had stopped being true. datasette/telemetry_registry.py now holds each name once, with its documentation. Attribute and SpanName subclass str, so a registry entry *is* the string OpenTelemetry wants - no wrapper API over the OTel calls, no parallel structure to keep in step, and a typo becomes an ImportError rather than a silently misnamed attribute. docs/internals.rst renders the span reference from it via cog, and `cog --check docs/*.rst` already runs in CI, so the reference cannot drift from the definitions. Nothing changes on the wire: the emitted span names and attribute keys are byte-identical before and after, verified by diffing a dump of both. tests/test_telemetry_registry.py exercises a real workload and compares it against the registry in both directions - emitted-but-unregistered catches instrumentation added without documentation, registered-but-never-emitted catches documentation that has outlived its code. Because the call sites now take their names from the registry, neither direction can catch a rename: move DB_NAMESPACE to "db.namespace2" and code and registry still agree while every dashboard breaks. So the literal names are also written out in the test and asserted against the registry and against the wire separately. That pair is the only comparison in the file not derived from the registry itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
return False
Extend the kit with metric-side conformance: kind, unit and enum checks The five surveyed plugin plans all kept a hand-rolled metrics-vs-registry diff because the kit's conformance helpers covered spans only. This adds the metric side: - metric_for() in the registry (the span_for analogue - no prefix/dynamic machinery, metric names are static), and the attribute helpers are documented as accepting MetricName entries. - MetricsCollector.collect() now retains the instrumentation scope per collected metric, so a plugin is judged against its own meter only. - assert_metrics_conform(): every collected metric in scope is registered, was created as the instrument kind and unit its registry entry declares (drift between the registry entry and the meter.create_*() call was previously caught by nothing, in core or any plugin), sets only registered attributes, and respects values= enums - the check that makes a metric dimension provably bounded. - assert_metrics_covered(): every registered metric collected at least once with every non-optional attribute seen. Both *_covered helpers now exempt optional=True attributes, so a workload is not forced to manufacture every error path; pin those with targeted tests instead. - datasette.operation declares values={"read", "write"} - core dogfoods the enum enforcement on the dimension where it matters most. - Core's generic metric conformance tests are now calls to the kit helpers with scope_name="datasette"; the stricter literal-pinning and optional-attribute-coverage tests stay hand-written on purpose. - The metric reference docs render attributes through the same helper as spans, so *(optional)* markers and enum values now appear there too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:55:47 -07:00
return emitted_key in entry.attributes
Extend the kit with metric-side conformance: kind, unit and enum checks The five surveyed plugin plans all kept a hand-rolled metrics-vs-registry diff because the kit's conformance helpers covered spans only. This adds the metric side: - metric_for() in the registry (the span_for analogue - no prefix/dynamic machinery, metric names are static), and the attribute helpers are documented as accepting MetricName entries. - MetricsCollector.collect() now retains the instrumentation scope per collected metric, so a plugin is judged against its own meter only. - assert_metrics_conform(): every collected metric in scope is registered, was created as the instrument kind and unit its registry entry declares (drift between the registry entry and the meter.create_*() call was previously caught by nothing, in core or any plugin), sets only registered attributes, and respects values= enums - the check that makes a metric dimension provably bounded. - assert_metrics_covered(): every registered metric collected at least once with every non-optional attribute seen. Both *_covered helpers now exempt optional=True attributes, so a workload is not forced to manufacture every error path; pin those with targeted tests instead. - datasette.operation declares values={"read", "write"} - core dogfoods the enum enforcement on the dimension where it matters most. - Core's generic metric conformance tests are now calls to the kit helpers with scope_name="datasette"; the stricter literal-pinning and optional-attribute-coverage tests stay hand-written on purpose. - The metric reference docs render attributes through the same helper as spans, so *(optional)* markers and enum values now appear there too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:55:47 -07:00
def attribute_value_allowed(entry, emitted_key, value):
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
"""
Extend the kit with metric-side conformance: kind, unit and enum checks The five surveyed plugin plans all kept a hand-rolled metrics-vs-registry diff because the kit's conformance helpers covered spans only. This adds the metric side: - metric_for() in the registry (the span_for analogue - no prefix/dynamic machinery, metric names are static), and the attribute helpers are documented as accepting MetricName entries. - MetricsCollector.collect() now retains the instrumentation scope per collected metric, so a plugin is judged against its own meter only. - assert_metrics_conform(): every collected metric in scope is registered, was created as the instrument kind and unit its registry entry declares (drift between the registry entry and the meter.create_*() call was previously caught by nothing, in core or any plugin), sets only registered attributes, and respects values= enums - the check that makes a metric dimension provably bounded. - assert_metrics_covered(): every registered metric collected at least once with every non-optional attribute seen. Both *_covered helpers now exempt optional=True attributes, so a workload is not forced to manufacture every error path; pin those with targeted tests instead. - datasette.operation declares values={"read", "write"} - core dogfoods the enum enforcement on the dimension where it matters most. - Core's generic metric conformance tests are now calls to the kit helpers with scope_name="datasette"; the stricter literal-pinning and optional-attribute-coverage tests stay hand-written on purpose. - The metric reference docs render attributes through the same helper as spans, so *(optional)* markers and enum values now appear there too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:55:47 -07:00
Whether `value` is permitted for `emitted_key` on `entry` (a `SpanName`
or a `MetricName`).
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
True for any value when the attribute declares no `values=` enum; when it
does, membership is enforced - that is what makes a declared enum a real
Extend the kit with metric-side conformance: kind, unit and enum checks The five surveyed plugin plans all kept a hand-rolled metrics-vs-registry diff because the kit's conformance helpers covered spans only. This adds the metric side: - metric_for() in the registry (the span_for analogue - no prefix/dynamic machinery, metric names are static), and the attribute helpers are documented as accepting MetricName entries. - MetricsCollector.collect() now retains the instrumentation scope per collected metric, so a plugin is judged against its own meter only. - assert_metrics_conform(): every collected metric in scope is registered, was created as the instrument kind and unit its registry entry declares (drift between the registry entry and the meter.create_*() call was previously caught by nothing, in core or any plugin), sets only registered attributes, and respects values= enums - the check that makes a metric dimension provably bounded. - assert_metrics_covered(): every registered metric collected at least once with every non-optional attribute seen. Both *_covered helpers now exempt optional=True attributes, so a workload is not forced to manufacture every error path; pin those with targeted tests instead. - datasette.operation declares values={"read", "write"} - core dogfoods the enum enforcement on the dimension where it matters most. - Core's generic metric conformance tests are now calls to the kit helpers with scope_name="datasette"; the stricter literal-pinning and optional-attribute-coverage tests stay hand-written on purpose. - The metric reference docs render attributes through the same helper as spans, so *(optional)* markers and enum values now appear there too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:55:47 -07:00
cardinality bound rather than documentation. On a metric entry this is
where the bound matters most: a metric series is keyed by its attribute
values.
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
"""
Extend the kit with metric-side conformance: kind, unit and enum checks The five surveyed plugin plans all kept a hand-rolled metrics-vs-registry diff because the kit's conformance helpers covered spans only. This adds the metric side: - metric_for() in the registry (the span_for analogue - no prefix/dynamic machinery, metric names are static), and the attribute helpers are documented as accepting MetricName entries. - MetricsCollector.collect() now retains the instrumentation scope per collected metric, so a plugin is judged against its own meter only. - assert_metrics_conform(): every collected metric in scope is registered, was created as the instrument kind and unit its registry entry declares (drift between the registry entry and the meter.create_*() call was previously caught by nothing, in core or any plugin), sets only registered attributes, and respects values= enums - the check that makes a metric dimension provably bounded. - assert_metrics_covered(): every registered metric collected at least once with every non-optional attribute seen. Both *_covered helpers now exempt optional=True attributes, so a workload is not forced to manufacture every error path; pin those with targeted tests instead. - datasette.operation declares values={"read", "write"} - core dogfoods the enum enforcement on the dimension where it matters most. - Core's generic metric conformance tests are now calls to the kit helpers with scope_name="datasette"; the stricter literal-pinning and optional-attribute-coverage tests stay hand-written on purpose. - The metric reference docs render attributes through the same helper as spans, so *(optional)* markers and enum values now appear there too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:55:47 -07:00
if entry is None:
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
return False
Extend the kit with metric-side conformance: kind, unit and enum checks The five surveyed plugin plans all kept a hand-rolled metrics-vs-registry diff because the kit's conformance helpers covered spans only. This adds the metric side: - metric_for() in the registry (the span_for analogue - no prefix/dynamic machinery, metric names are static), and the attribute helpers are documented as accepting MetricName entries. - MetricsCollector.collect() now retains the instrumentation scope per collected metric, so a plugin is judged against its own meter only. - assert_metrics_conform(): every collected metric in scope is registered, was created as the instrument kind and unit its registry entry declares (drift between the registry entry and the meter.create_*() call was previously caught by nothing, in core or any plugin), sets only registered attributes, and respects values= enums - the check that makes a metric dimension provably bounded. - assert_metrics_covered(): every registered metric collected at least once with every non-optional attribute seen. Both *_covered helpers now exempt optional=True attributes, so a workload is not forced to manufacture every error path; pin those with targeted tests instead. - datasette.operation declares values={"read", "write"} - core dogfoods the enum enforcement on the dimension where it matters most. - Core's generic metric conformance tests are now calls to the kit helpers with scope_name="datasette"; the stricter literal-pinning and optional-attribute-coverage tests stay hand-written on purpose. - The metric reference docs render attributes through the same helper as spans, so *(optional)* markers and enum values now appear there too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:55:47 -07:00
for attribute in entry.attributes:
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs A survey of five plugin OTel plans (datasette-paper, -agent, -litestream, -accounts, -cron) found every one hand-copying the same core machinery: the registry classes, the conformance-test harness, the pytest fixtures, the bucket boundaries and the detached-root-with-Link recipe. This makes that machinery importable instead: - The registry classes are documented public API. Attribute gains values= (a closed enum the conformance helpers enforce - what makes an attribute safe as a metric dimension); SpanName gains prefix=True for span families like "chat {model}" whose names share a fixed prefix, matched by span_for() after exact names. span_for()/attribute helpers accept a spans= tuple so plugin registries can use them. - datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link shape for work a request caused without containing - background jobs, scheduled ticks, block=False writes. Core's own write thread now uses it instead of building the kwargs inline. - datasette.telemetry_testing: the session provider fixtures, otel_spans / otel_metrics, a two-way registry conformance checker (including enum and prefix handling, filtered by instrumentation scope) and an assert_package_never_imports_sdk() guard. Core's conftest now imports these instead of defining them, so the suite consumes the kit exactly as a plugin's would. - New "Telemetry for plugin authors" docs page: scope discipline, registry usage, privacy/cardinality rules, named-callable guidance, request_span(), the background root-with-link convention (one root per tick, always emitted), provider-ordering facts and known caveats. request_span() is now documented public API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-02 12:24:42 -07:00
if attribute == emitted_key:
return attribute.values is None or value in attribute.values
return False
# --- Metrics --------------------------------------------------------------
# Every duration histogram here is in seconds, and OpenTelemetry's default
# bucket boundaries are tuned for milliseconds - their first non-zero boundary
# is 5, so without explicit boundaries every SQLite query lands in the single
# (0, 5] second bucket and every quantile query returns noise.
#
# These are the OpenTelemetry semantic conventions' recommended boundaries for
# db.client.operation.duration, in seconds, plus 0.0001 and 0.0005 at the
# bottom. The deviation is deliberate: those boundaries assume a network
# database client, whereas SQLite is in-process and a large fraction of real
# queries run in 30-80us, which would otherwise all pile into the first
# bucket and be indistinguishable from each other.
#
# One shared list is used for every duration histogram rather than a tailored
# list each, so that dashboards stay comparable and a queue wait can be read
# against the query duration it delays. It already spans 100us to 10s, which
# covers both a fast in-process read and a write queued behind contention.
DURATION_BUCKETS = (0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10)
M_OPERATION_DURATION = MetricName(
"db.client.operation.duration",
HISTOGRAM,
"s",
"Duration of a SQL operation. The standard OpenTelemetry semantic "
"convention metric, and the one that survives trace sampling. "
"Callback-style calls (``execute_fn()`` and friends) are counted "
"alongside the SQL-string methods.",
(DB_SYSTEM, DB_NAMESPACE, OPERATION, ERROR_TYPE),
buckets=DURATION_BUCKETS,
)
M_WRITE_QUEUE_WAIT = MetricName(
"datasette.write.queue_wait",
HISTOGRAM,
"s",
"Time each write waited in its database's write queue. The metric "
"counterpart of the ``db.write.queue_wait`` span.",
(DB_NAMESPACE,),
buckets=DURATION_BUCKETS,
)
M_QUERIES_INTERRUPTED = MetricName(
"datasette.sql.queries.interrupted",
COUNTER,
"{query}",
"Queries cancelled for exceeding :ref:`setting_sql_time_limit_ms`. Worth "
"alerting on: a rising rate means the limit is too tight or a table has "
"outgrown its queries. A caller that opted into a deliberately shorter "
"budget - facet suggestion, for example - is not counted, for the same "
"reason its timeout is not a span error.",
(DB_NAMESPACE,),
)
M_THREADS_LIMIT = MetricName(
"datasette.sql.threads.limit",
GAUGE,
"{thread}",
"Maximum concurrent read queries - the :ref:`setting_num_sql_threads` "
"value. Not reported when ``num_sql_threads`` is ``0``, since then queries "
"run on the event loop and there is no pool.",
)
M_THREADS_QUEUE_DEPTH = MetricName(
"datasette.sql.threads.queue_depth",
GAUGE,
"{query}",
"Read queries waiting for a free thread. **This is the saturation "
"signal** - sustained above zero means requests are queueing on "
"``num_sql_threads``.",
)
M_QUERIES_PENDING = MetricName(
"datasette.sql.queries.pending",
GAUGE,
"{query}",
"Read queries submitted to the pool and not yet complete. Summed across "
"databases and compared against the thread limit, this is pool "
"utilisation.",
(DB_NAMESPACE,),
)
M_WRITE_QUEUE_DEPTH = MetricName(
"datasette.write.queue_depth",
GAUGE,
"{write}",
"Writes queued behind a database's single write thread. Backpressure that "
"raising ``num_sql_threads`` cannot relieve. Not reported for a database "
"that has never been written to.",
(DB_NAMESPACE,),
)
M_CONNECTIONS_OPEN = MetricName(
"datasette.connections.open",
GAUGE,
"{connection}",
"Open SQLite connections currently tracked for closing.",
(DB_NAMESPACE,),
)
METRICS = (
M_OPERATION_DURATION,
M_WRITE_QUEUE_WAIT,
M_QUERIES_INTERRUPTED,
M_THREADS_LIMIT,
M_THREADS_QUEUE_DEPTH,
M_QUERIES_PENDING,
M_WRITE_QUEUE_DEPTH,
M_CONNECTIONS_OPEN,
)