Skip to content

OpenTelemetry tracing for the database layer - #2862

Draft
asg017 wants to merge 10 commits into
mainfrom
asg017/otel-phase1-1-database-spans
Draft

OpenTelemetry tracing for the database layer#2862
asg017 wants to merge 10 commits into
mainfrom
asg017/otel-phase1-1-database-spans

Conversation

@asg017

@asg017 asg017 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

This is the first PR for the #1730 OTel feature. It adds the appropriate lightweight OTel API for recording spans, and hooks it up to just raw SQL queries for now. This allows one to observe what SQL queries take up the most time for a Datasette application, but it's missing other important spans/traces (ex HTTP requests, facets, plugin hooks, startup times, etc). Those will come in future PRs.

🤖 Claude-generated PR description

Adds OpenTelemetry spans to Datasette's database layer.

This addresses #1730"SQL tracing should much more closely track the SQL query execution"
by splitting the measurement in two: db.query covers the full round trip a caller experiences, and
db.query.execute covers only the work inside the SQL worker thread. The gap between them is
exactly the thread-pool wait that the current tracer folds invisibly into one number, which is what
that issue is about.

I've said "addresses" rather than "closes" deliberately: this adds a second, correct measurement
alongside the existing tracer rather than fixing the existing one. If you'd rather #1730 stay open
until the old tracer is dealt with, that seems right to me — it would close with the third PR in
this series.

Nothing is removed. ?_trace=1 and trace_debug still work exactly as before. The cost of that,
stated up front: for one release cycle the db.execute() path carries both instrumentations. I
think that's worth it to keep the removal a separate decision, but it is a real if small cost.

 datasette/app.py                 |  116 +++--
 datasette/database.py            |  484 +++++++++++++++---
 datasette/telemetry.py           |  117 +++++
 datasette/telemetry_registry.py  |  273 ++++++++++
 datasette/views/row.py           |    9 +-
 datasette/views/table.py         |   16 +-
 docs/changelog.rst               |   10 +
 docs/internals.rst               |  130 +++++
 docs/telemetry_doc.py            |   37 ++
 pyproject.toml                   |    2 +
 tests/conftest.py                |   62 +++
 tests/test_internals_database.py |  109 ++++
 tests/test_telemetry.py          | 1015 ++++++++++++++++++++++++++++++++++++++
 tests/test_telemetry_registry.py |  309 ++++++++++++
 14 files changed, 2552 insertions(+), 137 deletions(-)

About 1,500 of those lines are tests and another 440 are the span registry plus the docs
generated from it. The instrumentation itself is a few hundred lines across database.py and
app.py.

The design, in one paragraph

Datasette core depends on opentelemetry-api only and is a pure telemetry producer: it never
creates a TracerProvider, configures an exporter, or sets a sampler. Turning telemetry on is
entirely the job of whoever runs Datasette, normally via opentelemetry-instrument. With no
provider installed, every span is a NonRecordingSpan — see the benchmark below for what that
actually costs, measured rather than asserted.

That constraint is load-bearing rather than stylistic, and there's a test enforcing it
(test_datasette_package_never_imports_the_sdk). An earlier draft of this work had core own a
provider so it could render traces into a page, and that single requirement produced three problems
I found in self-review: a cross-request span leak that could expose another user's SQL text, a test
suite that permanently poisoned the process-global provider with no possible teardown (the SDK has
no remove_span_processor, and once a real provider exists ProxyTracer caches the concrete
tracer it resolved and ignores any later one), and a sampling environment variable that silently
blanked output. Not owning a provider dissolves all three.

To be precise about that caching, since a stronger version of the claim is easy to reach for and
is wrong: ProxyTracer does not cache the no-op tracer. Checked against opentelemetry-api
1.44, ProxyTracer._tracer returns self._noop_tracer without assigning _real_tracer when no
provider is set. So spans emitted before a provider exists are simply not recorded; they do not
poison the tracer for the rest of the process.

Spans

Span Kind What
db.query CLIENT A SQL operation, covering the full round trip including queue time
db.query.execute INTERNAL The read executing inside a worker thread
db.write.queue_wait INTERNAL Time a write spent in its database's write queue
db.write.execute INTERNAL The write executing on the write thread
datasette.startup INTERNAL invoke_startup(), once per process

