Skip to content

Remove the hand-rolled tracer: ?_trace=1, trace_debug, datasette.tracer - #2864

Draft
asg017 wants to merge 1 commit into
asg017/otel-phase1-2-request-spanfrom
asg017/otel-phase1-3-remove-tracer
Draft

Remove the hand-rolled tracer: ?_trace=1, trace_debug, datasette.tracer#2864
asg017 wants to merge 1 commit into
asg017/otel-phase1-2-request-spanfrom
asg017/otel-phase1-3-remove-tracer

Conversation

@asg017

@asg017 asg017 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

This removes Datasette's hand-rolled tracer now that OpenTelemetry covers the same ground:

  • datasette/tracer.py (156 lines) — trace(), trace_child_tasks(), capture_traces(),
    AsgiTracer
  • the trace_debug setting
  • the ?_trace=1 query-string argument, in both HTML and JSON responses
  • the datasette.tracer section of the internals documentation
 .github/workflows/deploy-latest.yml |   2 +-
 datasette/app.py                    |   8 --
 datasette/database.py               | 205 ++++++++++++++++--------------------
 datasette/tracer.py                 | 156 ---------------------------
 datasette/views/base.py             |  24 +----
 datasette/views/table.py            |   4 +-
 docs/changelog.rst                  |   7 +-
 docs/cli-reference.rst              |   2 -
 docs/internals.rst                  |  80 --------------
 docs/introspection.rst              |   1 -
 docs/json_api.rst                   |  10 --
 docs/settings.rst                   |  18 ----
 tests/conftest.py                   |   1 -
 tests/fixtures.py                   |   6 --
 tests/plugins/my_plugin.py          |  11 +-
 tests/test_api.py                   |   1 -
 tests/test_csv.py                   |  55 ++++++----
 tests/test_html.py                  |   9 +-
 tests/test_table_api.py             |  23 +++-
 tests/test_tracer.py                |  98 -----------------
 tests/test_utils.py                 |   6 +-
 21 files changed, 170 insertions(+), 557 deletions(-)

Most of the datasette/database.py churn is a one-level dedent: git diff -w datasette/database.py
shows only the deleted with trace(...) lines, the deleted import, and one stale comment.

This is a user-facing removal and a plugin-API removal. I have tried to make the case below
honestly, including the parts that argue against it. If the trade isn't worth it, the right outcome
is to close this and keep both — PRs 1 and 2 stand on their own and don't depend on this landing.

What is actually lost

Not "nothing, use the console exporter". Specifically:

?_trace=1 today OpenTelemetry after PRs 1–2
Works in a browser against a deployed instance Needs process restart under opentelemetry-instrument, env vars, and somewhere to send data
Traces exactly one request, on demand Traces every request for the process lifetime
Embedded in the JSON API response Separate transport entirely
Records a traceback per query (traceback.extract_stack(limit=6)) — "which line issued this?" No code origin at all
Records parameter values Parameter count only
Zero infrastructure Collector, or console output on the server's stderr

The traceback is the one I'd most want back, and nothing in the OpenTelemetry work replaces it.

from datasette.tracer import trace and trace_child_tasks() are documented plugin APIs
(docs/internals.rst), so this is a breaking change for plugins, not only for users. Any plugin
importing them raises ModuleNotFoundError after this.

datasette-pretty-traces is linked from the internals docs as the recommended way to read this
output, and it does break — but not by import. Its source doesn't touch datasette.tracer at all;
it gates on request.args.get("_trace") and datasette.setting("trace_debug"). Datasette.setting()
returns None for an unknown key rather than raising, so after this PR the plugin installs and
imports fine and simply never injects its JavaScript again. That is a quieter failure than an
ImportError, which arguably makes a coordinated release more important, not less. I'm happy to
send that PR.

GitHub code search, run at time of writing (gh api search/code):

Query Total hits Repos, deduplicated
"from datasette.tracer import" 66 simonw/datasette, simonw/datasette-private, simonw/docs-for-llms, fulcrumresearch/datasette (a fork), PyTables/datasette-connectors
"trace_child_tasks" 81 the same five, plus four unrelated repos that appear to be vendored copies of the Datasette docs (AnantAgarwal07/civic-ai, SBC1357/xiake-island, itcrowdsource/vcftocsvcontactexporter, tushkum34-cloud/pylibsmeta)

