Add opentelemetry-api dependency and datasette/telemetry.py scaffolding
Datasette core is gaining OpenTelemetry spans alongside the existing
hand-rolled tracer. This commit only lays the groundwork - no span is
emitted yet.
Core takes a runtime dependency on opentelemetry-api and nothing more.
It deliberately never creates a TracerProvider, configures an exporter,
or touches sampling: that belongs to whoever runs Datasette, normally
via an opentelemetry-instrument agent. Owning a provider in core was
tried in an earlier design and produced a cross-request span leak, a
process-global provider that tests could not tear down, and a sampling
env var that silently blanked output. With no provider installed every
span is a NonRecordingSpan and costs approximately nothing.
datasette/telemetry.py exposes the module-level tracer plus
sql_attribute(), which truncates SQL to 2048 characters. On a public
instance the SQL is attacker-controlled and unbounded - someone can
paste a 10MB query into ?sql= - so it must never reach a telemetry
pipeline verbatim.
opentelemetry-sdk goes in the dev dependency group only, because the
test suite needs it to assert on spans while the package itself must
not import it. tests/test_telemetry.py enforces that by importing
datasette in a fresh interpreter and inspecting sys.modules, which
catches a lazy import inside a function body that a grep would miss.
conftest.py gains a session-scoped autouse fixture installing an SDK
provider with an InMemorySpanExporter. It has to be session-scoped
because set_tracer_provider() is effectively once-per-process - a
second call logs a warning and is ignored. SimpleSpanProcessor rather
than BatchSpanProcessor, so assertions made right after a request never
race a background export thread. The otel_spans fixture that later
tickets assert against is added here too.
test_datasette_package_never_imports_the_sdk is moved to the front of
the run. Late in a serial run the pytest process holds enough threads
that the fork half of subprocess' fork+exec segfaults the interpreter
on macOS/CPython 3.13. That reproduces with any subprocess call in that
position on an unmodified tree, so it is a pre-existing hazard rather
than something this commit introduces; the repo already moves its other
subprocess-spawning tests to the front for related reasons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:52 -07:00
|
|
|
"""
|
|
|
|
|
OpenTelemetry integration for Datasette core.
|
|
|
|
|
|
|
|
|
|
Core depends on `opentelemetry-api` only. It never creates a
|
|
|
|
|
`TracerProvider`, never configures an exporter, and never touches
|
|
|
|
|
sampling - that is the responsibility of whoever is running Datasette
|
|
|
|
|
(an `opentelemetry-instrument` agent, a future plugin, or a test
|
Document what the database-layer spans emit, and how to turn them on
The span reference itself is generated from the registry, so this adds the
prose the generated list cannot supply: how to actually see a span, what is
deliberately never recorded, and where the instrumentation stops short.
The "how to turn it on" part is the part people get wrong. Core installs no
provider, so OTEL_TRACES_EXPORTER=console against a plain `datasette` process
emits nothing at all - that variable is read by the SDK auto-configuration
which only runs under `opentelemetry-instrument`. Documented as a warning
because it reads like a bug when you hit it. Two more measured facts get the
same treatment: the SDK's BatchSpanProcessor default schedule delay is 5000ms
(checked, not assumed - `BatchSpanProcessor._default_schedule_delay_millis()`
on opentelemetry-sdk 1.44), so nothing appears for five seconds; and without
OTEL_SERVICE_NAME the default resource reports service.name=unknown_service.
Privacy properties are stated positively rather than left implicit: SQL
truncated at 2048 characters, parameter values never recorded, no actor
identifiers, table names only from an explicit `table=` argument. The last of
those is now documented on db.execute() itself, since it is public API.
The limitations section claims only what was measured. An earlier draft said
two traces per process are orphaned by the register_output_renderer and
asgi_wrapper hooks; measuring it showed a default install emits zero spans
from either, because Datasette queries no database there - it is a plugin
that would produce the orphan. Corrected to say that.
It also deliberately does NOT say an embedder must install its provider
before Datasette's first span or get nothing. That claim is false:
ProxyTracer._tracer returns the no-op tracer without caching it when no
provider is set, so early spans are dropped and nothing is poisoned.
The telemetry.py docstring said no-op spans "cost approximately nothing".
The benchmark for this diff does not support a claim that strong - a table
page emits ~58 spans - so it now states the measurement instead: median
9.80ms to 9.98ms across 15 runs, inside a 1.4ms run-to-run spread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:10:04 -07:00
|
|
|
harness).
|
|
|
|
|
|
|
|
|
|
With no provider installed every span produced here is a
|
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
|
|
|
`NonRecordingSpan`. That is not free - a table page emits ~100 spans -
|
2026-09-02 11:25:10 -07:00
|
|
|
but end-to-end page benchmarks put the overhead below their own
|
|
|
|
|
run-to-run variation. Installing an SDK provider is what costs
|
|
|
|
|
something measurable.
|
Add opentelemetry-api dependency and datasette/telemetry.py scaffolding
Datasette core is gaining OpenTelemetry spans alongside the existing
hand-rolled tracer. This commit only lays the groundwork - no span is
emitted yet.
Core takes a runtime dependency on opentelemetry-api and nothing more.
It deliberately never creates a TracerProvider, configures an exporter,
or touches sampling: that belongs to whoever runs Datasette, normally
via an opentelemetry-instrument agent. Owning a provider in core was
tried in an earlier design and produced a cross-request span leak, a
process-global provider that tests could not tear down, and a sampling
env var that silently blanked output. With no provider installed every
span is a NonRecordingSpan and costs approximately nothing.
datasette/telemetry.py exposes the module-level tracer plus
sql_attribute(), which truncates SQL to 2048 characters. On a public
instance the SQL is attacker-controlled and unbounded - someone can
paste a 10MB query into ?sql= - so it must never reach a telemetry
pipeline verbatim.
opentelemetry-sdk goes in the dev dependency group only, because the
test suite needs it to assert on spans while the package itself must
not import it. tests/test_telemetry.py enforces that by importing
datasette in a fresh interpreter and inspecting sys.modules, which
catches a lazy import inside a function body that a grep would miss.
conftest.py gains a session-scoped autouse fixture installing an SDK
provider with an InMemorySpanExporter. It has to be session-scoped
because set_tracer_provider() is effectively once-per-process - a
second call logs a warning and is ignored. SimpleSpanProcessor rather
than BatchSpanProcessor, so assertions made right after a request never
race a background export thread. The otel_spans fixture that later
tickets assert against is added here too.
test_datasette_package_never_imports_the_sdk is moved to the front of
the run. Late in a serial run the pytest process holds enough threads
that the fork half of subprocess' fork+exec segfaults the interpreter
on macOS/CPython 3.13. That reproduces with any subprocess call in that
position on an unmodified tree, so it is a pre-existing hazard rather
than something this commit introduces; the repo already moves its other
subprocess-spawning tests to the front for related reasons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:52 -07:00
|
|
|
"""
|
|
|
|
|
|
2026-09-02 14:20:32 -07:00
|
|
|
import contextvars
|
Make db.query spans match OpenTelemetry semantic conventions
Three corrections to the emitted data, bundled because changing what is on
the wire after operators have built dashboards on it is a breaking change -
so they belong in the first release that ships spans at all, not a later one.
db.query is now SpanKind.CLIENT. Trace UIs key their database rendering off
the span kind rather than off db.system, so the spans rendered as ordinary
internal work despite carrying db.system and db.query.text. The three child
spans stay INTERNAL on purpose: db.query.execute, db.write.execute and
db.write.queue_wait are Datasette's decomposition of one logical query, not
three database calls, and queue_wait touches no database at all - marking
them CLIENT would make one query look like several to anything counting
spans by kind.
The instrumentation scope now carries the Datasette version and a schema
URL, so a backend can tell which Datasette produced a span. The URL is
1.29.0 rather than the latest semconv release because that is the highest
version at which every name emitted here is the current spelling: db.system
was renamed to db.system.name in 1.30.0 and this code still emits the older
form. Claiming a later schema would be false, and would stop a consumer
translating that name forward, since the claim asserts the rename already
happened.
db.operation.name is the statement's leading keyword matched against a fixed
allowlist, not a parse. On a public instance the SQL is attacker-controlled
and this attribute is a candidate metric dimension in a later phase, so
echoing back an arbitrary first token would let a visitor's typo mint a
permanent series. Anything unrecognised gets no attribute rather than a
wrong one. execute_write_script() does not set it at all, since semantic
conventions say not to extract an operation name from query text that can
hold several statements.
db.collection.name comes only from a new table= argument on
Database.execute(), and is never derived from the SQL: deriving it would be
a parse, and on an instance where anyone can create a table the value set
has no ceiling. It is passed from every query in the table and row views
that targets exactly one user table. Internal-catalog reads and the row
view's cross-table foreign key counts are deliberately left without it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:29:16 -07:00
|
|
|
import re
|
|
|
|
|
|
Add opentelemetry-api dependency and datasette/telemetry.py scaffolding
Datasette core is gaining OpenTelemetry spans alongside the existing
hand-rolled tracer. This commit only lays the groundwork - no span is
emitted yet.
Core takes a runtime dependency on opentelemetry-api and nothing more.
It deliberately never creates a TracerProvider, configures an exporter,
or touches sampling: that belongs to whoever runs Datasette, normally
via an opentelemetry-instrument agent. Owning a provider in core was
tried in an earlier design and produced a cross-request span leak, a
process-global provider that tests could not tear down, and a sampling
env var that silently blanked output. With no provider installed every
span is a NonRecordingSpan and costs approximately nothing.
datasette/telemetry.py exposes the module-level tracer plus
sql_attribute(), which truncates SQL to 2048 characters. On a public
instance the SQL is attacker-controlled and unbounded - someone can
paste a 10MB query into ?sql= - so it must never reach a telemetry
pipeline verbatim.
opentelemetry-sdk goes in the dev dependency group only, because the
test suite needs it to assert on spans while the package itself must
not import it. tests/test_telemetry.py enforces that by importing
datasette in a fresh interpreter and inspecting sys.modules, which
catches a lazy import inside a function body that a grep would miss.
conftest.py gains a session-scoped autouse fixture installing an SDK
provider with an InMemorySpanExporter. It has to be session-scoped
because set_tracer_provider() is effectively once-per-process - a
second call logs a warning and is ignored. SimpleSpanProcessor rather
than BatchSpanProcessor, so assertions made right after a request never
race a background export thread. The otel_spans fixture that later
tickets assert against is added here too.
test_datasette_package_never_imports_the_sdk is moved to the front of
the run. Late in a serial run the pytest process holds enough threads
that the fork half of subprocess' fork+exec segfaults the interpreter
on macOS/CPython 3.13. That reproduces with any subprocess call in that
position on an unmodified tree, so it is a pre-existing hazard rather
than something this commit introduces; the repo already moves its other
subprocess-spawning tests to the front for related reasons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:52 -07:00
|
|
|
from opentelemetry import trace as otel_trace
|
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.propagate import extract
|
|
|
|
|
from opentelemetry.propagators.textmap import Getter
|
|
|
|
|
from opentelemetry.trace import SpanKind, Status, StatusCode
|
Add opentelemetry-api dependency and datasette/telemetry.py scaffolding
Datasette core is gaining OpenTelemetry spans alongside the existing
hand-rolled tracer. This commit only lays the groundwork - no span is
emitted yet.
Core takes a runtime dependency on opentelemetry-api and nothing more.
It deliberately never creates a TracerProvider, configures an exporter,
or touches sampling: that belongs to whoever runs Datasette, normally
via an opentelemetry-instrument agent. Owning a provider in core was
tried in an earlier design and produced a cross-request span leak, a
process-global provider that tests could not tear down, and a sampling
env var that silently blanked output. With no provider installed every
span is a NonRecordingSpan and costs approximately nothing.
datasette/telemetry.py exposes the module-level tracer plus
sql_attribute(), which truncates SQL to 2048 characters. On a public
instance the SQL is attacker-controlled and unbounded - someone can
paste a 10MB query into ?sql= - so it must never reach a telemetry
pipeline verbatim.
opentelemetry-sdk goes in the dev dependency group only, because the
test suite needs it to assert on spans while the package itself must
not import it. tests/test_telemetry.py enforces that by importing
datasette in a fresh interpreter and inspecting sys.modules, which
catches a lazy import inside a function body that a grep would miss.
conftest.py gains a session-scoped autouse fixture installing an SDK
provider with an InMemorySpanExporter. It has to be session-scoped
because set_tracer_provider() is effectively once-per-process - a
second call logs a warning and is ignored. SimpleSpanProcessor rather
than BatchSpanProcessor, so assertions made right after a request never
race a background export thread. The otel_spans fixture that later
tickets assert against is added here too.
test_datasette_package_never_imports_the_sdk is moved to the front of
the run. Late in a serial run the pytest process holds enough threads
that the fork half of subprocess' fork+exec segfaults the interpreter
on macOS/CPython 3.13. That reproduces with any subprocess call in that
position on an unmodified tree, so it is a pre-existing hazard rather
than something this commit introduces; the repo already moves its other
subprocess-spawning tests to the front for related reasons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:52 -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
|
|
|
from .telemetry_registry import (
|
|
|
|
|
ERROR_TYPE,
|
|
|
|
|
HTTP_REQUEST_METHOD,
|
|
|
|
|
HTTP_RESPONSE_STATUS_CODE,
|
2026-09-02 14:20:32 -07:00
|
|
|
INTERNAL_CLIENT,
|
Give every span a request to belong to
Nothing in Datasette created a span for the HTTP request itself, so every
span the database layer emits was a root span. Measured on this branch: one
faceted table page produces 70 spans in 36 separate traces, none of which
carries a URL. A trace UI shows that as dozens of unrelated single-span
traces per page, interleaved across concurrent requests - worse than
?_trace=1 at the exact job people reach for tracing to do. With the request
span it is 71 spans in 1 trace.
`opentelemetry-instrument` does not fix this on its own: auto-instrumentation
only picks up frameworks that ship an instrumentor entry point, and
Datasette's raw ASGI app is not one.
TelemetryMiddleware is mounted outermost in Datasette.app(), after the
asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run
*inside* the span. Putting it in DatasetteRouter instead would leave a span
created by an instrumented plugin as an orphan root - reintroducing the
problem for exactly the code most likely to be instrumented.
It stays at ~90 lines, against roughly 700 for
opentelemetry-instrumentation-asgi, because Datasette's app does not return
before its body is sent: route_path awaits response.asgi_send(send), and a
streaming CSV export runs its generator inline inside AsgiStream.asgi_send.
So a plain `finally` covers the response body and no deferred-end machinery
is needed.
Two decisions worth flagging for review:
- Inbound W3C traceparent and baggage are extracted, using the *global*
propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI
instrumentation), and going through the global propagator leaves the
operator in control with no Datasette setting to invent:
OTEL_PROPAGATORS=none disables it entirely. A public instance that does
not want client-influenced traces should strip those headers at the proxy.
- url.query is not recorded, anywhere. Datasette query strings carry
user-supplied SQL in ?sql= and canned query parameters. client.address is
not recorded either.
The status code is sniffed from the ASGI http.response.start message rather
than read off a Response, because asgi_static, the favicon route, AsgiStream
and AsgiFileDownload all send that message themselves and never build one.
Only a >= 500 sets an error status - per semantic conventions a 4xx is the
client's mistake, and Datasette 404s are routine enough that treating them
as errors would bury a real 500.
The registry gains a `dynamic` flag, because this span's name is composed at
runtime and so can never equal a fixed registry string. Dynamic entries
resolve by span kind instead, and only after exact and prefix matching has
failed, so they cannot shadow a span that does have a registered name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
|
|
|
SERVER_ADDRESS,
|
|
|
|
|
URL_PATH,
|
|
|
|
|
URL_SCHEME,
|
|
|
|
|
USER_AGENT_ORIGINAL,
|
|
|
|
|
)
|
Make db.query spans match OpenTelemetry semantic conventions
Three corrections to the emitted data, bundled because changing what is on
the wire after operators have built dashboards on it is a breaking change -
so they belong in the first release that ships spans at all, not a later one.
db.query is now SpanKind.CLIENT. Trace UIs key their database rendering off
the span kind rather than off db.system, so the spans rendered as ordinary
internal work despite carrying db.system and db.query.text. The three child
spans stay INTERNAL on purpose: db.query.execute, db.write.execute and
db.write.queue_wait are Datasette's decomposition of one logical query, not
three database calls, and queue_wait touches no database at all - marking
them CLIENT would make one query look like several to anything counting
spans by kind.
The instrumentation scope now carries the Datasette version and a schema
URL, so a backend can tell which Datasette produced a span. The URL is
1.29.0 rather than the latest semconv release because that is the highest
version at which every name emitted here is the current spelling: db.system
was renamed to db.system.name in 1.30.0 and this code still emits the older
form. Claiming a later schema would be false, and would stop a consumer
translating that name forward, since the claim asserts the rename already
happened.
db.operation.name is the statement's leading keyword matched against a fixed
allowlist, not a parse. On a public instance the SQL is attacker-controlled
and this attribute is a candidate metric dimension in a later phase, so
echoing back an arbitrary first token would let a visitor's typo mint a
permanent series. Anything unrecognised gets no attribute rather than a
wrong one. execute_write_script() does not set it at all, since semantic
conventions say not to extract an operation name from query text that can
hold several statements.
db.collection.name comes only from a new table= argument on
Database.execute(), and is never derived from the SQL: deriving it would be
a parse, and on an instance where anyone can create a table the value set
has no ceiling. It is passed from every query in the table and row views
that targets exactly one user table. Internal-catalog reads and the row
view's cross-table foreign key counts are deliberately left without it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:29:16 -07:00
|
|
|
from .version import __version__
|
|
|
|
|
|
2026-09-02 14:20:32 -07:00
|
|
|
# True while code is executing within a datasette.client request. Defined
|
|
|
|
|
# here rather than in app.py (which owns its writers and the in_client()
|
|
|
|
|
# accessor) so TelemetryMiddleware can read it without a circular import:
|
|
|
|
|
# an in-process sub-request runs the full ASGI stack, so it emits a second,
|
|
|
|
|
# nested SERVER span - datasette.internal_client marks those so kind-based
|
|
|
|
|
# dashboards can filter the double-count out.
|
|
|
|
|
_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False)
|
|
|
|
|
|
Make db.query spans match OpenTelemetry semantic conventions
Three corrections to the emitted data, bundled because changing what is on
the wire after operators have built dashboards on it is a breaking change -
so they belong in the first release that ships spans at all, not a later one.
db.query is now SpanKind.CLIENT. Trace UIs key their database rendering off
the span kind rather than off db.system, so the spans rendered as ordinary
internal work despite carrying db.system and db.query.text. The three child
spans stay INTERNAL on purpose: db.query.execute, db.write.execute and
db.write.queue_wait are Datasette's decomposition of one logical query, not
three database calls, and queue_wait touches no database at all - marking
them CLIENT would make one query look like several to anything counting
spans by kind.
The instrumentation scope now carries the Datasette version and a schema
URL, so a backend can tell which Datasette produced a span. The URL is
1.29.0 rather than the latest semconv release because that is the highest
version at which every name emitted here is the current spelling: db.system
was renamed to db.system.name in 1.30.0 and this code still emits the older
form. Claiming a later schema would be false, and would stop a consumer
translating that name forward, since the claim asserts the rename already
happened.
db.operation.name is the statement's leading keyword matched against a fixed
allowlist, not a parse. On a public instance the SQL is attacker-controlled
and this attribute is a candidate metric dimension in a later phase, so
echoing back an arbitrary first token would let a visitor's typo mint a
permanent series. Anything unrecognised gets no attribute rather than a
wrong one. execute_write_script() does not set it at all, since semantic
conventions say not to extract an operation name from query text that can
hold several statements.
db.collection.name comes only from a new table= argument on
Database.execute(), and is never derived from the SQL: deriving it would be
a parse, and on an instance where anyone can create a table the value set
has no ceiling. It is passed from every query in the table and row views
that targets exactly one user table. Internal-catalog reads and the row
view's cross-table foreign key counts are deliberately left without it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:29:16 -07:00
|
|
|
# The semantic-convention version whose spellings this instrumentation
|
|
|
|
|
# actually emits. Deliberately NOT the latest release.
|
|
|
|
|
#
|
|
|
|
|
# A schema URL is a machine-readable claim: a consumer doing schema
|
|
|
|
|
# translation replays the renames between the declared version and the one
|
|
|
|
|
# it wants, so the claim has to name the version whose spellings are on the
|
|
|
|
|
# wire. A wrong one makes translation wrong rather than merely uninformative.
|
|
|
|
|
#
|
|
|
|
|
# Datasette emits `db.system`, which was renamed to `db.system.name` in
|
|
|
|
|
# semconv 1.30.0. Everything else it emits (`db.namespace`, `db.query.text`,
|
|
|
|
|
# `db.operation.name`, `db.collection.name`) has been current since 1.26.0.
|
|
|
|
|
# So 1.29.0 is the highest version at which every name emitted here is the
|
|
|
|
|
# current spelling. Everything under `datasette.*` is Datasette's own and
|
|
|
|
|
# outside semconv, so it is unaffected either way.
|
|
|
|
|
#
|
|
|
|
|
# Declaring 1.43.0 would be false about `db.system`, and would actively STOP
|
|
|
|
|
# a consumer translating it forward, because it asserts the rename already
|
|
|
|
|
# happened. Bump this deliberately, in the same commit as the attribute
|
|
|
|
|
# renames it implies - it is a claim about the names, not decoration.
|
|
|
|
|
SCHEMA_URL = "https://opentelemetry.io/schemas/1.29.0"
|
|
|
|
|
|
|
|
|
|
tracer = otel_trace.get_tracer("datasette", __version__, schema_url=SCHEMA_URL)
|
Add opentelemetry-api dependency and datasette/telemetry.py scaffolding
Datasette core is gaining OpenTelemetry spans alongside the existing
hand-rolled tracer. This commit only lays the groundwork - no span is
emitted yet.
Core takes a runtime dependency on opentelemetry-api and nothing more.
It deliberately never creates a TracerProvider, configures an exporter,
or touches sampling: that belongs to whoever runs Datasette, normally
via an opentelemetry-instrument agent. Owning a provider in core was
tried in an earlier design and produced a cross-request span leak, a
process-global provider that tests could not tear down, and a sampling
env var that silently blanked output. With no provider installed every
span is a NonRecordingSpan and costs approximately nothing.
datasette/telemetry.py exposes the module-level tracer plus
sql_attribute(), which truncates SQL to 2048 characters. On a public
instance the SQL is attacker-controlled and unbounded - someone can
paste a 10MB query into ?sql= - so it must never reach a telemetry
pipeline verbatim.
opentelemetry-sdk goes in the dev dependency group only, because the
test suite needs it to assert on spans while the package itself must
not import it. tests/test_telemetry.py enforces that by importing
datasette in a fresh interpreter and inspecting sys.modules, which
catches a lazy import inside a function body that a grep would miss.
conftest.py gains a session-scoped autouse fixture installing an SDK
provider with an InMemorySpanExporter. It has to be session-scoped
because set_tracer_provider() is effectively once-per-process - a
second call logs a warning and is ignored. SimpleSpanProcessor rather
than BatchSpanProcessor, so assertions made right after a request never
race a background export thread. The otel_spans fixture that later
tickets assert against is added here too.
test_datasette_package_never_imports_the_sdk is moved to the front of
the run. Late in a serial run the pytest process holds enough threads
that the fork half of subprocess' fork+exec segfaults the interpreter
on macOS/CPython 3.13. That reproduces with any subprocess call in that
position on an unmodified tree, so it is a pre-existing hazard rather
than something this commit introduces; the repo already moves its other
subprocess-spawning tests to the front for related reasons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:52 -07:00
|
|
|
|
|
|
|
|
MAX_SQL_LENGTH = 2048
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sql_attribute(sql: str) -> str:
|
|
|
|
|
"Truncate SQL text so it is safe to attach to a span as an attribute."
|
|
|
|
|
sql = sql.strip()
|
|
|
|
|
if len(sql) <= MAX_SQL_LENGTH:
|
|
|
|
|
return sql
|
|
|
|
|
return sql[:MAX_SQL_LENGTH] + "…[truncated]"
|
Make db.query spans match OpenTelemetry semantic conventions
Three corrections to the emitted data, bundled because changing what is on
the wire after operators have built dashboards on it is a breaking change -
so they belong in the first release that ships spans at all, not a later one.
db.query is now SpanKind.CLIENT. Trace UIs key their database rendering off
the span kind rather than off db.system, so the spans rendered as ordinary
internal work despite carrying db.system and db.query.text. The three child
spans stay INTERNAL on purpose: db.query.execute, db.write.execute and
db.write.queue_wait are Datasette's decomposition of one logical query, not
three database calls, and queue_wait touches no database at all - marking
them CLIENT would make one query look like several to anything counting
spans by kind.
The instrumentation scope now carries the Datasette version and a schema
URL, so a backend can tell which Datasette produced a span. The URL is
1.29.0 rather than the latest semconv release because that is the highest
version at which every name emitted here is the current spelling: db.system
was renamed to db.system.name in 1.30.0 and this code still emits the older
form. Claiming a later schema would be false, and would stop a consumer
translating that name forward, since the claim asserts the rename already
happened.
db.operation.name is the statement's leading keyword matched against a fixed
allowlist, not a parse. On a public instance the SQL is attacker-controlled
and this attribute is a candidate metric dimension in a later phase, so
echoing back an arbitrary first token would let a visitor's typo mint a
permanent series. Anything unrecognised gets no attribute rather than a
wrong one. execute_write_script() does not set it at all, since semantic
conventions say not to extract an operation name from query text that can
hold several statements.
db.collection.name comes only from a new table= argument on
Database.execute(), and is never derived from the SQL: deriving it would be
a parse, and on an instance where anyone can create a table the value set
has no ceiling. It is passed from every query in the table and row views
that targets exactly one user table. Internal-catalog reads and the row
view's cross-table foreign key counts are deliberately left without it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:29:16 -07:00
|
|
|
|
|
|
|
|
|
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
|
|
|
def callback_name(fn) -> str:
|
|
|
|
|
"""
|
|
|
|
|
The name recorded as `datasette.callback` for a callback-style call.
|
|
|
|
|
|
|
|
|
|
`functools.partial` objects (and other callables) have no `__qualname__`,
|
|
|
|
|
so fall back to the type's name rather than fail the query over telemetry.
|
|
|
|
|
"""
|
|
|
|
|
return getattr(fn, "__qualname__", type(fn).__name__)
|
|
|
|
|
|
|
|
|
|
|
Make db.query spans match OpenTelemetry semantic conventions
Three corrections to the emitted data, bundled because changing what is on
the wire after operators have built dashboards on it is a breaking change -
so they belong in the first release that ships spans at all, not a later one.
db.query is now SpanKind.CLIENT. Trace UIs key their database rendering off
the span kind rather than off db.system, so the spans rendered as ordinary
internal work despite carrying db.system and db.query.text. The three child
spans stay INTERNAL on purpose: db.query.execute, db.write.execute and
db.write.queue_wait are Datasette's decomposition of one logical query, not
three database calls, and queue_wait touches no database at all - marking
them CLIENT would make one query look like several to anything counting
spans by kind.
The instrumentation scope now carries the Datasette version and a schema
URL, so a backend can tell which Datasette produced a span. The URL is
1.29.0 rather than the latest semconv release because that is the highest
version at which every name emitted here is the current spelling: db.system
was renamed to db.system.name in 1.30.0 and this code still emits the older
form. Claiming a later schema would be false, and would stop a consumer
translating that name forward, since the claim asserts the rename already
happened.
db.operation.name is the statement's leading keyword matched against a fixed
allowlist, not a parse. On a public instance the SQL is attacker-controlled
and this attribute is a candidate metric dimension in a later phase, so
echoing back an arbitrary first token would let a visitor's typo mint a
permanent series. Anything unrecognised gets no attribute rather than a
wrong one. execute_write_script() does not set it at all, since semantic
conventions say not to extract an operation name from query text that can
hold several statements.
db.collection.name comes only from a new table= argument on
Database.execute(), and is never derived from the SQL: deriving it would be
a parse, and on an instance where anyone can create a table the value set
has no ceiling. It is passed from every query in the table and row views
that targets exactly one user table. Internal-catalog reads and the row
view's cross-table foreign key counts are deliberately left without it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:29:16 -07:00
|
|
|
# db.operation.name is the leading keyword of a statement matched against a
|
|
|
|
|
# fixed allowlist - deliberately not a parse.
|
|
|
|
|
#
|
|
|
|
|
# This runs against arbitrary user-supplied SQL (the `?sql=` query string,
|
|
|
|
|
# canned queries, anything typed into the query editor), and the attribute is
|
|
|
|
|
# a candidate dimension on a query-duration metric in a later phase. A metric
|
|
|
|
|
# series is keyed by its attribute values, so echoing back an arbitrary first
|
|
|
|
|
# token would let one visitor's typo mint a new, permanent series. The
|
|
|
|
|
# allowlist bounds that at a fixed, small set regardless of what anyone sends.
|
|
|
|
|
DB_OPERATION_ALLOWLIST = frozenset(
|
|
|
|
|
{
|
|
|
|
|
"SELECT",
|
|
|
|
|
"INSERT",
|
|
|
|
|
"UPDATE",
|
|
|
|
|
"DELETE",
|
|
|
|
|
"CREATE",
|
|
|
|
|
"DROP",
|
|
|
|
|
"ALTER",
|
|
|
|
|
"PRAGMA",
|
|
|
|
|
"EXPLAIN",
|
|
|
|
|
"REPLACE",
|
|
|
|
|
"VACUUM",
|
|
|
|
|
"ANALYZE",
|
|
|
|
|
"WITH",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
_LEADING_KEYWORD = re.compile(r"^\s*([A-Za-z]+)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sql_operation_name(sql: str) -> str | None:
|
|
|
|
|
"""
|
|
|
|
|
The statement's leading keyword, if it is one we recognise.
|
|
|
|
|
|
|
|
|
|
Returns None - never a guess - for anything not on the allowlist,
|
|
|
|
|
including a statement that opens with a comment or with punctuation such
|
|
|
|
|
as the "(" of a parenthesised SELECT.
|
|
|
|
|
|
|
|
|
|
Known limitation: a statement beginning with a CTE reports `WITH` rather
|
|
|
|
|
than the operation inside it, and a substantial share of Datasette's own
|
|
|
|
|
reads take that form. Extracting more than the leading keyword means
|
|
|
|
|
handling comment stripping, parenthesised `(SELECT ...) UNION` and
|
|
|
|
|
compound names like `CREATE TABLE` - each a special case a hand-rolled
|
|
|
|
|
matcher would accrete and eventually get wrong. Omitting a name beats
|
|
|
|
|
guessing at one.
|
|
|
|
|
|
|
|
|
|
Only safe to call with a single statement: `execute_write_script()` runs
|
|
|
|
|
several separated by semicolons, and semantic conventions say
|
|
|
|
|
`db.operation.name` "SHOULD NOT be extracted from db.query.text, when the
|
|
|
|
|
database system supports query text with multiple operations in non-batch
|
|
|
|
|
operations" - so that call site does not use this at all rather than
|
|
|
|
|
reporting only the first statement's operation.
|
|
|
|
|
"""
|
|
|
|
|
match = _LEADING_KEYWORD.match(sql)
|
|
|
|
|
if not match:
|
|
|
|
|
return None
|
|
|
|
|
keyword = match.group(1).upper()
|
|
|
|
|
if keyword in DB_OPERATION_ALLOWLIST:
|
|
|
|
|
return keyword
|
|
|
|
|
return None
|
Give every span a request to belong to
Nothing in Datasette created a span for the HTTP request itself, so every
span the database layer emits was a root span. Measured on this branch: one
faceted table page produces 70 spans in 36 separate traces, none of which
carries a URL. A trace UI shows that as dozens of unrelated single-span
traces per page, interleaved across concurrent requests - worse than
?_trace=1 at the exact job people reach for tracing to do. With the request
span it is 71 spans in 1 trace.
`opentelemetry-instrument` does not fix this on its own: auto-instrumentation
only picks up frameworks that ship an instrumentor entry point, and
Datasette's raw ASGI app is not one.
TelemetryMiddleware is mounted outermost in Datasette.app(), after the
asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run
*inside* the span. Putting it in DatasetteRouter instead would leave a span
created by an instrumented plugin as an orphan root - reintroducing the
problem for exactly the code most likely to be instrumented.
It stays at ~90 lines, against roughly 700 for
opentelemetry-instrumentation-asgi, because Datasette's app does not return
before its body is sent: route_path awaits response.asgi_send(send), and a
streaming CSV export runs its generator inline inside AsgiStream.asgi_send.
So a plain `finally` covers the response body and no deferred-end machinery
is needed.
Two decisions worth flagging for review:
- Inbound W3C traceparent and baggage are extracted, using the *global*
propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI
instrumentation), and going through the global propagator leaves the
operator in control with no Datasette setting to invent:
OTEL_PROPAGATORS=none disables it entirely. A public instance that does
not want client-influenced traces should strip those headers at the proxy.
- url.query is not recorded, anywhere. Datasette query strings carry
user-supplied SQL in ?sql= and canned query parameters. client.address is
not recorded either.
The status code is sniffed from the ASGI http.response.start message rather
than read off a Response, because asgi_static, the favicon route, AsgiStream
and AsgiFileDownload all send that message themselves and never build one.
Only a >= 500 sets an error status - per semantic conventions a 4xx is the
client's mistake, and Datasette 404s are routine enough that treating them
as errors would bury a real 500.
The registry gains a `dynamic` flag, because this span's name is composed at
runtime and so can never equal a fixed registry string. Dynamic entries
resolve by span kind instead, and only after exact and prefix matching has
failed, so they cannot shadow a span that does have a registered name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- The HTTP request span ------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _ScopeHeadersGetter(Getter):
|
|
|
|
|
"""
|
|
|
|
|
Read W3C trace context out of an ASGI scope's headers.
|
|
|
|
|
|
|
|
|
|
`scope["headers"]` is a list of `(bytes, bytes)` pairs, lowercased by the
|
|
|
|
|
server per the ASGI spec - but `.lower()` is applied again here because
|
|
|
|
|
that is a spec promise about servers, not something this process
|
|
|
|
|
controls. Header bytes are latin-1 by RFC 9110.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def get(self, carrier, key):
|
|
|
|
|
wanted = key.lower().encode("latin-1")
|
|
|
|
|
values = [v.decode("latin-1") for k, v in carrier if k.lower() == wanted]
|
|
|
|
|
return values or None
|
|
|
|
|
|
|
|
|
|
def keys(self, carrier):
|
|
|
|
|
return [k.decode("latin-1") for k, _ in carrier]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_HEADERS_GETTER = _ScopeHeadersGetter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# An unclamped method is an unbounded dimension a client controls: anyone can
|
|
|
|
|
# send `FOO / HTTP/1.1`. Semantic conventions say map anything unrecognised to
|
|
|
|
|
# `_OTHER`. These nine are the methods of RFC 9110 plus PATCH (RFC 5789).
|
|
|
|
|
_KNOWN_METHODS = frozenset(
|
|
|
|
|
{"GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clamp_http_method(method):
|
|
|
|
|
"The request method if it is one we recognise, else ``_OTHER``."
|
|
|
|
|
method = (method or "").upper()
|
|
|
|
|
return method if method in _KNOWN_METHODS else "_OTHER"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _first_header(headers, name):
|
|
|
|
|
"The first value of a header, decoded, or None."
|
|
|
|
|
for key, value in headers:
|
|
|
|
|
if key.lower() == name:
|
|
|
|
|
return value.decode("latin-1")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _url_path(scope):
|
|
|
|
|
"""
|
|
|
|
|
The request path, with any query string removed.
|
|
|
|
|
|
|
|
|
|
`raw_path` is preferred because it is the bytes the client sent, before
|
|
|
|
|
percent-decoding - Datasette routes on database and table names that can
|
|
|
|
|
contain encoded slashes, which `scope["path"]` has already collapsed.
|
|
|
|
|
|
|
|
|
|
The split on "?" is not decoration. The ASGI spec's `raw_path` excludes
|
2026-09-02 11:27:06 -07:00
|
|
|
the query string, but the name is read both ways in the wild - httpx's
|
|
|
|
|
own `raw_path` includes the query - and Datasette's query strings carry
|
|
|
|
|
user-supplied SQL, which core never records. A literal "?" cannot appear
|
|
|
|
|
unencoded in a path, so the defensive split costs nothing when the server
|
|
|
|
|
is well behaved.
|
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
|
|
|
"""
|
|
|
|
|
raw_path = scope.get("raw_path")
|
|
|
|
|
if raw_path:
|
|
|
|
|
if isinstance(raw_path, bytes):
|
|
|
|
|
raw_path = raw_path.decode("latin-1")
|
|
|
|
|
return raw_path.split("?", 1)[0]
|
|
|
|
|
return scope.get("path", "")
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
# The request span is handed to `DatasetteRouter.route_path` through the ASGI
|
|
|
|
|
# scope rather than through `get_current_span()`, because by the time routing
|
|
|
|
|
# happens the current span may well be something else: a plugin
|
|
|
|
|
# `asgi_wrapper()` runs *inside* this middleware, and an instrumented one makes
|
|
|
|
|
# its own span current for the whole request. Reading the current span there
|
|
|
|
|
# would set `http.route` on that plugin's span - and rename it - while leaving
|
|
|
|
|
# the actual request span without the one attribute a trace UI groups by. Not
|
|
|
|
|
# hypothetical: an ordinary tracing plugin triggers it.
|
|
|
|
|
#
|
|
|
|
|
# Namespaced per the ASGI spec's rules for extension keys. Absent when the span
|
|
|
|
|
# is not recording, which is exactly when the router should skip the work too.
|
|
|
|
|
REQUEST_SPAN_SCOPE_KEY = "datasette.telemetry.request_span"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def request_span(scope):
|
|
|
|
|
"""
|
|
|
|
|
The recording request span for an ASGI scope, or None.
|
|
|
|
|
|
|
|
|
|
Falls back to the current span so that a `DatasetteRouter` running under
|
|
|
|
|
some other instrumentation - one that started a SERVER span but of course
|
|
|
|
|
knows nothing about this scope key - still gets enriched.
|
|
|
|
|
"""
|
|
|
|
|
span = scope.get(REQUEST_SPAN_SCOPE_KEY)
|
|
|
|
|
if span is None:
|
|
|
|
|
span = otel_trace.get_current_span()
|
2026-09-02 11:27:06 -07:00
|
|
|
# is_recording(), not `get_span_context().is_valid` - see the fast-path
|
|
|
|
|
# comment in TelemetryMiddleware for why valid is not the same as recording.
|
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
|
|
|
return span if span.is_recording() else None
|
|
|
|
|
|
|
|
|
|
|
Give every span a request to belong to
Nothing in Datasette created a span for the HTTP request itself, so every
span the database layer emits was a root span. Measured on this branch: one
faceted table page produces 70 spans in 36 separate traces, none of which
carries a URL. A trace UI shows that as dozens of unrelated single-span
traces per page, interleaved across concurrent requests - worse than
?_trace=1 at the exact job people reach for tracing to do. With the request
span it is 71 spans in 1 trace.
`opentelemetry-instrument` does not fix this on its own: auto-instrumentation
only picks up frameworks that ship an instrumentor entry point, and
Datasette's raw ASGI app is not one.
TelemetryMiddleware is mounted outermost in Datasette.app(), after the
asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run
*inside* the span. Putting it in DatasetteRouter instead would leave a span
created by an instrumented plugin as an orphan root - reintroducing the
problem for exactly the code most likely to be instrumented.
It stays at ~90 lines, against roughly 700 for
opentelemetry-instrumentation-asgi, because Datasette's app does not return
before its body is sent: route_path awaits response.asgi_send(send), and a
streaming CSV export runs its generator inline inside AsgiStream.asgi_send.
So a plain `finally` covers the response body and no deferred-end machinery
is needed.
Two decisions worth flagging for review:
- Inbound W3C traceparent and baggage are extracted, using the *global*
propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI
instrumentation), and going through the global propagator leaves the
operator in control with no Datasette setting to invent:
OTEL_PROPAGATORS=none disables it entirely. A public instance that does
not want client-influenced traces should strip those headers at the proxy.
- url.query is not recorded, anywhere. Datasette query strings carry
user-supplied SQL in ?sql= and canned query parameters. client.address is
not recorded either.
The status code is sniffed from the ASGI http.response.start message rather
than read off a Response, because asgi_static, the favicon route, AsgiStream
and AsgiFileDownload all send that message themselves and never build one.
Only a >= 500 sets an error status - per semantic conventions a 4xx is the
client's mistake, and Datasette 404s are routine enough that treating them
as errors would bury a real 500.
The registry gains a `dynamic` flag, because this span's name is composed at
runtime and so can never equal a fixed registry string. Dynamic entries
resolve by span kind instead, and only after exact and prefix matching has
failed, so they cannot shadow a span that does have a registered name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
|
|
|
class TelemetryMiddleware:
|
|
|
|
|
"""
|
|
|
|
|
One `SpanKind.SERVER` span per HTTP request.
|
|
|
|
|
|
|
|
|
|
Mounted outermost in `Datasette.app()`, so every other span raised while
|
|
|
|
|
serving a request - database queries, plugin middleware, startup work on
|
|
|
|
|
a cold ASGI-hosted deployment - has somewhere to belong instead of
|
|
|
|
|
becoming its own root trace.
|
|
|
|
|
|
|
|
|
|
Deliberately much smaller than `opentelemetry-instrumentation-asgi`,
|
|
|
|
|
which needs several hundred lines of deferred-end machinery for
|
|
|
|
|
applications that return before their body is sent. Datasette does not:
|
|
|
|
|
`DatasetteRouter.route_path` awaits `response.asgi_send(send)`, and for a
|
|
|
|
|
streaming CSV export `AsgiStream.asgi_send` runs the generator inline.
|
|
|
|
|
All of it happens inside the single `await self.app(...)` below, so
|
|
|
|
|
ending the span in a `finally` covers the response body too.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, app):
|
|
|
|
|
self.app = app
|
|
|
|
|
|
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
|
|
|
# First, before anything else: `AsgiLifespan` is *inside* this
|
|
|
|
|
# middleware, so lifespan startup and shutdown have to pass through
|
|
|
|
|
# untouched or the server never starts. Same for websockets.
|
|
|
|
|
if scope["type"] != "http":
|
|
|
|
|
await self.app(scope, receive, send)
|
|
|
|
|
return
|
|
|
|
|
headers = scope.get("headers") or []
|
|
|
|
|
# The *global* propagator, deliberately: it leaves the operator in
|
|
|
|
|
# control with no Datasette-specific setting - OTEL_PROPAGATORS=none
|
|
|
|
|
# disables extraction entirely, OTEL_PROPAGATORS=tracecontext drops
|
|
|
|
|
# baggage - and core configuring propagation itself would be the same
|
|
|
|
|
# mistake as core configuring sampling.
|
|
|
|
|
context = extract(headers, getter=_HEADERS_GETTER)
|
|
|
|
|
method = clamp_http_method(scope.get("method", ""))
|
|
|
|
|
# The method, not the URL: a span name has to be low cardinality, and
|
|
|
|
|
# the method is what is known out here at the edge, before any routing
|
|
|
|
|
# has happened.
|
|
|
|
|
with tracer.start_as_current_span(
|
|
|
|
|
method, context=context, kind=SpanKind.SERVER
|
|
|
|
|
) as span:
|
|
|
|
|
if not span.is_recording():
|
|
|
|
|
# No provider installed, or a sampler dropped this trace.
|
|
|
|
|
# Everything below would be discarded, so skip building the
|
|
|
|
|
# `send` wrapper and let a default install pay almost
|
|
|
|
|
# nothing. Note this cannot be `get_span_context().is_valid`:
|
|
|
|
|
# with no provider but an inbound `traceparent`, the API's
|
|
|
|
|
# NoOpTracer returns a NonRecordingSpan carrying the *remote*
|
|
|
|
|
# context, which is perfectly valid and still records nothing.
|
|
|
|
|
await self.app(scope, receive, send)
|
|
|
|
|
return
|
|
|
|
|
span.set_attribute(HTTP_REQUEST_METHOD, method)
|
|
|
|
|
span.set_attribute(URL_PATH, _url_path(scope))
|
|
|
|
|
scheme = scope.get("scheme")
|
|
|
|
|
if scheme:
|
|
|
|
|
span.set_attribute(URL_SCHEME, scheme)
|
|
|
|
|
host = _first_header(headers, b"host")
|
|
|
|
|
if host:
|
|
|
|
|
span.set_attribute(SERVER_ADDRESS, host)
|
|
|
|
|
user_agent = _first_header(headers, b"user-agent")
|
|
|
|
|
if user_agent:
|
|
|
|
|
span.set_attribute(USER_AGENT_ORIGINAL, user_agent)
|
2026-09-02 14:20:32 -07:00
|
|
|
if _in_datasette_client.get():
|
|
|
|
|
span.set_attribute(INTERNAL_CLIENT, True)
|
Give every span a request to belong to
Nothing in Datasette created a span for the HTTP request itself, so every
span the database layer emits was a root span. Measured on this branch: one
faceted table page produces 70 spans in 36 separate traces, none of which
carries a URL. A trace UI shows that as dozens of unrelated single-span
traces per page, interleaved across concurrent requests - worse than
?_trace=1 at the exact job people reach for tracing to do. With the request
span it is 71 spans in 1 trace.
`opentelemetry-instrument` does not fix this on its own: auto-instrumentation
only picks up frameworks that ship an instrumentor entry point, and
Datasette's raw ASGI app is not one.
TelemetryMiddleware is mounted outermost in Datasette.app(), after the
asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run
*inside* the span. Putting it in DatasetteRouter instead would leave a span
created by an instrumented plugin as an orphan root - reintroducing the
problem for exactly the code most likely to be instrumented.
It stays at ~90 lines, against roughly 700 for
opentelemetry-instrumentation-asgi, because Datasette's app does not return
before its body is sent: route_path awaits response.asgi_send(send), and a
streaming CSV export runs its generator inline inside AsgiStream.asgi_send.
So a plain `finally` covers the response body and no deferred-end machinery
is needed.
Two decisions worth flagging for review:
- Inbound W3C traceparent and baggage are extracted, using the *global*
propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI
instrumentation), and going through the global propagator leaves the
operator in control with no Datasette setting to invent:
OTEL_PROPAGATORS=none disables it entirely. A public instance that does
not want client-influenced traces should strip those headers at the proxy.
- url.query is not recorded, anywhere. Datasette query strings carry
user-supplied SQL in ?sql= and canned query parameters. client.address is
not recorded either.
The status code is sniffed from the ASGI http.response.start message rather
than read off a Response, because asgi_static, the favicon route, AsgiStream
and AsgiFileDownload all send that message themselves and never build one.
Only a >= 500 sets an error status - per semantic conventions a 4xx is the
client's mistake, and Datasette 404s are routine enough that treating them
as errors would bury a real 500.
The registry gains a `dynamic` flag, because this span's name is composed at
runtime and so can never equal a fixed registry string. Dynamic entries
resolve by span kind instead, and only after exact and prefix matching has
failed, so they cannot shadow a span that does have a registered name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
|
|
|
|
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
|
|
|
# A copy, not a mutation: the scope belongs to the server, and
|
|
|
|
|
# every other layer in Datasette extends it the same way.
|
|
|
|
|
scope = dict(scope, **{REQUEST_SPAN_SCOPE_KEY: span})
|
|
|
|
|
|
Give every span a request to belong to
Nothing in Datasette created a span for the HTTP request itself, so every
span the database layer emits was a root span. Measured on this branch: one
faceted table page produces 70 spans in 36 separate traces, none of which
carries a URL. A trace UI shows that as dozens of unrelated single-span
traces per page, interleaved across concurrent requests - worse than
?_trace=1 at the exact job people reach for tracing to do. With the request
span it is 71 spans in 1 trace.
`opentelemetry-instrument` does not fix this on its own: auto-instrumentation
only picks up frameworks that ship an instrumentor entry point, and
Datasette's raw ASGI app is not one.
TelemetryMiddleware is mounted outermost in Datasette.app(), after the
asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run
*inside* the span. Putting it in DatasetteRouter instead would leave a span
created by an instrumented plugin as an orphan root - reintroducing the
problem for exactly the code most likely to be instrumented.
It stays at ~90 lines, against roughly 700 for
opentelemetry-instrumentation-asgi, because Datasette's app does not return
before its body is sent: route_path awaits response.asgi_send(send), and a
streaming CSV export runs its generator inline inside AsgiStream.asgi_send.
So a plain `finally` covers the response body and no deferred-end machinery
is needed.
Two decisions worth flagging for review:
- Inbound W3C traceparent and baggage are extracted, using the *global*
propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI
instrumentation), and going through the global propagator leaves the
operator in control with no Datasette setting to invent:
OTEL_PROPAGATORS=none disables it entirely. A public instance that does
not want client-influenced traces should strip those headers at the proxy.
- url.query is not recorded, anywhere. Datasette query strings carry
user-supplied SQL in ?sql= and canned query parameters. client.address is
not recorded either.
The status code is sniffed from the ASGI http.response.start message rather
than read off a Response, because asgi_static, the favicon route, AsgiStream
and AsgiFileDownload all send that message themselves and never build one.
Only a >= 500 sets an error status - per semantic conventions a 4xx is the
client's mistake, and Datasette 404s are routine enough that treating them
as errors would bury a real 500.
The registry gains a `dynamic` flag, because this span's name is composed at
runtime and so can never equal a fixed registry string. Dynamic entries
resolve by span kind instead, and only after exact and prefix matching has
failed, so they cannot shadow a span that does have a registered name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:33:04 -07:00
|
|
|
# The status cannot be read off a Response object: `asgi_static`,
|
|
|
|
|
# the favicon route, `AsgiStream` and `AsgiFileDownload` all call
|
|
|
|
|
# `send` directly and never build one. Wrapping `send` is the only
|
|
|
|
|
# thing that sees every response, including the 404 and 500
|
|
|
|
|
# handlers.
|
|
|
|
|
status_holder = {}
|
|
|
|
|
|
|
|
|
|
async def wrapped_send(message):
|
|
|
|
|
if (
|
|
|
|
|
message["type"] == "http.response.start"
|
|
|
|
|
and "status" not in status_holder
|
|
|
|
|
):
|
|
|
|
|
status_holder["status"] = message["status"]
|
|
|
|
|
await send(message)
|
|
|
|
|
|
|
|
|
|
escaped = False
|
|
|
|
|
try:
|
|
|
|
|
await self.app(scope, receive, wrapped_send)
|
|
|
|
|
except BaseException as exception:
|
|
|
|
|
# BaseException, not Exception: `route_path` turns almost
|
|
|
|
|
# everything into a 500 itself, but `asyncio.CancelledError`
|
|
|
|
|
# on client disconnect is a BaseException its `except
|
|
|
|
|
# Exception` deliberately does not catch.
|
|
|
|
|
escaped = True
|
|
|
|
|
span.set_attribute(ERROR_TYPE, type(exception).__name__)
|
|
|
|
|
span.set_status(Status(StatusCode.ERROR, str(exception)))
|
|
|
|
|
raise
|
|
|
|
|
finally:
|
|
|
|
|
status = status_holder.get("status")
|
|
|
|
|
if status is not None:
|
|
|
|
|
span.set_attribute(HTTP_RESPONSE_STATUS_CODE, status)
|
|
|
|
|
# 4xx is NOT an error for a SERVER span per semantic
|
|
|
|
|
# conventions - the client made the mistake, not us.
|
|
|
|
|
#
|
|
|
|
|
# `not escaped` because this block still runs when an
|
|
|
|
|
# exception is on its way out, and a response can have
|
|
|
|
|
# started before it: the exception's class name is more
|
|
|
|
|
# use than the string "500", so it wins.
|
|
|
|
|
if status >= 500 and not escaped:
|
|
|
|
|
span.set_status(Status(StatusCode.ERROR))
|
|
|
|
|
span.set_attribute(ERROR_TYPE, str(status))
|