Attributes follow OTel semantic conventions where they exist (db.system, db.namespace,
db.query.text, db.operation.name, db.collection.name) and a datasette.* prefix otherwise.
The instrumentation scope declares schema URL https://opentelemetry.io/schemas/1.29.0 — not the
latest, deliberately: 1.29.0 is the highest version at which every name emitted is the current
spelling. db.system and code.function were renamed in 1.30.0, so declaring anything later would
be a false machine-readable claim and would stop consumers translating those names forward.

Only db.query is CLIENT. Its children stay INTERNAL because they are Datasette's decomposition of
one logical query, not three database calls, and db.write.queue_wait touches no database at all.

SQL text is truncated to 2048 characters — on a public instance it is attacker-controlled and
unbounded. Parameter values are not recorded at all in this PR, only a count. No actor
identifiers are recorded.

db.operation.name is the statement's leading keyword matched against a fixed 13-word allowlist,
never a parse. Anything unrecognised gets no attribute rather than a wrong one. A statement starting
with a CTE therefore reports WITH rather than the operation inside it, which is a real limitation
and a substantial share of Datasette's own reads take that form. The allowlist is deliberate: the
same attribute goes on a metric in a later phase, and echoing back an arbitrary first token from
attacker-controlled SQL would let a visitor's typo mint a permanent metric series.

Things worth reviewing carefully

A new public API argument. db.execute() gains a table= keyword argument, used to set
db.collection.name. Table names are never derived from the SQL — deriving them would be a parse,
and on an instance where anyone can create a table the dimension has no ceiling. If you'd rather not
add public API for a telemetry reason, I can drop db.collection.name and keep db.operation.name,
which needs nothing new.

copy_context() propagates more than OpenTelemetry. Context is carried across five thread
boundaries with contextvars.copy_context(), which copies every ContextVar, not just OTel's.
Datasette has others — _skip_permission_checks and _permission_check_cache
(datasette/permissions.py), _in_datasette_client (app.py). Those values now flow into SQL
worker threads where they previously did not. I believe this is benign and possibly a latent fix,
but it is a behaviour change outside telemetry and I'd rather flag it than have it found in review.
If you'd prefer, I can propagate only the OTel context.

A fresh copy_context() per submit is required. Reusing one raises RuntimeError: cannot enter context ... already entered under concurrency.

Attach/detach discipline on the write thread. It is long-lived and shared, so a leaked context
token poisons it for every subsequent write. A wrong-token detach only logs — it does not raise
— so this cannot be validated by "does it throw". test_write_thread_context_is_detached_between_tasks
asserts it directly by carrying a plain context value through several writes and checking the
observed sequence.

block=False writes are linked, not parented. Measured: a block=False write's spans end ~50ms
after the enqueueing request's span has already closed, because parenting asserts containment and
the request causes the write without containing it. Those spans are roots carrying an OTel link back
to the enqueueing span. The accepted cost is that a linked span doesn't appear in the request's
waterfall in most trace UIs — for a fire-and-forget write whose latency the request never pays,
being absent beats being drawn wrongly.

Expected SQL errors are not marked as errors. ArrayFacet.suggest() probes every column with
json_type() specifically to find out which ones raise; Datasette passes log_sql_errors=False to
say "failure is the expected answer here". Those spans leave status UNSET and set
datasette.sql_error_suppressed, so they stay discoverable without reading as failures. Without
this, a plain table page produced 4 error spans out of 225 and any alerting keyed on span status
would fire constantly.

The same reasoning applies to timeouts. A caller that passes a custom_time_limit shorter than
the instance-wide sql_time_limit_ms is saying "this may not finish, and that is an answer I can
use" — table_counts() stores None, facet suggestion moves to the next column, autocomplete
falls back to a prefix query. Those spans set datasette.interrupted but leave status UNSET.
Without it the homepage alone emitted one red span per table on every hit, since it counts every
table under a 10ms budget.