The high hit counts are almost entirely Datasette's own repositories, forks of them, and copies of
Datasette's documentation — GitHub counts files, not projects. The only third-party package in
either list is PyTables/datasette-connectors. I have not audited it; someone should before this
merges. "_trace=1" returns 8,424 hits, but that string is far too generic to draw any conclusion
from and I'm not going to pretend otherwise.

latest.datasette.io is deployed with --setting trace_debug 1
(.github/workflows/deploy-latest.yml), so that workflow changes in this PR. To be precise about
what would happen if it didn't: --setting <bare-name> <value> only rewrites to settings.<name>
when <name> is a known setting (datasette/cli.py, Setting.convert). Once trace_debug is
gone, --setting trace_debug 1 is silently accepted as a meaningless top-level config key — no
error, no warning, deploy stays green with a dead flag. (--setting settings.trace_debug 1 does
fail loudly: Error: Invalid setting 'trace_debug' in config file.) The flag is removed here because
it is dead, not because leaving it would break the deploy.

The docs that link to https://latest.datasette.io/fixtures/roadside_attractions?_trace=1 change
too.

The case for removing it anyway

?_trace=1 cannot be rebuilt on OpenTelemetry without core owning a provider. To be precise,
since it matters: the existing hand-rolled tracer coexists with the OpenTelemetry work perfectly
well — they are independent code paths, which is exactly why the previous two PRs left it running.
What does not work is the tempting version of this, where ?_trace=1 is reimplemented on top of the
new spans. That needs Datasette to collect spans in-process and render them into a response, which
means core owns a TracerProvider. An earlier draft of this work did exactly that, and that single
requirement produced three problems 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 ProxyTracer caches its concrete tracer permanently,
    so resets don't work
  • a sampling environment variable that silently blanked output

Deleting ?_trace=1 dissolved all three. The invariant that replaced it — core depends on
opentelemetry-api only and never creates a provider, exporter or sampler
— is what makes the
rest of this work cheap and safe, and it's enforced by a test.

So the honest form of the argument is narrow: core cannot own this feature any more. It says
nothing against the browser experience existing as a plugin — one owning its own provider and an
in-memory span collector could serve much the same page, out of core, where owning a provider is
fine. I haven't built that, and I'd rather say so than imply the option doesn't exist. See "If you'd
rather not" below.

The timing argument. Datasette is in the 1.0 alpha series. Removing a documented plugin API is
dramatically cheaper before 1.0 final than after it. If this is going to happen at all, now is when
it costs least.

It's carrying real baggage. AsgiTracer buffers up to 256KB of every response body to splice
trace output into it, and still carries a # TODO: What to do about Content-Type or other cases?.
?_trace=1 was the subject of a reflected-XSS security release (1360). It has been default-off
since 0.57.

It measures the wrong thing, which is a known open issue.
#1730"SQL tracing should much more closely
track the SQL query execution"
— has been open since 2022, and the hand-rolled tracer is what it is
about. PRs 1 and 2 built the correct measurement alongside it: db.query covers the full round trip
and db.query.execute covers only the work inside the worker thread, so the gap between them is
exactly the thread-pool wait the current tracer folds invisibly into one number. This PR is what
actually closes #1730
, by retiring the measurement that was wrong rather than leaving two in
place.

What's left behind

  • datasette/views/base.py still reads request.args.get("_trace") to wrap CSV output in an HTML
    <textarea> debug page. Removed here.
  • tests/plugins/my_plugin.py keeps its /parallel-queries route, now with the
    trace_child_tasks() wrapper gone. Nothing exercises it any more — its only test lived in
    test_tracer.py and the docs section that referenced it is deleted. Left in place as a fixture.
  • The changelog keeps its historical ?_trace=1 entries, obviously — only forward-looking docs
    change.
  • DATASETTE_TRACE_PLUGINS is not touched by this PR. It's a separate mechanism with a separate
    issue history.
  • datasette.utils.EscapeHtmlWriter now has no caller anywhere in core — the CSV <textarea> debug
    page was its only use. It is left in place because it is an importable name in datasette.utils
    and removing it would widen the API break beyond what this PR advertises. Say the word and it goes.

A note on the three rebuilt tests

Three tests used ?_trace=1 as an assertion instrument rather than testing tracing, so they are
rebuilt against spans instead of deleted: test_table_csv_stream_does_not_calculate_facets,
test_table_csv_stream_does_not_calculate_counts and test_nocount_nofacet_if_shape_is_object.

