datasette/datasette/telemetry_testing.py
Alex Garcia 2a05263ab8 Extend the kit with metric-side conformance: kind, unit and enum checks
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
2026-09-02 14:21:14 -07:00

400 lines
16 KiB
Python

"""
Pytest helpers for testing OpenTelemetry instrumentation - Datasette's own
and any plugin's. Part of Datasette's public plugin API; see the "Telemetry
for plugin authors" documentation.
Usage from a plugin's ``conftest.py``::
from datasette.telemetry_testing import ( # noqa: F401
MetricsCollector,
otel_metrics,
otel_meter_provider,
otel_provider,
otel_spans,
)
Importing the fixture names into a conftest registers them; ``otel_provider``
and ``otel_meter_provider`` are session-scoped and autouse, so a real SDK
provider (when the SDK is installed) is in place before any test emits a
signal. Tests then take ``otel_spans`` / ``otel_metrics``. Everything here
imports the OpenTelemetry SDK lazily: with no SDK installed the fixtures
skip rather than fail, and importing this module costs nothing.
The conformance helpers (`assert_spans_conform`, `assert_registry_covered`)
check a registry of `SpanName` entries against actually-finished spans in
both directions - emitted-but-unregistered and registered-but-never-emitted,
the two drift modes documented in `tests/test_telemetry_registry.py`.
"""
import subprocess
import sys
import pytest
from .telemetry_registry import (
attribute_allowed,
attribute_value_allowed,
metric_for,
span_for,
)
_span_exporter = None
_metric_reader = None
def install_span_exporter():
"""
Install a TracerProvider + InMemorySpanExporter once per process and
return the exporter, or None when the SDK is not installed.
`set_tracer_provider()` is effectively once-per-process (a second call
logs a warning and is ignored), so this must run before anything asserts
on spans. A `SimpleSpanProcessor` exports synchronously on span end - no
background batching thread, so assertions immediately after a request
never race.
"""
global _span_exporter
if _span_exporter is not None:
return _span_exporter
try:
from opentelemetry import trace as otel_trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
except ImportError:
return None
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
otel_trace.set_tracer_provider(provider)
_span_exporter = exporter
return exporter
def install_metric_reader():
"""
Install a MeterProvider + InMemoryMetricReader once per process and
return the reader, or None when the SDK is not installed.
DELTA temporality for counters and histograms, so each collection
reports only what happened since the previous one - with the SDK default
of CUMULATIVE, every metrics test would see every measurement from every
earlier test in the session.
"""
global _metric_reader
if _metric_reader is not None:
return _metric_reader
try:
from opentelemetry import metrics as otel_metrics_api
from opentelemetry.sdk.metrics import Counter, Histogram, MeterProvider
from opentelemetry.sdk.metrics.export import (
AggregationTemporality,
InMemoryMetricReader,
)
except ImportError:
return None
reader = InMemoryMetricReader(
preferred_temporality={
Counter: AggregationTemporality.DELTA,
Histogram: AggregationTemporality.DELTA,
}
)
otel_metrics_api.set_meter_provider(MeterProvider(metric_readers=[reader]))
_metric_reader = reader
return reader
@pytest.fixture(scope="session", autouse=True)
def otel_provider():
"""
Session-scoped, autouse: install the span exporter exactly once, before
any span is created.
`datasette.telemetry.tracer` (and a plugin's own tracer) is a
module-level `ProxyTracer`: once a provider exists, the first span it
starts resolves a concrete tracer and caches it permanently. It does
*not* cache the no-op tracer, so a span started before this fixture runs
is merely lost rather than poisoning the tracer for the process. With no
SDK installed this does nothing and spans stay no-op.
"""
install_span_exporter()
@pytest.fixture(scope="session", autouse=True)
def otel_meter_provider():
"""
Session-scoped, autouse: install the metric reader once per process.
Unlike the tracer, ordering is not load-bearing - `_ProxyMeter` and its
instruments forward to a provider installed after they were created.
Still autouse for symmetry, and so a single reader collects all run.
"""
install_metric_reader()
@pytest.fixture
def otel_spans():
"""
Function-scoped access to the finished-spans exporter: clears spans left
over from previous tests, then yields the exporter so a test can call
`.get_finished_spans()`. Skips if the OTel SDK is not installed.
"""
pytest.importorskip("opentelemetry.sdk")
exporter = install_span_exporter()
if exporter is None:
pytest.skip("OpenTelemetry SDK provider was not installed")
exporter.clear()
yield exporter
class MetricsCollector:
"""
Thin reader over an `InMemoryMetricReader`.
`collect()` runs a collection cycle - which is what invokes observable
gauge callbacks - and snapshots the result. Queries then run against
that snapshot rather than re-collecting, so a test that inspects
several metrics sees one consistent moment and does not drain delta
state twice.
"""
def __init__(self, reader):
self.reader = reader
self.snapshot = {}
# (instrumentation scope name, sdk Metric) pairs from the last
# collect() - the metric conformance helpers read this, because the
# name-keyed snapshot deliberately flattens the scope away.
self.collected = []
def collect(self):
self.snapshot = {}
self.collected = []
data = self.reader.get_metrics_data()
if data is None:
return self.snapshot
for resource_metrics in data.resource_metrics:
for scope_metrics in resource_metrics.scope_metrics:
scope_name = scope_metrics.scope.name if scope_metrics.scope else None
for metric in scope_metrics.metrics:
self.snapshot.setdefault(metric.name, []).extend(
metric.data.data_points
)
self.collected.append((scope_name, metric))
return self.snapshot
def points(self, name, attributes=None):
"Data points for `name` whose attributes are a superset of `attributes`."
found = []
for point in self.snapshot.get(name, []):
point_attributes = dict(point.attributes or {})
if all(point_attributes.get(k) == v for k, v in (attributes or {}).items()):
found.append(point)
return found
def point(self, name, attributes=None):
"The single matching data point, asserting there is exactly one."
found = self.points(name, attributes)
assert len(found) == 1, (
f"expected exactly one {name} point matching {attributes}, "
f"got {len(found)}: {found}"
)
return found[0]
@pytest.fixture
def otel_metrics():
"""
Function-scoped metrics collector. Drains delta state accumulated by
earlier tests before yielding, so counts start from zero.
"""
pytest.importorskip("opentelemetry.sdk")
reader = install_metric_reader()
if reader is None:
pytest.skip("OpenTelemetry SDK meter provider was not installed")
reader.get_metrics_data()
yield MetricsCollector(reader)
def _scoped(finished_spans, scope_name):
if scope_name is None:
return list(finished_spans)
return [
span
for span in finished_spans
if span.instrumentation_scope and span.instrumentation_scope.name == scope_name
]
def assert_spans_conform(registry_spans, finished_spans, scope_name=None):
"""
Every finished span (optionally: only those from `scope_name`, which is
what a plugin should pass - its own tracer's name) resolves to an entry
in `registry_spans`, sets only registered attributes, and respects any
declared `values=` enums. This is the emitted-but-unregistered direction:
instrumentation added without documentation fails here.
"""
problems = []
for span in _scoped(finished_spans, scope_name):
entry = span_for(str(span.name), kind=span.kind, spans=registry_spans)
if entry is None:
problems.append(f"unregistered span: {span.name!r}")
continue
for key, value in (span.attributes or {}).items():
if not attribute_allowed(entry, str(key)):
problems.append(f"{span.name}: unregistered attribute {key!r}")
elif not attribute_value_allowed(entry, str(key), value):
problems.append(
f"{span.name}: {key}={value!r} not in the declared enum"
)
assert not problems, "\n".join(problems)
def assert_registry_covered(registry_spans, finished_spans, scope_name=None):
"""
Every entry in `registry_spans` was emitted at least once, and every one
of its registered non-`optional` attributes appeared on it at least
once. This is the registered-but-never-emitted direction - documentation
describing a signal that no longer exists, which is worse than omitting
it because a reader will build a dashboard on it. Run it against a
workload broad enough to exercise everything the registry claims;
`optional=True` attributes are exempt so a workload is not forced to
manufacture every error path (pin those with targeted tests instead).
"""
spans = _scoped(finished_spans, scope_name)
seen_attributes = {}
for span in spans:
entry = span_for(str(span.name), kind=span.kind, spans=registry_spans)
if entry is not None:
seen = seen_attributes.setdefault(str(entry), set())
seen.update(str(key) for key in (span.attributes or {}))
problems = []
for entry in registry_spans:
if str(entry) not in seen_attributes:
problems.append(f"registered span never emitted: {entry!r}")
continue
required = {
str(attribute) for attribute in entry.attributes if not attribute.optional
}
missing = required - seen_attributes[str(entry)]
if missing:
problems.append(
f"{entry}: registered attributes never emitted: {sorted(missing)}"
)
assert not problems, "\n".join(problems)
# Registry instrument kinds mapped to the SDK data type collected for them.
# A registry kind outside this table (a plugin's own vocabulary) is not
# kind-checked. "Counter" maps to Sum; monotonicity is not asserted, so
# UpDownCounters registered as "Counter" pass too.
_KIND_TO_DATA_TYPE = {
"Counter": "Sum",
"Histogram": "Histogram",
"Observable gauge": "Gauge",
}
def _scoped_metrics(collector, scope_name):
for scope, metric in collector.collected:
if scope_name is None or scope == scope_name:
yield metric
def assert_metrics_conform(registry_metrics, collector, scope_name=None):
"""
Every metric in the collector's last `collect()` (optionally: only those
from `scope_name`, which is what a plugin should pass - its own meter's
name) is registered in `registry_metrics`, was created as the instrument
kind and unit the registry declares, sets only registered attributes,
and respects any declared `values=` enums.
The kind and unit checks catch a drift nothing else does: the registry
entry and the `meter.create_*()` call are separate statements, and a
dashboard built on the registry's word breaks silently if they disagree.
"""
problems = set()
for metric in _scoped_metrics(collector, scope_name):
entry = metric_for(metric.name, metrics=registry_metrics)
if entry is None:
problems.add(f"unregistered metric: {metric.name!r}")
continue
expected_data_type = _KIND_TO_DATA_TYPE.get(entry.kind)
actual_data_type = type(metric.data).__name__
if expected_data_type is not None and actual_data_type != expected_data_type:
problems.add(
f"{metric.name}: registry declares {entry.kind}, "
f"SDK collected {actual_data_type}"
)
if (metric.unit or "") != (entry.unit or ""):
problems.add(
f"{metric.name}: instrument unit {metric.unit!r} != "
f"registry unit {entry.unit!r}"
)
for point in metric.data.data_points:
for key, value in dict(point.attributes or {}).items():
if not attribute_allowed(entry, str(key)):
problems.add(f"{metric.name}: unregistered attribute {key!r}")
elif not attribute_value_allowed(entry, str(key), value):
problems.add(
f"{metric.name}: {key}={value!r} not in the declared enum"
)
assert not problems, "\n".join(sorted(problems))
def assert_metrics_covered(registry_metrics, collector, scope_name=None):
"""
Every entry in `registry_metrics` was collected at least once, and every
registered non-`optional` attribute appeared on it at least once - the
registered-but-never-emitted direction for metrics.
Run one broad workload, then a single `collect()`, then this: the reader
uses delta temporality, so measurements drained by an earlier collect()
are gone. `optional=True` attributes (e.g. an `error.type` only present
on failures) are exempt, same as the span-side helper.
"""
seen_attributes = {}
for metric in _scoped_metrics(collector, scope_name):
entry = metric_for(metric.name, metrics=registry_metrics)
if entry is None:
continue
seen = seen_attributes.setdefault(str(entry), set())
for point in metric.data.data_points:
seen.update(str(key) for key in dict(point.attributes or {}))
problems = []
for entry in registry_metrics:
if str(entry) not in seen_attributes:
problems.append(f"registered metric never collected: {entry!r}")
continue
required = {
str(attribute) for attribute in entry.attributes if not attribute.optional
}
missing = required - seen_attributes[str(entry)]
if missing:
problems.append(
f"{entry}: registered attributes never collected: {sorted(missing)}"
)
assert not problems, "\n".join(problems)
def assert_package_never_imports_sdk(*module_names):
"""
Import the named modules in a fresh interpreter and assert none of them
dragged in `opentelemetry.sdk`. Checked via sys.modules in a subprocess
rather than by grepping, so a lazy `import opentelemetry.sdk` inside a
function body cannot slip past. A plugin should depend on
`opentelemetry-api` only, exactly as Datasette core does.
"""
imports = "; ".join(f"import {name}" for name in module_names)
code = (
f"import sys; {imports}; "
"print([m for m in sys.modules if m.startswith('opentelemetry.sdk')])"
)
result = subprocess.run(
[sys.executable, "-c", code], capture_output=True, text=True, check=True
)
assert result.stdout.strip() == "[]", (
f"importing {module_names} pulled in the OpenTelemetry SDK: "
f"{result.stdout.strip()}"
)