A test ordering hack in tests/conftest.py, which you may reasonably want to argue with.
test_datasette_package_never_imports_the_sdk shells out to a fresh interpreter to prove
import datasette does not pull in the SDK. Run late in a serial suite on macOS it segfaults —
SIGSEGV/SIGBUS inside subprocess's _execute_child — so it is moved to the front with the
existing move_to_front() helper that test_cli, test_black and seven others already use.

This is not caused by anything in this PR. I reproduced it on unmodified main at e889403 with
a bare subprocess call placed at the same point in the run: by then the pytest process holds
enough threads that the fork half of fork+exec crashes CPython 3.13.1 on macOS. Linux CI will
never hit it, because CPython uses posix_spawn there. I have gone with the existing local
convention rather than adding a new mechanism, but if you would rather the test did not get
special ordering treatment, it can be marked serial instead.

Dependency

Adds opentelemetry-api to runtime dependencies. At 1.44 it brings only typing-extensions, and it
supports Python 3.10–3.14 — exactly this repo's CI matrix. No SDK, no exporter, no collector; those
are the operator's business.

Real output

All of the following was captured on this branch by installing an SDK TracerProvider with a
SimpleSpanProcessor and an InMemorySpanExporter, then making real requests. Times are
milliseconds.

invoke_startup(), one trace per process. Every span startup creates nests under it, so a
trace UI shows one startup trace rather than a wall of unrelated single-span ones:

                                                        start      dur
datasette.startup                                        0.00     7.48  |████████████████████████████████████████████|
  db.write.queue_wait                                    0.15     0.55  |███                                         |
  db.write.execute                                       0.72     4.88  |    ████████████████████████████            |
  db.query                                               5.69     0.39  |                                 ██         |
    db.query.execute                                     5.84     0.19  |                                  █         |
  db.query                                               6.11     0.11  |                                   █        |
    db.query.execute                                     6.13     0.02  |                                    █       |
  db.query                                               6.24     0.09  |                                    █       |
    db.query.execute                                     6.28     0.02  |                                    █       |
  ... six more db.query / db.query.execute pairs ...
  db.write.queue_wait                                    7.04     0.01  |                                         █  |
  db.write.execute                                       7.06     0.21  |                                         █  |
  db.query                                               7.32     0.15  |                                           █|
    db.write.queue_wait                                  7.35     0.02  |                                           █|
    db.write.execute                                     7.38     0.06  |                                           █|

26 spans, 1 trace.

The durations are self-consistent: children fit inside parents, the 4.88ms db.write.execute is
the internal catalog build, and the whole of startup is 7.48ms — which is what starting Datasette
against a 500-row database should cost.

A table page. GET /demo/dogs?_size=100 against a 500-row table:

                                                        start      dur
db.query                                                 0.00     0.10  |█                                           |
  db.query.execute                                       0.04     0.01  |█                                           |
db.query                                                 0.12     0.07  |█                                           |
  db.query.execute                                       0.14     0.01  |█                                           |
db.query                                                 0.20     0.07  |█                                           |
  db.query.execute                                       0.23     0.01  |█                                           |
... 30 more ...

66 spans, 33 traces.

33 separate traces, because nothing here creates a span for the request — that is the PR 2
limitation restated as output, and it is the honest reason to review the two together.

One db.query span in full:

kind: CLIENT
db.system            = 'sqlite'
db.namespace         = 'demo'
db.query.text        = 'select id, name, age from dogs order by id limit 101'
db.operation.name    = 'SELECT'
db.collection.name   = 'dogs'
datasette.rows_returned = 101
datasette.truncated     = False
datasette.time_limit_ms = 1000

The thing #1730 is about. On an idle instance the gap between db.query and
db.query.execute is small — across the 33 db.query spans above, min 0.05ms, median 0.09ms,
max 0.45ms. That gap is the whole point, and it only becomes interesting under load. Twelve
concurrent identical queries against three SQL threads:

   start  db.query    wait  execute
    0.00     14.82    0.07    14.70
    0.02     14.97    0.12    14.80
    0.09     14.66    0.25    14.34
    0.17     29.14   14.56    14.46
    0.20     29.31   14.60    14.57
    0.21     29.31   14.75    14.48
    0.23     44.01   28.99    14.95
    0.24     43.97   29.16    14.69
    0.26     43.75   29.21    14.48
    0.27     58.31   43.70    14.55
    0.28     58.40   43.82    14.54
    0.30     58.37   43.89    14.41