While rebuilding them I checked each could still fail, and found that all three had stopped being
able to fail some time ago
— they were passing for reasons unrelated to what they claim to test:

  • The facets test looked for the literal string select content, count(*) as n. Facet suggestion
    hasn't emitted that shape since it moved to a with limited as (...) CTE; the modern query is
    select content as value, count(*) as n from limited. The needle matched nothing, so the
    assertion was unconditionally true.
  • Both CSV tests requested a plain .csv URL. On current main a CSV request resolves no count
    or suggested_facets extra at all, so neither query runs regardless of the _nofacet=1/
    _nocount=1 injection in stream_csv() that the tests were nominally guarding.
  • test_nocount_nofacet_if_shape_is_object asserted "count(*)" not in response.text on
    ?_shape=object without asking for a count in the first place — so the _shape in ("array", "object") → nocount = True branch it is named after was never exercised.

The rebuilt versions ask for the work explicitly (?_shape=object&_extra=count&_facet=state), match
on strings the current SQL actually contains, and each carries a guard assertion so an empty span
list can't masquerade as a pass. I verified each one fails when the code it covers is broken, and
passes again when it is restored.

One more test in the same category: test_trace_correctly_escaped requested
/fixtures/-/query?sql=select+'<h1>Hello'&_trace=1 using ds_client, which has no trace_debug
set — so ?_trace=1 did nothing and the test never touched the tracer. What it did cover is the
query page echoing user-supplied SQL back into HTML, which is the exact surface of the two
reflected-XSS advisories in issue 1360, and nothing else in the suite covers it. Rather than delete
it with the rest, it is kept as test_query_page_escapes_sql with the ?_trace=1 dropped. It fails
if autoescape is turned off.

Separately, and not fixed here: for a CSV request table_view_traced() calls table_view_data()
once with the unmodified request before stream_csv() builds the _nofacet=1&_nocount=1 request,
so on /t.csv?_extra=count the count query runs once anyway. The injection halves the work rather
than preventing it. That predates this PR and is a separate issue.

If you'd rather not

Reasonable outcomes other than merging, ranked by what I'd pick:

  1. Merge this, and I write the replacement plugin. A datasette-otel-inspect plugin owning its
    own provider and an in-memory collector, giving back the in-browser per-request view without core
    owning a provider. Say the word and I'll build it before this merges rather than promising it
    afterwards.
  2. Deprecate instead. Keep ?_trace=1 for a release with a deprecation warning, remove after
    1.0. The real cost is carrying two tracing systems through the window, not any provider risk —
    the old tracer owns no provider.
  3. Keep it indefinitely. Entirely viable: the two mechanisms are independent code paths and PRs
    1 and 2 removed nothing. The cost is permanent duplicate instrumentation on the db.execute()
    hot path and two things to keep working.

I'd rather any of these than have the PR sit open.

Migration note for the changelog

For anyone using ?_trace=1 today, the nearest equivalent with no collector:

OTEL_TRACES_EXPORTER=console OTEL_METRICS_EXPORTER=none OTEL_LOGS_EXPORTER=none \
  opentelemetry-instrument datasette mydb.db

Terminal rather than browser, whole process rather than one request. That gap is the regression
above, stated plainly rather than dressed up.

How to test this

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

# the module is gone
python -c "import datasette.tracer"                    # ModuleNotFoundError

# the setting is gone
datasette --help-settings | grep trace_debug           # no match
datasette --setting settings.trace_debug 1 mydb.db     # Error: Invalid setting 'trace_debug'
# note: the *bare* form `--setting trace_debug 1` does NOT error - see above

# no vestiges
grep -rn '_trace\b\|trace_debug' datasette/ docs/ .github/   # changelog history only

Stack created with GitHub Stacks CLIGive Feedback 💬

…ground

Datasette had two tracing systems since the OpenTelemetry spans landed. The
hand-rolled one measures the wrong thing - issue 1730, open since 2022, is
about exactly that - and it cannot be rebuilt on top of the new spans without
core owning a TracerProvider, which is the one thing the OTel design refuses
to do. Rather than carry duplicate instrumentation on the db.execute() hot
path indefinitely, the old system goes.

Deleted: datasette/tracer.py, the trace_debug setting, the AsgiTracer
response-rewriting middleware and the ?_trace=1 query-string argument.

