Skip to content

Plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs - #2899

Open
asg017 wants to merge 4 commits into
asg017/otel-phase1-5-metricsfrom
asg017/otel-phase1-6-plugin-kit
Open

Plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs#2899
asg017 wants to merge 4 commits into
asg017/otel-phase1-5-metricsfrom
asg017/otel-phase1-6-plugin-kit

Conversation

@asg017

@asg017 asg017 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator
🤖 Claude-generated PR description

Fourth PR in the OTel stack, on top of #2897. Makes core's telemetry machinery importable by plugins instead of copyable.

Why

A survey of five plugin OTel plans (datasette-paper, datasette-agent, datasette-litestream, datasette-accounts, datasette-cron) found every single one hand-copying the same core machinery: the Attribute/SpanName/MetricName registry classes (~60-80 lines each), the two-way conformance-test harness, the otel_spans/otel_metrics pytest fixtures, the never-imports-the-SDK test, the bucket boundaries, and the detached-root-with-Link recipe. Two plugins had independently invented the same extensions (closed-enum attribute values; prefix-matched span families). Nobody was blocked — the patterns are good enough that everyone copies them verbatim — which is exactly the argument for making them API.

What this adds

Public registry API (datasette/telemetry_registry.py):

  • The registry classes are now documented plugin API.
  • Attribute(values={...}) declares a closed enum, enforced by the conformance helpers — the enforceable version of "bounded, safe as a metric dimension". Enum values render in the generated docs.
  • SpanName(prefix=True) registers a span family whose emitted names share a fixed prefix (chat {model}), matched by span_for() after exact names. (This flag existed once and was removed as dead code — the plugin registries are the consumer that makes it real, so it returns with tests and a user.)
  • span_for() / attribute_value_allowed() accept a spans= tuple so they work against plugin registries.

linked_root_span_kwargs() (datasette/telemetry.py): the root-span-with-Link shape for work a request caused without containing — background jobs, scheduled ticks, block=False writes. Three of the five surveyed plugins planned to hand-roll exactly this incantation. Core's write thread now uses it instead of building the kwargs inline (dogfood).

datasette.telemetry_testing: the session-scoped provider fixtures, otel_spans / otel_metrics, two-way conformance checkers for both spans and metrics, and assert_package_never_imports_sdk(). Imports the SDK lazily — fixtures skip when it's absent. Core's own conftest and conformance tests now import these instead of defining them, so the suite consumes the kit exactly the way a plugin's would.

  • Span side: assert_spans_conform / assert_spans_covered — enum- and prefix-aware, filtered by instrumentation scope so a plugin is only judged against its own signals.
  • assert_no_forbidden_values(): the enforcement half of the docs page's privacy rules — plant sentinel secrets in a workload and assert they never appear in any span name, attribute, event, status description or metric attribute, across all scopes by default (upstreamed from the datasette-accounts plan, which invented it).
  • otel_reset: an autouse fixture draining the exporter and reader after every test, so a large suite doesn't accumulate recorded spans for its whole lifetime — and the installers now detect a pre-existing provider (opentelemetry-instrument, an embedding app) and skip clearly instead of asserting against an exporter wired to nothing.
  • Metric side (metric_for() + a scope-retaining MetricsCollector): assert_metrics_conform additionally verifies each instrument was created as the kind and unit its registry entry declares — drift between the registry entry and the meter.create_*() call was previously caught by nothing, in core or any plugin — and enforces values= enums on metric dimensions, the place a cardinality bound matters most (datasette.operation now declares values={"read", "write"} as the dogfood). Four instrument kinds are supported including UPDOWN_COUNTER, with monotonicity checked both ways. assert_metrics_covered is the reverse direction; both *_covered helpers exempt optional=True attributes so a workload isn't forced to manufacture every error path.