queue wait: min 0.07ms  median 28.99ms  max 43.89ms
execute:    min 14.34ms median 14.55ms  max 14.95ms

Four waves of three, and db.query climbs 15 → 29 → 44 → 58ms while db.query.execute stays
flat at 14.5ms. The existing tracer reports one number here, and that number would be 58ms for a
query that took 14.5ms to run. Separating the two makes "your database is slow" and "your thread
pool is saturated" distinguishable, which is the request in #1730.

(Sanity check on those numbers, since a duration bug elsewhere in this work survived every test I
had: 12 queries ÷ 3 threads = 4 waves × 14.5ms = 58ms, which is exactly the largest db.query.
The arithmetic closes.)

Performance

Measured on this diff, against main at e889403, on the same machine and the same database file.
Workload is one table page — /bench/records?_size=100 on a 5,000-row table with facet
suggestion on. Each run is 80 requests after 15 warm-up requests, taking the median request time;
each configuration is a number of independent runs, and I report the median of those run medians
plus the full range across runs.

runs median range across runs spans/request
main, no provider 15 9.80ms 9.46 – 10.81 0
this PR, no provider 15 9.98ms 9.74 – 11.31 0
main, SDK provider 5 9.58ms 9.51 – 10.72 0
this PR, SDK provider 10 11.25ms 10.61 – 11.92 58.2

With no provider installed — the default, and what everyone who does not opt in gets — there is
no measurable regression.
The medians differ by 0.18ms, and run-to-run spread within a single
configuration is 1.3–1.6ms. The delta is well inside the noise. I am deliberately not quoting
0.18ms as a result: this benchmark cannot resolve it. What it can say is that ~58 no-op spans per
page do not show up in end-to-end page latency.

With an SDK provider installed the cost is real and measurable: +1.67ms against main with
the same provider, or +1.27ms against this same branch with the provider removed. Over 58.2 spans
that is roughly 22µs per span. This is a best case for the SDK — SimpleSpanProcessor writing to
an InMemorySpanExporter, so no serialization and no network. A real OTLP exporter adds its own
cost on top, but that cost is paid only by operators who chose to turn tracing on, and it is the
SDK's cost rather than Datasette's.

Both datasette/telemetry.py's docstring and the new docs state this measurement rather than
claiming spans are free. An earlier version of this work had a docstring saying spans "cost
approximately nothing" while its own benchmark showed +7.1ms on a large page; I would rather the
comment and the number agree.

Caveat on resolution: this is a Python end-to-end benchmark on a laptop, so it resolves about a
millisecond. It is the right instrument for "does turning this off cost users anything" and the
wrong one for micro-attribution.

How to test this

pip install -e '.[test]'
pytest -n auto -m "not serial" && pytest -m "serial"

# core must never import the SDK
grep -rn 'opentelemetry\.sdk' datasette/     # must be empty

# see spans with no infrastructure
OTEL_TRACES_EXPORTER=console OTEL_METRICS_EXPORTER=none OTEL_LOGS_EXPORTER=none \
  opentelemetry-instrument datasette mydb.db

Note OTEL_TRACES_EXPORTER=console datasette mydb.db on its own produces nothing — that variable
is read by the SDK's auto-configuration, which only runs under opentelemetry-instrument. Core
installs no provider, so plain datasette emits nothing at all. This trips everyone up once.

Full serial suite on this branch: 2353 passed, 39 skipped, 6 xfailed, 15 xpassed, 141 subtests,
no failures and no new warnings.

