mirror of
https://github.com/simonw/datasette.git
synced 2026-09-08 17:44:06 +02:00
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
400 lines
16 KiB
Python
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()}"
|
|
)
|