Docs: new page :ref:plugin_telemetry — "Telemetry for plugin authors": own-scope discipline, registry usage with a worked example, privacy/cardinality rules, pass-named-callables guidance, request_span() (now documented public API), the background convention (root span per tick with an outcome attribute, always emitted, linked not parented), ProxyTracer-vs-_ProxyMeter ordering facts, prescribed naming rules (scope = import package name; signal prefixes you own, never bare datasette.*), an observable-gauges pattern section, and known caveats (streaming spans, deliberate double-measurement, datasette.client sub-request spans — now marked with datasette.internal_client: true by #2863 so dashboards can filter them).

Tests

  • tests/test_telemetry_testing_kit.py: a toy plugin registry + tracer + meter exercising both conformance directions for spans and metrics, enum enforcement, prefix families, kind/unit mismatch detection, optional-attribute exemption, scope filtering, linked_root_span_kwargs() (root, not child; link present; no-current-span case), and the kit's own lazy-SDK guarantee.
  • tests/test_telemetry_registry.py: prefix lookup (restored), exact-beats-prefix precedence, enum value enforcement; the generic metric conformance tests are now kit-helper calls with scope_name="datasette", while the stricter literal-pinning and optional-coverage tests stay hand-written on purpose.
  • Existing telemetry suites run unchanged against the relocated fixtures.

🤖 Generated with Claude Code

https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 252 lines in your changes missing coverage. Please review.
✅ Project coverage is 0.00%. Comparing base (9e5a402) to head (71d382b).

Files with missing lines Patch % Lines
datasette/telemetry_testing.py 0.00% 212 Missing ⚠️
datasette/telemetry_registry.py 0.00% 32 Missing ⚠️
datasette/telemetry.py 0.00% 6 Missing ⚠️
datasette/database.py 0.00% 2 Missing ⚠️
Additional details and impacted files
@@                      Coverage Diff                      @@
##           asg017/otel-phase1-5-metrics   #2899    +/-   ##
=============================================================
  Coverage                          0.00%   0.00%            
=============================================================
  Files                                75      76     +1     
  Lines                             12738   12975   +237     
=============================================================
- Misses                            12738   12975   +237     

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

asg017 and others added 4 commits September 2, 2026 14:21
…rgs, test helpers, docs

A survey of five plugin OTel plans (datasette-paper, -agent, -litestream,
-accounts, -cron) found every one hand-copying the same core machinery:
the registry classes, the conformance-test harness, the pytest fixtures,
the bucket boundaries and the detached-root-with-Link recipe. This makes
that machinery importable instead:

- The registry classes are documented public API. Attribute gains
  values= (a closed enum the conformance helpers enforce - what makes an
  attribute safe as a metric dimension); SpanName gains prefix=True for
  span families like "chat {model}" whose names share a fixed prefix,
  matched by span_for() after exact names. span_for()/attribute helpers
  accept a spans= tuple so plugin registries can use them.
- datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link
  shape for work a request caused without containing - background jobs,
  scheduled ticks, block=False writes. Core's own write thread now uses
  it instead of building the kwargs inline.
- datasette.telemetry_testing: the session provider fixtures, otel_spans
  / otel_metrics, a two-way registry conformance checker (including enum
  and prefix handling, filtered by instrumentation scope) and an
  assert_package_never_imports_sdk() guard. Core's conftest now imports
  these instead of defining them, so the suite consumes the kit exactly
  as a plugin's would.
- New "Telemetry for plugin authors" docs page: scope discipline,
  registry usage, privacy/cardinality rules, named-callable guidance,
  request_span(), the background root-with-link convention (one root per
  tick, always emitted), provider-ordering facts and known caveats.
  request_span() is now documented public API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
The five surveyed plugin plans all kept a hand-rolled metrics-vs-registry
diff because the kit's conformance helpers covered spans only. This adds
the metric side:

- metric_for() in the registry (the span_for analogue - no prefix/dynamic
  machinery, metric names are static), and the attribute helpers are
  documented as accepting MetricName entries.
- MetricsCollector.collect() now retains the instrumentation scope per
  collected metric, so a plugin is judged against its own meter only.
- assert_metrics_conform(): every collected metric in scope is registered,
  was created as the instrument kind and unit its registry entry declares
  (drift between the registry entry and the meter.create_*() call was
  previously caught by nothing, in core or any plugin), sets only
  registered attributes, and respects values= enums - the check that makes
  a metric dimension provably bounded.
- assert_metrics_covered(): every registered metric collected at least
  once with every non-optional attribute seen. Both *_covered helpers now
  exempt optional=True attributes, so a workload is not forced to
  manufacture every error path; pin those with targeted tests instead.
- datasette.operation declares values={"read", "write"} - core dogfoods
  the enum enforcement on the dimension where it matters most.
- Core's generic metric conformance tests are now calls to the kit
  helpers with scope_name="datasette"; the stricter literal-pinning and
  optional-attribute-coverage tests stay hand-written on purpose.
- The metric reference docs render attributes through the same helper as
  spans, so *(optional)* markers and enum values now appear there too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
test_kit_module_itself_never_imports_the_sdk shells out, and like every
subprocess-spawning test in this suite it crashes the interpreter with
SIGBUS on macOS/CPython 3.13 when it runs late enough that the process
holds many threads - the exact failure conftest already front-loads
test_datasette_package_never_imports_the_sdk for. Move it to the front
too, and note the hazard in assert_package_never_imports_sdk's docstring
since plugin suites will call it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
…Counter, naming rules, privacy walk

Outcome of a whole-stack review with the kit visible as one system:

- otel_reset: an autouse fixture draining the span exporter and metric
  reader after every test. Without it a large suite accumulates hundreds
  of thousands of recorded spans in the session-scoped exporter - the
  likeliest amplifier of the slow-runner CI flakes - and plugins would
  inherit the same leak.
- assert_registry_covered renamed to assert_spans_covered: the old name
  read as covering the whole registry, which is exactly wrong next to
  assert_metrics_covered. Public API is forever; renamed before anything
  ships, no alias.
- The installers now verify their provider actually took: with a
  provider installed first (opentelemetry-instrument, an embedding app),
  set_*_provider() is silently ignored, and fixtures would assert
  against an exporter wired to nothing. They skip clearly instead.
- UPDOWN_COUNTER registry kind, mapped to Sum with monotonicity checked
  both ways - a Counter must collect monotonic, an UpDownCounter must
  not. Previously an UpDownCounter's kind check was silently skipped.
- The docs page now prescribes naming: scope = import package name
  (underscores), signal prefix = a name you own, never bare datasette.*;
  its own examples no longer teach the hyphenated outlier. Plus an
  observable-gauges pattern section and a prefix-overlap note.
- assert_no_forbidden_values(): the enforcement half of the privacy
  rules - plant sentinel secrets in a workload and assert they never
  appear in any span name, attribute, event, status description or
  metric attribute, across all scopes by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
@asg017
asg017 force-pushed the asg017/otel-phase1-6-plugin-kit branch from 0dcb712 to 71d382b Compare September 2, 2026 21:28
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