Known limitations

  • Every span is currently a root span. Nothing here creates a span for the HTTP request, so a
    trace UI shows many single-span traces per page. PR 2 fixes this, and the two really are best
    reviewed and released together — merging this one alone and cutting a release would ship a version
    whose traces look broken. If you'd prefer them as a single PR, say so and I'll combine them; I
    split them because the request span is the only new code here and I wanted it reviewable on its
    own.
  • No metrics, no plugin hook spans, no permission spans, no template/facet spans. All are built on a
    branch stack and will follow as separate PRs. The roadmap is in the tracking issue.
  • No parameter values, by default or otherwise, in this PR.
  • Three timeout sites still produce ERROR spans. The rule above — a deliberately short query
    budget is not a failure — keys on the caller passing custom_time_limit. Three call sites
    swallow QueryInterrupted but pass no custom limit, so a timeout there still marks the span
    ERROR: the table page's count query (datasette/views/table_extras.py), expand_foreign_keys
    (datasette/app.py) and foreign_table_counts (datasette/views/row.py). Fixing them means
    changing what limit those callers request, which is a behaviour change rather than a telemetry
    one, so I left it out of scope. Happy to do it here if you'd rather.
  • db.operation.name reports WITH for a statement opening with a CTE, as described above.
  • Two plugin hooks — register_output_renderer and asgi_wrapper — are dispatched from
    Datasette.__init__() and .app(), before invoke_startup(), so anything they query falls
    outside the datasette.startup span. Measured: a default install emits zero spans from either,
    since Datasette queries no database there, so this only affects plugins. Covering them would
    mean holding a span open across object construction.
  • An embedder using Datasette().app() inside a host application should install its
    TracerProvider before serving traffic. Spans emitted before a provider exists aren't recorded.
    (This is ordinary OTel behaviour, not something Datasette controls, and nothing is permanently
    affected — those spans are just dropped.)

Stack created with GitHub Stacks CLIGive Feedback 💬

asg017 and others added 10 commits July 30, 2026 17:30
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>
Datasette's existing tracer times a "sql" block that wraps a good deal
more than the query itself - queueing onto the thread pool, the pool
wait, and result marshalling all disappear into one number. That is
#1730, "SQL tracing should much more closely track the
SQL query execution", open since 2022. A db.query span here is the outer
half of the answer; a later change adds the inner span drawn around the
sqlite3 call itself, and the gap between the two is exactly the thread
pool wait the current tracer folds away.

The span carries OTel semantic-convention attributes (db.system,
db.namespace, db.query.text) plus a few datasette.* ones. db.query.text
goes through sql_attribute(), which caps it at 2048 characters, because
on a public instance the SQL is attacker-supplied and unbounded. Only
len(params) is recorded, never a parameter value.

The existing `with trace(...)` wrapper stays exactly where it is and the
new span nests inside it. This change removes nothing: ?_trace=1 and the
trace_debug setting keep working unchanged. The two systems are
independent code paths.

Exception handling on the span is explicit rather than inherited from
start_as_current_span's defaults, which would record the exception and
set StatusCode.ERROR on anything passing through. That is wrong here
because some SQL failures are the expected answer. ArrayFacet.suggest()
runs json_type(<column>) against every column precisely to discover
which ones raise "malformed JSON", and passes log_sql_errors=False to
say so. Left to the defaults, a table with N text columns marks N
queries per page as failed - burying genuine failures and tripping any
alerting keyed on span status. Measured on a plain table page before
this: 4 error spans out of 225, all expected. Suppressed errors now
leave the status UNSET and set datasette.sql_error_suppressed instead,
so they stay discoverable without reading as failures.

QueryInterrupted still sets ERROR unconditionally. That is not quite
right either - facet suggestion is designed to time out - but the fix
needs its own reasoning and lands separately.

Behaviour change worth calling out: time_limit_ms is hoisted out of
sql_operation_in_thread so the span can record it on the event loop. It
is therefore read at call time rather than at thread-execution time.
Benign in practice, since ds.sql_time_limit_ms is set at startup, but it
is a real change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
execute_write(), execute_write_script() and execute_write_many() were the
only Database methods that ran SQL without producing an OpenTelemetry
span, so any instance doing writes - which is every instance, since
Datasette builds its internal catalog through these methods at startup -
showed reads in a trace and nothing else. The same db.system,
db.namespace and db.query.text attributes the read path already sets now
appear here, with db.query.text going through sql_attribute() so
attacker-supplied SQL cannot put an unbounded string on a span.

