Name every span and attribute once, in a registry the docs are built from
The span and attribute names were string literals spread across four call
sites in database.py and one in app.py, with a hand-written reference page
that would have been true only on the day it was written. That drift is not
hypothetical: an earlier iteration of this work carried a README asserting
parameter values were never recorded for two branches after that had stopped
being true.
datasette/telemetry_registry.py now holds each name once, with its
documentation. Attribute and SpanName subclass str, so a registry entry *is*
the string OpenTelemetry wants - no wrapper API over the OTel calls, no
parallel structure to keep in step, and a typo becomes an ImportError rather
than a silently misnamed attribute. docs/internals.rst renders the span
reference from it via cog, and `cog --check docs/*.rst` already runs in CI,
so the reference cannot drift from the definitions.
Nothing changes on the wire: the emitted span names and attribute keys are
byte-identical before and after, verified by diffing a dump of both.
tests/test_telemetry_registry.py exercises a real workload and compares it
against the registry in both directions - emitted-but-unregistered catches
instrumentation added without documentation, registered-but-never-emitted
catches documentation that has outlived its code. Because the call sites now
take their names from the registry, neither direction can catch a rename:
move DB_NAMESPACE to "db.namespace2" and code and registry still agree while
every dashboard breaks. So the literal names are also written out in the test
and asserted against the registry and against the wire separately. That pair
is the only comparison in the file not derived from the registry itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
|
|
|
"""
|
|
|
|
|
Render the span reference in ``internals.rst`` from
|
|
|
|
|
``datasette/telemetry_registry.py``.
|
|
|
|
|
|
|
|
|
|
Driven by cog, and ``cog --check docs/*.rst`` runs in CI - so adding a span
|
|
|
|
|
without documenting it, or documenting one that no longer exists, is a build
|
|
|
|
|
failure rather than something a reader discovers later.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _attribute_lines(cog, attributes):
|
|
|
|
|
if not attributes:
|
|
|
|
|
cog.out(" No attributes.\n\n")
|
|
|
|
|
return
|
|
|
|
|
cog.out(" Attributes:\n\n")
|
|
|
|
|
for attribute in attributes:
|
|
|
|
|
suffix = " *(optional)*" if attribute.optional else ""
|
Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, 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
2026-09-02 12:24:42 -07:00
|
|
|
line = f" - ``{attribute}``{suffix} - {attribute.description}"
|
|
|
|
|
if attribute.values is not None:
|
|
|
|
|
rendered = ", ".join(f"``{value}``" for value in sorted(attribute.values))
|
|
|
|
|
line += f" One of: {rendered}."
|
|
|
|
|
cog.out(line + "\n")
|
Name every span and attribute once, in a registry the docs are built from
The span and attribute names were string literals spread across four call
sites in database.py and one in app.py, with a hand-written reference page
that would have been true only on the day it was written. That drift is not
hypothetical: an earlier iteration of this work carried a README asserting
parameter values were never recorded for two branches after that had stopped
being true.
datasette/telemetry_registry.py now holds each name once, with its
documentation. Attribute and SpanName subclass str, so a registry entry *is*
the string OpenTelemetry wants - no wrapper API over the OTel calls, no
parallel structure to keep in step, and a typo becomes an ImportError rather
than a silently misnamed attribute. docs/internals.rst renders the span
reference from it via cog, and `cog --check docs/*.rst` already runs in CI,
so the reference cannot drift from the definitions.
Nothing changes on the wire: the emitted span names and attribute keys are
byte-identical before and after, verified by diffing a dump of both.
tests/test_telemetry_registry.py exercises a real workload and compares it
against the registry in both directions - emitted-but-unregistered catches
instrumentation added without documentation, registered-but-never-emitted
catches documentation that has outlived its code. Because the call sites now
take their names from the registry, neither direction can catch a rename:
move DB_NAMESPACE to "db.namespace2" and code and registry still agree while
every dashboard breaks. So the literal names are also written out in the test
and asserted against the registry and against the wire separately. That pair
is the only comparison in the file not derived from the registry itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
|
|
|
cog.out("\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def spans(cog):
|
|
|
|
|
from opentelemetry.trace import SpanKind
|
|
|
|
|
|
|
|
|
|
from datasette.telemetry_registry import SPANS
|
|
|
|
|
|
|
|
|
|
cog.out("\n")
|
|
|
|
|
for span in SPANS:
|
2026-09-02 11:25:10 -07:00
|
|
|
cog.out(f"``{span}``\n")
|
Name every span and attribute once, in a registry the docs are built from
The span and attribute names were string literals spread across four call
sites in database.py and one in app.py, with a hand-written reference page
that would have been true only on the day it was written. That drift is not
hypothetical: an earlier iteration of this work carried a README asserting
parameter values were never recorded for two branches after that had stopped
being true.
datasette/telemetry_registry.py now holds each name once, with its
documentation. Attribute and SpanName subclass str, so a registry entry *is*
the string OpenTelemetry wants - no wrapper API over the OTel calls, no
parallel structure to keep in step, and a typo becomes an ImportError rather
than a silently misnamed attribute. docs/internals.rst renders the span
reference from it via cog, and `cog --check docs/*.rst` already runs in CI,
so the reference cannot drift from the definitions.
Nothing changes on the wire: the emitted span names and attribute keys are
byte-identical before and after, verified by diffing a dump of both.
tests/test_telemetry_registry.py exercises a real workload and compares it
against the registry in both directions - emitted-but-unregistered catches
instrumentation added without documentation, registered-but-never-emitted
catches documentation that has outlived its code. Because the call sites now
take their names from the registry, neither direction can catch a rename:
move DB_NAMESPACE to "db.namespace2" and code and registry still agree while
every dashboard breaks. So the literal names are also written out in the test
and asserted against the registry and against the wire separately. That pair
is the only comparison in the file not derived from the registry itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:46:34 -07:00
|
|
|
cog.out(f" {span.description}\n\n")
|
|
|
|
|
# INTERNAL is the default and the overwhelming majority of spans -
|
|
|
|
|
# printing it on every one would be noise. Only the exceptional case,
|
|
|
|
|
# a real database call, is worth calling out.
|
|
|
|
|
if span.kind != SpanKind.INTERNAL:
|
|
|
|
|
cog.out(f" Kind: ``{span.kind.name}``.\n\n")
|
|
|
|
|
_attribute_lines(cog, span.attributes)
|
2026-09-01 12:24:01 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def metrics(cog):
|
|
|
|
|
from datasette.telemetry_registry import METRICS
|
|
|
|
|
|
|
|
|
|
cog.out("\n")
|
|
|
|
|
for metric in METRICS:
|
|
|
|
|
cog.out(f"``{metric}``\n")
|
|
|
|
|
cog.out(f" {metric.kind}, unit ``{metric.unit}``. {metric.description}\n\n")
|
|
|
|
|
if metric.buckets:
|
|
|
|
|
boundaries = ", ".join(f"``{boundary}``" for boundary in metric.buckets)
|
|
|
|
|
cog.out(f" Bucket boundaries: {boundaries}.\n\n")
|
|
|
|
|
if metric.attributes:
|
|
|
|
|
cog.out(" Attributes:\n\n")
|
|
|
|
|
for attribute in metric.attributes:
|
|
|
|
|
cog.out(f" - ``{attribute}`` - {attribute.description}\n")
|
|
|
|
|
cog.out("\n")
|
|
|
|
|
else:
|
|
|
|
|
cog.out(" No attributes.\n\n")
|