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
|
|
|
"""
|
|
|
|
|
Two-way conformance between `datasette/telemetry_registry.py` and what
|
|
|
|
|
Datasette actually emits.
|
|
|
|
|
|
|
|
|
|
This is the test that makes the generated documentation trustworthy. cog
|
|
|
|
|
guarantees the docs match the registry; this guarantees the registry matches
|
|
|
|
|
the code. Without it, both could agree with each other and be wrong.
|
|
|
|
|
|
|
|
|
|
It checks both directions, and the second one is the one nothing else catches:
|
|
|
|
|
|
|
|
|
|
- **emitted but not registered** - instrumentation was added without
|
|
|
|
|
documenting it, so the reference page silently omits it.
|
|
|
|
|
- **registered but never emitted** - the reference page describes a span or
|
|
|
|
|
attribute that no longer exists, which is worse than omitting it, because a
|
|
|
|
|
reader will build a dashboard on it.
|
|
|
|
|
|
|
|
|
|
Both of those directions compare the code against the registry. Neither can
|
|
|
|
|
catch a *rename*, because the call sites now take their names from the
|
|
|
|
|
registry - move `DB_NAMESPACE` to `"db.namespace2"` and code and registry
|
|
|
|
|
still agree with each other, while every existing dashboard breaks. So the
|
|
|
|
|
literal names live here too, spelled out, and are asserted against both the
|
|
|
|
|
registry and the wire. That is the one comparison in this file that is not
|
|
|
|
|
made against a value derived from the registry itself.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import itertools
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
import pytest_asyncio
|
|
|
|
|
|
|
|
|
|
pytest.importorskip("opentelemetry.sdk")
|
|
|
|
|
|
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
|
|
|
from opentelemetry.trace import SpanKind
|
|
|
|
|
|
|
|
|
|
from datasette import hookimpl
|
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
|
|
|
from datasette import telemetry_registry as reg
|
|
|
|
|
from datasette.app import Datasette
|
|
|
|
|
from datasette.database import QueryInterrupted
|
|
|
|
|
from datasette.utils.sqlite import sqlite3
|
|
|
|
|
|
|
|
|
|
# The names as they appear on the wire, written out rather than read from the
|
|
|
|
|
# registry. If a change to the registry makes one of these fail, that change
|
|
|
|
|
# is renaming something a user's dashboards and saved queries depend on -
|
|
|
|
|
# which is a decision to take deliberately, here, not a line to re-derive.
|
|
|
|
|
EXPECTED_ATTRIBUTES = {
|
|
|
|
|
"db.query": {
|
|
|
|
|
"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
|
|
|
"datasette.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",
|
|
|
|
|
"datasette.param_count",
|
|
|
|
|
"datasette.param_sets",
|
|
|
|
|
"datasette.time_limit_ms",
|
|
|
|
|
"datasette.rows_returned",
|
|
|
|
|
"datasette.truncated",
|
|
|
|
|
"datasette.interrupted",
|
|
|
|
|
"datasette.sql_error_suppressed",
|
|
|
|
|
"datasette.executescript",
|
|
|
|
|
"datasette.executemany",
|
|
|
|
|
},
|
|
|
|
|
"db.query.execute": set(),
|
|
|
|
|
"db.write.queue_wait": set(),
|
|
|
|
|
"db.write.execute": {
|
|
|
|
|
"datasette.isolated_connection",
|
|
|
|
|
"datasette.transaction",
|
|
|
|
|
},
|
|
|
|
|
"datasette.startup": set(),
|
|
|
|
|
}
|
|
|
|
|
EXPECTED_SPANS = set(EXPECTED_ATTRIBUTES)
|
|
|
|
|
|
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
|
|
|
# The HTTP request span is handled separately because its name is composed at
|
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
|
|
|
# runtime - the request method, then the route it matched - so there is no
|
|
|
|
|
# fixed string to pin it to. What can still be pinned, and is what a dashboard
|
|
|
|
|
# depends on, is the shape of that name and the attribute keys.
|
|
|
|
|
#
|
|
|
|
|
# The route half is deliberately not spelled out as a literal: it is a core
|
|
|
|
|
# route regex, and pinning those here would make an unrelated routing change
|
|
|
|
|
# fail the telemetry conformance test. What is pinned instead is that the name
|
|
|
|
|
# is exactly the method, a space, and the span's own `http.route` value - the
|
|
|
|
|
# `{method} {route}` shape semantic conventions specify. The workload below
|
|
|
|
|
# only issues GETs, so a change that stopped clamping the method, or that
|
|
|
|
|
# started naming the span after the path, fails here.
|
|
|
|
|
EXPECTED_HTTP_SPAN_NAME = "{http.request.method} {http.route}"
|
|
|
|
|
EXPECTED_HTTP_METHOD_NAMES = {"GET"}
|
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
|
|
|
EXPECTED_HTTP_ATTRIBUTES = {
|
|
|
|
|
"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",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# The registry's own name for the request span is that template, not anything
|
|
|
|
|
# that appears on the wire.
|
|
|
|
|
EXPECTED_REGISTRY_ATTRIBUTES = dict(
|
|
|
|
|
EXPECTED_ATTRIBUTES, **{EXPECTED_HTTP_SPAN_NAME: EXPECTED_HTTP_ATTRIBUTES}
|
|
|
|
|
)
|
|
|
|
|
EXPECTED_REGISTRY_NAMES = set(EXPECTED_REGISTRY_ATTRIBUTES)
|
|
|
|
|
|
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
|
|
|
# Named in-memory databases are shared-cache, so two Datasette instances using
|
|
|
|
|
# the same name share one SQLite database - and the second `create table`
|
|
|
|
|
# fails. Every workload below therefore gets its own name.
|
|
|
|
|
_names = itertools.count()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _unique(prefix):
|
|
|
|
|
return f"{prefix}{next(_names)}"
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
class _BoomPlugin:
|
|
|
|
|
"""
|
|
|
|
|
A route that raises.
|
|
|
|
|
|
|
|
|
|
`error.type` on the request span is only ever set by a 5xx, and nothing
|
|
|
|
|
in Datasette returns one on a healthy instance - `route_path` converts
|
|
|
|
|
exceptions into a 500 itself, so the workload has to supply the
|
|
|
|
|
exception.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__name__ = "TelemetryRegistryBoomPlugin"
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
|
|
|
|
def register_routes(self):
|
|
|
|
|
return [(r"^/-/telemetry-registry-boom$", lambda: 1 / 0)]
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
async def exercise():
|
|
|
|
|
"""
|
|
|
|
|
Drive enough of Datasette to emit every span and attribute the registry
|
|
|
|
|
claims exists.
|
|
|
|
|
|
|
|
|
|
Each call is here because it is the only thing that produces some span or
|
|
|
|
|
attribute - see the comments. If you add instrumentation on a path this
|
|
|
|
|
does not reach, add the path rather than loosening the assertions.
|
|
|
|
|
|
|
|
|
|
Returns the instance so the caller can close it; startup happens inside
|
|
|
|
|
so that the `datasette.startup` span lands in the collected set.
|
|
|
|
|
"""
|
|
|
|
|
name = _unique("registry")
|
|
|
|
|
ds = Datasette(memory=True)
|
|
|
|
|
ds.add_memory_database(name)
|
|
|
|
|
# datasette.startup - and the internal catalog work nested under it
|
|
|
|
|
await ds.invoke_startup()
|
|
|
|
|
db = ds.get_database(name)
|
|
|
|
|
|
|
|
|
|
# Writes: db.write.queue_wait, db.write.execute, db.query
|
|
|
|
|
await db.execute_write("create table t (id integer primary key, v text)")
|
|
|
|
|
# datasette.executemany, datasette.param_sets
|
|
|
|
|
await db.execute_write_many(
|
|
|
|
|
"insert into t (id, v) values (?, ?)", [[i, f"v{i}"] for i in range(30)]
|
|
|
|
|
)
|
|
|
|
|
# datasette.executescript
|
|
|
|
|
await db.execute_write_script("create table t2 (id integer); drop table t2;")
|
|
|
|
|
# datasette.transaction=False - VACUUM cannot run inside a transaction
|
|
|
|
|
await db.execute_write("vacuum", transaction=False)
|
|
|
|
|
# datasette.isolated_connection=True
|
|
|
|
|
await db.execute_isolated_fn(lambda conn: conn.execute("select 1").fetchone())
|
|
|
|
|
|
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
|
|
|
# datasette.callback, with named functions so the conformance run sees the
|
|
|
|
|
# attribute's documented value shape (a qualname, not just "<lambda>")
|
|
|
|
|
def registry_read_callback(conn):
|
|
|
|
|
return conn.execute("select count(*) from t").fetchone()
|
|
|
|
|
|
|
|
|
|
def registry_write_callback(conn):
|
|
|
|
|
conn.execute("insert into t (id, v) values (100, 'callback')")
|
|
|
|
|
|
|
|
|
|
await db.execute_fn(registry_read_callback)
|
|
|
|
|
await db.execute_write_fn(registry_write_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
|
|
|
# Reads: db.query.execute, datasette.rows_returned, datasette.truncated,
|
|
|
|
|
# datasette.param_count, datasette.time_limit_ms
|
|
|
|
|
await db.execute("select * from t where id > :n", {"n": 5})
|
|
|
|
|
await db.execute("select * from t", truncate=True)
|
|
|
|
|
|
|
|
|
|
# datasette.sql_error_suppressed - the caller is probing and treats
|
|
|
|
|
# failure as an expected answer
|
|
|
|
|
with pytest.raises(sqlite3.OperationalError):
|
|
|
|
|
await db.execute("select nope from t", log_sql_errors=False)
|
|
|
|
|
|
|
|
|
|
# datasette.interrupted - only ever set when a query exceeds its time
|
|
|
|
|
# limit, so the workload has to force one rather than exempt it. An
|
|
|
|
|
# unbounded recursive CTE cannot finish, so 1ms is always exceeded.
|
|
|
|
|
with pytest.raises(QueryInterrupted):
|
|
|
|
|
await db.execute(
|
|
|
|
|
"with recursive c(x) as (select 0 union all select x+1 from c) "
|
|
|
|
|
"select * from c",
|
|
|
|
|
custom_time_limit=1,
|
|
|
|
|
)
|
|
|
|
|
|
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
|
|
|
# These requests produce the HTTP request span and its
|
|
|
|
|
# http.request.method / url.path / url.scheme / server.address /
|
|
|
|
|
# user_agent.original / http.response.status_code attributes.
|
|
|
|
|
assert (await ds.client.get(f"/{name}/t?_facet=v")).status_code == 200
|
|
|
|
|
assert (await ds.client.get(f"/{name}/t/1.json")).status_code == 200
|
|
|
|
|
|
|
|
|
|
# error.type on the request span, which only a 5xx sets
|
|
|
|
|
ds.pm.register(_BoomPlugin(), name="telemetry-registry-boom")
|
|
|
|
|
try:
|
|
|
|
|
response = await ds.client.get("/-/telemetry-registry-boom")
|
|
|
|
|
assert response.status_code == 500
|
|
|
|
|
finally:
|
|
|
|
|
ds.pm.unregister(name="telemetry-registry-boom")
|
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 ds
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def emitted(otel_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
|
|
|
"""
|
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
|
|
|
Every (span name, span kind, attributes) triple a broad workload emits.
|
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
|
|
|
|
|
|
|
|
The kind is carried because the request span's name is composed at
|
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
|
|
|
runtime, so `span_for()` resolves it by kind instead. The attributes are
|
|
|
|
|
carried as a mapping rather than a set of keys because the request span's
|
|
|
|
|
name has to be checked against its own `http.route` value.
|
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
|
|
|
"""
|
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
|
|
|
# otel_spans has already cleared the exporter, and nothing is cleared
|
|
|
|
|
# after this point: the workload's own startup emits datasette.startup.
|
|
|
|
|
ds = await exercise()
|
|
|
|
|
spans = otel_spans.get_finished_spans()
|
|
|
|
|
assert spans, "no spans captured - the fixture is not exercising anything"
|
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
|
|
|
# str() because span.name is the registry's SpanName instance, and a set
|
|
|
|
|
# of those would compare equal to literals but read confusingly in a
|
|
|
|
|
# failure message.
|
|
|
|
|
collected = tuple(
|
|
|
|
|
(
|
|
|
|
|
str(span.name),
|
|
|
|
|
span.kind,
|
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
|
|
|
{str(key): value for key, value in (span.attributes or {}).items()},
|
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
|
|
|
)
|
|
|
|
|
for span in spans
|
|
|
|
|
)
|
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
|
|
|
ds.close()
|
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
|
|
|
return collected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _partition(emitted):
|
|
|
|
|
"The statically named spans, and the dynamically named request spans."
|
|
|
|
|
static = [record for record in emitted if record[1] is not SpanKind.SERVER]
|
|
|
|
|
server = [record for record in emitted if record[1] is SpanKind.SERVER]
|
|
|
|
|
return static, 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
|
|
|
|
|
|
|
|
|
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 _keys_by_span(records):
|
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
|
|
|
by_span = {}
|
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
|
|
|
for name, _kind, attributes in records:
|
|
|
|
|
by_span.setdefault(name, set()).update(attributes)
|
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 by_span
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_workload_emits_exactly_the_expected_names(emitted):
|
|
|
|
|
"""
|
|
|
|
|
The wire format, pinned to literals.
|
|
|
|
|
|
|
|
|
|
Not derived from the registry, so this is what catches a rename that the
|
|
|
|
|
registry and the call sites make together.
|
|
|
|
|
"""
|
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
|
|
|
static, server = _partition(emitted)
|
|
|
|
|
by_span = _keys_by_span(static)
|
|
|
|
|
assert set(by_span) == EXPECTED_SPANS
|
|
|
|
|
assert by_span == EXPECTED_ATTRIBUTES
|
|
|
|
|
|
|
|
|
|
assert server, "the workload made HTTP requests but no SERVER span was emitted"
|
|
|
|
|
union = set()
|
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
|
|
|
methods = set()
|
|
|
|
|
for name, _kind, attributes in server:
|
|
|
|
|
union |= set(attributes)
|
|
|
|
|
route = attributes.get("http.route")
|
|
|
|
|
# Every request in the workload matches a route, so every one of these
|
|
|
|
|
# names must be `{method} {route}`. A 404 would be a bare method - the
|
|
|
|
|
# http_route tests cover that case with a real request.
|
|
|
|
|
assert route, f"the request span {name!r} carries no http.route"
|
|
|
|
|
method, _, name_route = name.partition(" ")
|
|
|
|
|
assert name_route == route, (
|
|
|
|
|
f"the request span is named {name!r}, which is not the "
|
|
|
|
|
f"`{{method}} {{route}}` of {method!r} and {route!r}"
|
|
|
|
|
)
|
|
|
|
|
methods.add(method)
|
|
|
|
|
assert methods == EXPECTED_HTTP_METHOD_NAMES
|
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
|
|
|
assert union == EXPECTED_HTTP_ATTRIBUTES
|
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 test_registry_matches_the_expected_names():
|
|
|
|
|
"The other half of the rename check: the registry against the same literals."
|
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
|
|
|
assert {str(span) for span in reg.SPANS} == EXPECTED_REGISTRY_NAMES
|
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
|
|
|
for span in reg.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
|
|
|
assert {
|
|
|
|
|
str(attribute) for attribute in span.attributes
|
|
|
|
|
} == EXPECTED_REGISTRY_ATTRIBUTES[str(span)], f"{span} attributes have drifted"
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_every_emitted_span_is_registered(emitted):
|
|
|
|
|
"A span added without a registry entry would be missing from the docs."
|
|
|
|
|
unregistered = sorted(
|
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
|
|
|
{name for name, kind, _ in emitted if reg.span_for(name, kind) 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
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
not unregistered
|
|
|
|
|
), f"these spans are emitted but not in telemetry_registry.SPANS: {unregistered}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_every_emitted_attribute_is_registered(emitted):
|
|
|
|
|
"An attribute added without a registry entry would be missing from the docs."
|
|
|
|
|
unregistered = sorted(
|
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
|
|
|
{
|
|
|
|
|
f"{name} -> {key}"
|
|
|
|
|
for name, kind, keys in emitted
|
|
|
|
|
for key in keys
|
|
|
|
|
if not reg.attribute_allowed(reg.span_for(name, kind), key)
|
|
|
|
|
}
|
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
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
not unregistered
|
|
|
|
|
), "these span attributes are emitted but not registered: " + ", ".join(
|
|
|
|
|
unregistered
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_every_registered_span_is_emitted(emitted):
|
|
|
|
|
"""
|
|
|
|
|
The direction nothing else catches: the docs must not describe a span that
|
|
|
|
|
no longer exists.
|
|
|
|
|
"""
|
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
|
|
|
# By identity, not by name: a dynamic entry's own string never appears on
|
|
|
|
|
# the wire, so comparing strings would be comparing the wrong things.
|
|
|
|
|
resolved = {id(reg.span_for(name, kind)) for name, kind, _ in emitted}
|
|
|
|
|
missing = sorted(str(span) for span in reg.SPANS if id(span) not in resolved)
|
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
|
|
|
assert not missing, (
|
|
|
|
|
f"these spans are documented but never emitted by the workload: {missing}. "
|
|
|
|
|
"Either the instrumentation was removed, or exercise() no longer reaches it."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_every_registered_attribute_is_emitted(emitted):
|
|
|
|
|
"""
|
|
|
|
|
Every registered attribute, optional or not, must actually be set at least
|
|
|
|
|
once by the workload.
|
|
|
|
|
|
|
|
|
|
`optional` describes whether a reader should expect it on every span, not
|
|
|
|
|
whether the code still sets it - so an attribute deleted from the code but
|
|
|
|
|
left in the docs has to fail here even when it is marked optional. If a
|
|
|
|
|
new attribute only appears in some rare case, extend exercise() to reach
|
|
|
|
|
that case.
|
|
|
|
|
"""
|
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
|
|
|
by_entry = {}
|
|
|
|
|
for name, kind, keys in emitted:
|
|
|
|
|
entry = reg.span_for(name, kind)
|
|
|
|
|
if entry is not None:
|
|
|
|
|
by_entry.setdefault(id(entry), set()).update(keys)
|
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
|
|
|
missing = []
|
|
|
|
|
for span in reg.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
|
|
|
emitted_keys = by_entry.get(id(span), set())
|
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
|
|
|
for attribute in span.attributes:
|
|
|
|
|
if attribute not in emitted_keys:
|
|
|
|
|
missing.append(f"{span} -> {attribute}")
|
|
|
|
|
assert not missing, (
|
|
|
|
|
"these attributes are documented but never emitted by the workload: "
|
|
|
|
|
+ ", ".join(sorted(missing))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_registry_has_no_duplicate_names():
|
|
|
|
|
assert len(set(reg.SPANS)) == len(reg.SPANS)
|
|
|
|
|
for span in reg.SPANS:
|
|
|
|
|
assert len(set(span.attributes)) == len(
|
|
|
|
|
span.attributes
|
|
|
|
|
), f"{span} lists an attribute twice"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_registry_entries_are_documented():
|
|
|
|
|
"Every entry carries a description - the docs are generated from these."
|
|
|
|
|
for span in reg.SPANS:
|
|
|
|
|
assert span.description.strip(), f"{span} has no description"
|
|
|
|
|
for attribute in span.attributes:
|
|
|
|
|
assert attribute.description.strip(), f"{span} -> {attribute} has none"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_registry_entries_are_usable_as_plain_strings():
|
|
|
|
|
"The str subclassing is the whole reason call sites need no wrapper API."
|
|
|
|
|
assert isinstance(reg.DB_QUERY, str)
|
|
|
|
|
assert isinstance(reg.DB_NAMESPACE, str)
|
|
|
|
|
assert reg.DB_QUERY == "db.query"
|
|
|
|
|
assert reg.DB_NAMESPACE == "db.namespace"
|
|
|
|
|
assert f"{reg.DB_QUERY}.execute" == "db.query.execute"
|
|
|
|
|
|
|
|
|
|
|
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 test_dynamic_span_lookup():
|
|
|
|
|
"""
|
|
|
|
|
`dynamic=True` matching, which is how the request span resolves.
|
|
|
|
|
|
|
|
|
|
The last two assertions are the ones worth having: a dynamic entry must
|
|
|
|
|
not swallow a span that does have a registered name, and must not match at
|
|
|
|
|
all when the caller supplies no kind - otherwise every unregistered span
|
|
|
|
|
in the suite would silently resolve to the request span and the
|
|
|
|
|
emitted-but-not-registered direction would stop catching anything.
|
|
|
|
|
"""
|
|
|
|
|
assert reg.span_for("GET", SpanKind.SERVER) is reg.HTTP_REQUEST
|
|
|
|
|
assert reg.span_for("POST /^/(?P<database>[^/]+)$", SpanKind.SERVER) is (
|
|
|
|
|
reg.HTTP_REQUEST
|
|
|
|
|
)
|
|
|
|
|
assert reg.span_for("GET") is None
|
|
|
|
|
assert reg.span_for("anything at all", SpanKind.INTERNAL) is None
|
|
|
|
|
assert reg.span_for("db.query", SpanKind.SERVER) is reg.DB_QUERY
|
|
|
|
|
|
|
|
|
|
|
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 test_span_and_attribute_lookup():
|
|
|
|
|
assert reg.span_for("db.query") is reg.DB_QUERY
|
|
|
|
|
assert reg.span_for("datasette.startup") is reg.STARTUP
|
|
|
|
|
assert reg.span_for("not.a.datasette.span") is None
|
|
|
|
|
assert reg.attribute_allowed(reg.DB_QUERY, "db.namespace")
|
|
|
|
|
assert not reg.attribute_allowed(reg.DB_QUERY, "db.namespace.extra")
|
|
|
|
|
assert not reg.attribute_allowed(reg.DB_QUERY, "datasette.isolated_connection")
|
|
|
|
|
assert not reg.attribute_allowed(None, "db.namespace")
|