execute_write_many() records the parameter-set count as
datasette.param_sets, not datasette.rows_returned. executemany() consumes
parameter sets and returns no rows at all, so a rows_returned name would
be describing something that does not exist - and a consumer building a
"rows written" dashboard on top of it would be charting the wrong number.

These spans only cover the event-loop side of a write. The time actually
spent waiting on the write queue and executing on the write thread is not
attributed yet; that needs context propagation across the thread
boundary and lands separately. Writes with block=False are worse still -
execute_write_fn returns before the write happens, so the span closes
early. Span links fix that later.

As with the read path, the existing `with trace(...)` wrappers stay put
and the new spans nest inside them, so ?_trace=1 keeps working
unchanged - including execute_write_many's `count`, which the old tracer
stashes through the context manager's return value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spans created on a worker thread resolve their parent from that thread's
ambient context, so without this every span produced below Database came
back as an unparented root, disconnected from the request that caused it.
Carrying the caller's context across each boundary is also what makes the
thread-pool wait visible: db.query covers the full round trip, the new
db.query.execute covers only the work inside the worker, and the gap
between them is the queueing the old tracer folds invisibly into one
number.

- execute_fn()'s executor.submit() and execute_isolated_fn()'s
  run_in_executor() (immutable databases) now run the callable inside a
  contextvars.copy_context(). A *fresh* copy per submit is required:
  concurrently entering one shared Context raises "RuntimeError: cannot
  enter context ... already entered".
- WriteTask carries the otel Context captured on the event loop at enqueue
  time plus an enqueued_at_ns timestamp (both need __slots__ entries, or
  they fail with AttributeError at runtime). _execute_writes attaches that
  context right after the _SHUTDOWN check and detaches it in a finally
  spanning all three execution branches - the write thread is persistent
  and shared, so a leaked token would grow its context stack for every
  write processed afterwards, and a wrong-token detach only logs rather
  than raising.
- New spans: db.query.execute (read worker thread), db.write.queue_wait
  (explicit start/end timestamps, so its duration is the real enqueue ->
  dequeue wait rather than the microseconds spent building the span) and
  db.write.execute (skipped in the conn_exception branch, where fn never
  runs). db.query.execute honours log_sql_errors for the same reason
  db.query does: facet suggestion probes with log_sql_errors=False and
  would otherwise paint two red spans per text column on every table page.
- The write-thread warm-up prepare_connection is left as a documented
  orphan root - no caller context exists that early.

Tests assert actual parent/child span-id relationships in a shared trace,
not just that spans exist, since an unparented root looks identical to a
correct span if you only check presence.

Note that copy_context() copies every ContextVar, not just OTel's, so
Datasette's own context vars (_skip_permission_checks,
_permission_check_cache, _in_datasette_client) now flow into worker
threads where they previously did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
invoke_startup() runs before any request exists, so nothing it does has an
ambient span to nest under. Measured on a fresh instance: 19 distinct traces,
19 of them single- or few-span roots - the register_* hook dispatches, the
internal catalog's db.query reads and its db.write.* catalog writes. In a
trace UI that is nineteen pieces of noise sitting next to every real trace,
which for an operator opening Jaeger for the first time is the difference
between "this works" and "this is unusable".

Bracketing the whole method body in one datasette.startup span takes that to
1. This is not a propagation fix - ticket 04's context propagation was already
correct, it simply had nothing to propagate. The bulk of the app.py diff is
re-indentation; `git diff -w` shows the real change (plus one line-length
rewrap black applied to the StartupError raise).

register_output_renderer and asgi_wrapper stay orphans deliberately: both are
dispatched from Datasette.__init__ / .app(), before invoke_startup() exists to
be called, and wrapping them would mean holding a span open across object
construction in library code that may never serve a request.

Suppressing instrumentation during warm-up was rejected as an alternative: a
slow prepare_connection runs on every connection, not just at startup, and is
exactly what tracing should reveal.