- datasette/database.py: the four `with trace(...)` wrappers PR 1 deliberately
  nested the OTel spans inside are removed and the bodies dedented. That also
  retires the `# noqa: SIM117` comments those wrappers required - a leftover
  unnecessary noqa trips ruff's RUF100 - and `kwargs["count"] = count` in
  execute_write_many, which fed the old tracer only. `git diff -w` on this file
  shows nothing but the deleted lines.
- datasette/views/base.py: stream_csv() still read ?_trace=1 to wrap CSV output
  in an HTML <textarea> debug page. That whole branch, including the
  EscapeHtmlWriter selection and the conditional content-type, is gone. The
  EscapeHtmlWriter class itself stays in datasette.utils - it is an importable
  public name and removing it would widen the API break.
- .github/workflows/deploy-latest.yml no longer passes --setting trace_debug 1.
  Worth stating precisely, because the ticket claimed otherwise: this would not
  have broken the deploy. Setting.convert() in cli.py only rewrites a bare name
  to settings.<name> for *known* settings, so `--setting trace_debug 1` would
  have been silently accepted as a meaningless top-level config key. The flag is
  removed because it is dead, not because it errors.

Tests. tests/test_tracer.py is deleted outright (6 items). Four other tests used
?_trace=1 as an assertion instrument rather than testing tracing:

- test_csv_trace tested the trace mechanism itself - deleted.
- test_table_csv_stream_does_not_calculate_facets,
  test_table_csv_stream_does_not_calculate_counts and
  test_nocount_nofacet_if_shape_is_object test real behaviour, and are rebuilt
  against captured spans. All three had silently stopped being able to fail: the
  facets test looked for "select content, count(*) as n", which facet suggestion
  has not emitted since it moved to a `with limited as (...)` CTE, and none of
  the three requested the count or facet work whose suppression they claim to
  check. The rebuilt versions ask for it explicitly, match strings the current
  SQL contains, and carry a guard assertion so an empty span list cannot
  masquerade as a pass. Each was confirmed to fail with the covered code broken.
- test_trace_correctly_escaped is kept, renamed test_query_page_escapes_sql,
  with ?_trace=1 dropped. It ran against ds_client, which has no trace_debug, so
  it never exercised the tracer - what it actually covered is the query page
  echoing user SQL into HTML, the surface of the two reflected-XSS advisories in
  issue 1360, and nothing else in the suite covers it. Deleting it would have
  quietly dropped that.

tests/test_utils.py's pairs_to_nested_config case used settings.trace_debug to
check that a later key overrides an earlier one; it now uses template_debug
rather than losing the case.

Docs: the datasette.tracer section of internals.rst, the trace_debug section of
settings.rst, the ?_trace=1 entries in json_api.rst and introspection.rst, and
the regenerated cli-reference.rst. changelog.rst gets a breaking-change entry
and keeps all its historical ?_trace=1 entries - two of them had to lose a
:ref: role pointing at a label this commit deletes, or Sphinx warns on every
build.

2368 passed, 39 skipped, 6 xfailed, 15 xpassed, 140 subtests, against 2375 /
141 before. Net -7 tests, fully accounted for: -6 test_tracer.py, -1
test_csv_trace, -1 test_trace_correctly_escaped, +1 test_query_page_escapes_sql.
The lost subtest is the per-setting case trace_debug generated in
test_settings_are_documented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@asg017 asg017 changed the title Remove the hand-rolled tracer now that OpenTelemetry covers the same ground Remove the hand-rolled tracer: ?_trace=1, trace_debug, datasette.tracer Jul 31, 2026
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 57 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (asg017/otel-phase1-2-request-span@fec260e). Learn more about missing BASE report.

Files with missing lines Patch % Lines
datasette/database.py 0.00% 54 Missing ⚠️
datasette/views/base.py 0.00% 2 Missing ⚠️
datasette/views/table.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@                         Coverage Diff                         @@
##             asg017/otel-phase1-2-request-span   #2864   +/-   ##
===================================================================
  Coverage                                     ?   0.00%           
===================================================================
  Files                                        ?      74           
  Lines                                        ?   12430           
  Branches                                     ?       0           
===================================================================
  Hits                                         ?       0           
  Misses                                       ?   12430           
  Partials                                     ?       0           

☔ 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.

SQL tracing should much more closely track the SQL query execution

1 participant