Also corrects the stale write-thread warm-up comment in database.py. It is
still a root, but for a reason worth stating precisely: a raw
threading.Thread does not inherit the starting thread's context, so the
datasette.startup span current on the event loop does not reach it. Read
connections do warm up under copy_context() and nest correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
A block=False write returns without awaiting the reply future, so the
enclosing db.query span finishes - and exports - before db.write.queue_wait
and db.write.execute even exist. They were still parented to it, which
produced a child bar ending ~50ms after its already-closed parent: legal
OpenTelemetry, but it renders as nonsense in a trace UI.

Parenting asserts containment; a link asserts causation without containment.
The enqueueing request causes the write without containing it, which is
exactly what a span link is for. So for block=False both write spans are now
roots - started with an explicit empty Context, so the write thread's ambient
context cannot supply a parent either - each carrying one link back to the
enqueueing span. block=True is untouched, since there the caller really does
await the reply and containment is accurate.

The link carries no attributes. There is only one kind of link here, so
naming the relationship would be a constant conveying nothing the link's
existence does not already say.

Accepted trade-off: a linked span will not appear inside the request's
waterfall in most trace UIs. It shows up as its own trace with a "linked
from" reference rather than a bar under the request. For a fire-and-forget
write whose latency the request never pays, that is the right trade -
correctness over at-a-glance nesting for a case the request-latency view was
never accurate for anyway.

This does add root traces, which looks like it cuts against the startup span
work that spent its whole diff removing them. The difference is reachability:
those roots were orphans, whereas these are reachable from the request that
caused them via the link.

Nothing in core issues block=False writes today - it is a plugin-facing path
- so this changes no trace Datasette produces on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…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>
Datasette has a family of callers that run a query under a tiny time limit
and treat "did not finish" as a usable answer. table_counts() is the loudest:
the homepage counts every table with a 10ms budget and stores None for the
ones that blow it. The QueryInterrupted handler on the db.query span was
unconditional, so on a two-table database that produced four ERROR spans -
two db.query and two db.query.execute - on every homepage hit. Measured on a
30MB two-table database: 4 red spans before, 0 after.

Honouring log_sql_errors here would have silenced none of it. Only the three
ArrayFacet json_type() probes pass log_sql_errors=False, and they are not the
queries that time out; table_counts() and ColumnFacet.suggest both leave it at
its True default. The signal that does separate the two cases is the budget
itself: a caller asking for less time than sql_time_limit_ms is saying the
query may not finish. Keying off that needs no new API and no changes outside
database.py. A query that runs out the instance-wide limit is still an error.

datasette.interrupted is still set in every case - it is the signal worth
having, and only the ERROR status becomes conditional. Its registry
description said the status is "also set to ERROR" full stop, which is now
wrong, and that string is published in docs/internals.rst.

The inner db.query.execute span carried the same bug through
set_status_on_exception=log_sql_errors, so its exception handling is now
explicit, matching the db.query span above it. The context manager's flags
apply to every exception type alike and this span has to tell two apart.

test_query_interrupted_sets_error_status forced its timeout with
?_timelimit=5, which is exactly the signal now reclassified as expected. It
now forces one via sql_time_limit_ms so it still tests what it was written to
test.

Also documents, at the copy_context() sites, that context propagation carries
Datasette's non-OTel ContextVars into worker threads too. Verified harmless:
nothing reads _skip_permission_checks, _permission_check_cache or
_in_datasette_client off the event loop, and Context.run() restores the
thread's previous context on return, so no value can reach the next task on
the shared pool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
@asg017 asg017 changed the title asg017/otel phase1 1 database spans OpenTelemetry tracing for the database layer Jul 31, 2026
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 265 lines in your changes missing coverage. Please review.
✅ Project coverage is 0.00%. Comparing base (e889403) to head (2de3d67).

Files with missing lines Patch % Lines
datasette/database.py 0.00% 151 Missing ⚠️
datasette/telemetry_registry.py 0.00% 55 Missing ⚠️
datasette/app.py 0.00% 37 Missing ⚠️
datasette/telemetry.py 0.00% 21 Missing ⚠️
datasette/views/table.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@          Coverage Diff           @@
##            main   #2862    +/-   ##
======================================
  Coverage   0.00%   0.00%            
======================================
  Files         73      75     +2     
  Lines      12279   12449   +170     
======================================
- Misses     12279   12449   +170     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant