mirror of
https://github.com/simonw/datasette.git
synced 2026-09-08 01:24:14 +02:00
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
This commit is contained in:
parent
f28db54eda
commit
2a05263ab8
8 changed files with 304 additions and 59 deletions
|
|
@ -204,7 +204,11 @@ ERROR_TYPE = Attribute(
|
|||
|
||||
DB_SYSTEM = Attribute("db.system", "Always ``sqlite``.")
|
||||
DB_NAMESPACE = Attribute("db.namespace", "Name of the database being queried.")
|
||||
OPERATION = Attribute("datasette.operation", "``read`` or ``write``.")
|
||||
OPERATION = Attribute(
|
||||
"datasette.operation",
|
||||
"Whether the operation was a read or a write.",
|
||||
values={"read", "write"},
|
||||
)
|
||||
DB_QUERY_TEXT = Attribute(
|
||||
"db.query.text",
|
||||
"The SQL, truncated to 2048 characters. Never the parameter values. "
|
||||
|
|
@ -461,24 +465,47 @@ def span_for(emitted_name, kind=None, spans=None):
|
|||
return None
|
||||
|
||||
|
||||
def attribute_allowed(span, emitted_key):
|
||||
"Whether `emitted_key` is a registered attribute of `span`."
|
||||
if span is None:
|
||||
return False
|
||||
return emitted_key in span.attributes
|
||||
|
||||
|
||||
def attribute_value_allowed(span, emitted_key, value):
|
||||
def metric_for(emitted_name, metrics=None):
|
||||
"""
|
||||
Whether `value` is permitted for `emitted_key` on `span`.
|
||||
Resolve an emitted metric name to its registry entry, or None.
|
||||
|
||||
The `span_for()` analogue - simpler, because metric names are always
|
||||
static strings. `metrics` defaults to core's own registry; the plugin
|
||||
testing kit passes a plugin's tuple instead.
|
||||
"""
|
||||
if metrics is None:
|
||||
metrics = METRICS
|
||||
for metric in metrics:
|
||||
if emitted_name == metric:
|
||||
return metric
|
||||
return None
|
||||
|
||||
|
||||
def attribute_allowed(entry, emitted_key):
|
||||
"""
|
||||
Whether `emitted_key` is a registered attribute of `entry`.
|
||||
|
||||
`entry` is a `SpanName` or a `MetricName` - both carry `.attributes`.
|
||||
"""
|
||||
if entry is None:
|
||||
return False
|
||||
return emitted_key in entry.attributes
|
||||
|
||||
|
||||
def attribute_value_allowed(entry, emitted_key, value):
|
||||
"""
|
||||
Whether `value` is permitted for `emitted_key` on `entry` (a `SpanName`
|
||||
or a `MetricName`).
|
||||
|
||||
True for any value when the attribute declares no `values=` enum; when it
|
||||
does, membership is enforced - that is what makes a declared enum a real
|
||||
cardinality bound rather than documentation.
|
||||
cardinality bound rather than documentation. On a metric entry this is
|
||||
where the bound matters most: a metric series is keyed by its attribute
|
||||
values.
|
||||
"""
|
||||
if span is None:
|
||||
if entry is None:
|
||||
return False
|
||||
for attribute in span.attributes:
|
||||
for attribute in entry.attributes:
|
||||
if attribute == emitted_key:
|
||||
return attribute.values is None or value in attribute.values
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import pytest
|
|||
from .telemetry_registry import (
|
||||
attribute_allowed,
|
||||
attribute_value_allowed,
|
||||
metric_for,
|
||||
span_for,
|
||||
)
|
||||
|
||||
|
|
@ -162,18 +163,25 @@ class MetricsCollector:
|
|||
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):
|
||||
|
|
@ -246,11 +254,13 @@ def assert_spans_conform(registry_spans, finished_spans, scope_name=None):
|
|||
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 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.
|
||||
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 = {}
|
||||
|
|
@ -264,7 +274,10 @@ def assert_registry_covered(registry_spans, finished_spans, scope_name=None):
|
|||
if str(entry) not in seen_attributes:
|
||||
problems.append(f"registered span never emitted: {entry!r}")
|
||||
continue
|
||||
missing = set(map(str, entry.attributes)) - seen_attributes[str(entry)]
|
||||
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)}"
|
||||
|
|
@ -272,6 +285,99 @@ def assert_registry_covered(registry_spans, finished_spans, scope_name=None):
|
|||
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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Unreleased
|
|||
- Every HTTP request now gets an OpenTelemetry ``SERVER`` span, named after the request method and matched route, carrying ``http.route``, the response status and W3C trace context extracted from inbound headers - so every database span has a request to belong to, and Datasette joins distributed traces started by a proxy or calling service. The query string is never recorded. See :ref:`internals_telemetry_requests`. (:issue:`1730`)
|
||||
- Datasette core now also emits OpenTelemetry **metrics** covering SQL thread pool saturation, per-database write queue depth, open connections, query latency and time-limit interruptions. These answer operational questions that spans structurally cannot - "am I saturating my :ref:`setting_num_sql_threads` threads?" is a level, not an event - and they survive trace sampling. As with spans, core installs no ``MeterProvider``, so there is no cost unless metrics are collected externally. See :ref:`internals_telemetry`. (:issue:`1730`)
|
||||
|
||||
- New :ref:`plugin telemetry kit <plugin_telemetry>` for plugins that emit their own OpenTelemetry signals: the registry classes (``Attribute`` with closed-enum ``values=``, ``SpanName`` with prefix-matched families, ``MetricName``) are now documented public API, ``datasette.telemetry.linked_root_span_kwargs()`` provides the root-span-with-link shape for background work, ``datasette.telemetry.request_span()`` is documented, and ``datasette.telemetry_testing`` ships the pytest fixtures and two-way conformance checks core's own suite uses. (:issue:`1730`)
|
||||
- New :ref:`plugin telemetry kit <plugin_telemetry>` for plugins that emit their own OpenTelemetry signals: the registry classes (``Attribute`` with closed-enum ``values=``, ``SpanName`` with prefix-matched families, ``MetricName``) are now documented public API, ``datasette.telemetry.linked_root_span_kwargs()`` provides the root-span-with-link shape for background work, ``datasette.telemetry.request_span()`` is documented, and ``datasette.telemetry_testing`` ships the pytest fixtures and two-way conformance checks - for spans and metrics, including instrument kind/unit verification and enum enforcement - that core's own suite uses. (:issue:`1730`)
|
||||
|
||||
Nothing is removed by the OpenTelemetry work: the ``?_trace=1`` query string parameter, the ``trace_debug`` setting and the :ref:`internals_tracer` module all continue to work as before.
|
||||
|
||||
|
|
|
|||
|
|
@ -2476,8 +2476,8 @@ This reference is generated from ``datasette/telemetry_registry.py``, like the s
|
|||
|
||||
- ``db.system`` - Always ``sqlite``.
|
||||
- ``db.namespace`` - Name of the database being queried.
|
||||
- ``datasette.operation`` - ``read`` or ``write``.
|
||||
- ``error.type`` - Set when the request failed: the exception class name if one escaped the application, otherwise the status code as a string for a 5xx response. A 4xx does **not** set this and does not set an error status - per semantic conventions a client error is not a server span's failure.
|
||||
- ``datasette.operation`` - Whether the operation was a read or a write. One of: ``read``, ``write``.
|
||||
- ``error.type`` *(optional)* - Set when the request failed: the exception class name if one escaped the application, otherwise the status code as a string for a 5xx response. A 4xx does **not** set this and does not set an error status - per semantic conventions a client error is not a server span's failure.
|
||||
|
||||
``datasette.write.queue_wait``
|
||||
Histogram, unit ``s``. Time each write waited in its database's write queue. The metric counterpart of the ``db.write.queue_wait`` span.
|
||||
|
|
|
|||
|
|
@ -163,27 +163,35 @@ Wire your registry to reality with the conformance helpers - the two directions
|
|||
.. code-block:: python
|
||||
|
||||
from datasette.telemetry_testing import (
|
||||
assert_metrics_conform,
|
||||
assert_metrics_covered,
|
||||
assert_package_never_imports_sdk,
|
||||
assert_registry_covered,
|
||||
assert_spans_conform,
|
||||
)
|
||||
|
||||
from my_plugin.telemetry import SPANS
|
||||
from my_plugin.telemetry import METRICS, SPANS
|
||||
|
||||
|
||||
def test_conformance(otel_spans):
|
||||
def test_conformance(otel_spans, otel_metrics):
|
||||
run_a_workload_that_exercises_everything()
|
||||
finished = otel_spans.get_finished_spans()
|
||||
# Everything emitted is registered (and enum values are legal):
|
||||
assert_spans_conform(SPANS, finished, scope_name="my-plugin")
|
||||
# Everything registered was emitted:
|
||||
assert_registry_covered(SPANS, finished, scope_name="my-plugin")
|
||||
# Same two directions for metrics - one collect() after the workload:
|
||||
otel_metrics.collect()
|
||||
assert_metrics_conform(METRICS, otel_metrics, scope_name="my-plugin")
|
||||
assert_metrics_covered(METRICS, otel_metrics, scope_name="my-plugin")
|
||||
|
||||
|
||||
def test_api_only_dependency():
|
||||
assert_package_never_imports_sdk("my_plugin")
|
||||
|
||||
Always pass ``scope_name`` - the exporter also holds core's spans, and your registry should only be judged against your own.
|
||||
Always pass ``scope_name`` - the exporter and reader also hold core's signals, and your registry should only be judged against your own.
|
||||
|
||||
The metric helpers check more than names: ``assert_metrics_conform`` asserts each instrument was created as the **kind** and **unit** its registry entry declares (the registry entry and the ``meter.create_*()`` call are separate statements, and a dashboard built on the registry's word breaks silently if they drift), and that every value on a ``values=`` enum attribute is a member - which is what makes a metric dimension *provably* bounded rather than bounded by intent. Both ``*_covered`` helpers exempt attributes marked ``optional=True`` (an ``error.type`` only present on failures should not force your workload to manufacture errors - pin those with targeted tests instead), and the metrics reader uses delta temporality, so run one broad workload followed by a single ``collect()``.
|
||||
|
||||
.. _plugin_telemetry_caveats:
|
||||
|
||||
|
|
|
|||
|
|
@ -50,10 +50,4 @@ def metrics(cog):
|
|||
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")
|
||||
_attribute_lines(cog, metric.attributes)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from opentelemetry.trace import SpanKind
|
|||
|
||||
from datasette import hookimpl
|
||||
from datasette import telemetry_registry as reg
|
||||
from datasette.telemetry_testing import assert_metrics_conform, assert_metrics_covered
|
||||
from datasette.app import Datasette
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.utils.sqlite import sqlite3
|
||||
|
|
@ -497,43 +498,28 @@ async def emitted_metrics(otel_metrics):
|
|||
pairs.add((metric_name, key))
|
||||
ds.close()
|
||||
slow.close()
|
||||
return {"names": set(snapshot), "pairs": pairs}
|
||||
return {"names": set(snapshot), "pairs": pairs, "collector": otel_metrics}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_conform_to_the_registry(emitted_metrics):
|
||||
"""
|
||||
Emitted-but-unregistered, via the plugin kit's helper - consumed here
|
||||
exactly the way a plugin's suite would. Beyond names and attribute keys,
|
||||
this also asserts each instrument was created as the kind and unit its
|
||||
registry entry declares, and that `datasette.operation` only ever takes
|
||||
its declared enum values.
|
||||
"""
|
||||
assert_metrics_conform(
|
||||
reg.METRICS, emitted_metrics["collector"], scope_name="datasette"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_registered_metric_is_emitted(emitted_metrics):
|
||||
"The both-ways name check for metrics."
|
||||
names = emitted_metrics["names"]
|
||||
missing = sorted(str(m) for m in reg.METRICS if m not in names)
|
||||
assert not missing, f"documented but never emitted: {missing}"
|
||||
|
||||
unregistered = sorted(
|
||||
name for name in names if name not in {str(m) for m in reg.METRICS}
|
||||
)
|
||||
assert not unregistered, f"emitted but not registered: {unregistered}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_emitted_metric_attribute_is_registered(emitted_metrics):
|
||||
"""
|
||||
An attribute added to a metric without a registry entry would be missing
|
||||
from the docs - the metric-side counterpart of
|
||||
`test_every_emitted_attribute_is_registered`.
|
||||
"""
|
||||
metric_for = {str(m): m for m in reg.METRICS}
|
||||
unregistered = sorted(
|
||||
f"{metric_name} -> {key}"
|
||||
for metric_name, key in emitted_metrics["pairs"]
|
||||
# A metric name with no registry entry at all is already reported by
|
||||
# test_every_registered_metric_is_emitted; do not double-report it
|
||||
# here, and do not crash attribute_allowed() on a None metric.
|
||||
if metric_name in metric_for
|
||||
and not reg.attribute_allowed(metric_for[metric_name], key)
|
||||
)
|
||||
assert (
|
||||
not unregistered
|
||||
), "these metric attributes are emitted but not registered: " + "\n".join(
|
||||
unregistered
|
||||
"Registered-but-never-collected, via the plugin kit's helper."
|
||||
assert_metrics_covered(
|
||||
reg.METRICS, emitted_metrics["collector"], scope_name="datasette"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -136,3 +136,127 @@ def test_kit_module_itself_never_imports_the_sdk():
|
|||
# The kit imports the SDK lazily, so a plugin importing it at module
|
||||
# level does not violate the api-only dependency rule.
|
||||
assert_package_never_imports_sdk("datasette.telemetry_testing")
|
||||
|
||||
|
||||
# --- Metric conformance helpers --------------------------------------------
|
||||
|
||||
import itertools
|
||||
|
||||
from opentelemetry import metrics as otel_metrics_api
|
||||
|
||||
from datasette.telemetry_testing import (
|
||||
assert_metrics_conform,
|
||||
assert_metrics_covered,
|
||||
)
|
||||
|
||||
toy_meter = otel_metrics_api.get_meter(SCOPE, "0.1")
|
||||
|
||||
# Instrument names must be unique per meter for the SDK, so each test mints
|
||||
# its own via this counter rather than re-registering one name.
|
||||
_metric_ids = itertools.count()
|
||||
|
||||
|
||||
def _toy_metric_registry(name, kind="Counter", unit="{job}", attributes=None):
|
||||
return (
|
||||
reg.MetricName(
|
||||
name,
|
||||
kind,
|
||||
unit,
|
||||
"A toy metric.",
|
||||
attributes if attributes is not None else (OUTCOME,),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_metrics_conform_passes_and_covers(otel_metrics):
|
||||
name = f"toyplugin.jobs.{next(_metric_ids)}"
|
||||
registry = _toy_metric_registry(name)
|
||||
counter = toy_meter.create_counter(name, unit="{job}", description="Jobs run")
|
||||
counter.add(1, {OUTCOME: "ok"})
|
||||
otel_metrics.collect()
|
||||
assert_metrics_conform(registry, otel_metrics, scope_name=SCOPE)
|
||||
assert_metrics_covered(registry, otel_metrics, scope_name=SCOPE)
|
||||
|
||||
|
||||
def test_metrics_conform_catches_unregistered_metric(otel_metrics):
|
||||
name = f"toyplugin.stealth.{next(_metric_ids)}"
|
||||
counter = toy_meter.create_counter(name, unit="{job}")
|
||||
counter.add(1)
|
||||
otel_metrics.collect()
|
||||
with pytest.raises(AssertionError, match="unregistered metric"):
|
||||
assert_metrics_conform((), otel_metrics, scope_name=SCOPE)
|
||||
|
||||
|
||||
def test_metrics_conform_catches_kind_mismatch(otel_metrics):
|
||||
name = f"toyplugin.kindclash.{next(_metric_ids)}"
|
||||
registry = _toy_metric_registry(name, kind="Histogram", unit="{job}")
|
||||
counter = toy_meter.create_counter(name, unit="{job}")
|
||||
counter.add(1, {OUTCOME: "ok"})
|
||||
otel_metrics.collect()
|
||||
with pytest.raises(AssertionError, match="registry declares Histogram"):
|
||||
assert_metrics_conform(registry, otel_metrics, scope_name=SCOPE)
|
||||
|
||||
|
||||
def test_metrics_conform_catches_unit_mismatch(otel_metrics):
|
||||
name = f"toyplugin.unitclash.{next(_metric_ids)}"
|
||||
registry = _toy_metric_registry(name, unit="s")
|
||||
counter = toy_meter.create_counter(name, unit="ms")
|
||||
counter.add(1, {OUTCOME: "ok"})
|
||||
otel_metrics.collect()
|
||||
with pytest.raises(AssertionError, match="unit"):
|
||||
assert_metrics_conform(registry, otel_metrics, scope_name=SCOPE)
|
||||
|
||||
|
||||
def test_metrics_conform_catches_unregistered_attribute(otel_metrics):
|
||||
name = f"toyplugin.attrclash.{next(_metric_ids)}"
|
||||
registry = _toy_metric_registry(name)
|
||||
counter = toy_meter.create_counter(name, unit="{job}")
|
||||
counter.add(1, {"toyplugin.stealth": "x"})
|
||||
otel_metrics.collect()
|
||||
with pytest.raises(AssertionError, match="unregistered attribute"):
|
||||
assert_metrics_conform(registry, otel_metrics, scope_name=SCOPE)
|
||||
|
||||
|
||||
def test_metrics_conform_enforces_declared_enums(otel_metrics):
|
||||
name = f"toyplugin.enumclash.{next(_metric_ids)}"
|
||||
registry = _toy_metric_registry(name)
|
||||
counter = toy_meter.create_counter(name, unit="{job}")
|
||||
counter.add(1, {OUTCOME: "surprise"})
|
||||
otel_metrics.collect()
|
||||
with pytest.raises(AssertionError, match="not in the declared enum"):
|
||||
assert_metrics_conform(registry, otel_metrics, scope_name=SCOPE)
|
||||
|
||||
|
||||
def test_metrics_covered_catches_never_collected(otel_metrics):
|
||||
registered_but_never_created = _toy_metric_registry(
|
||||
f"toyplugin.ghost.{next(_metric_ids)}"
|
||||
)
|
||||
otel_metrics.collect()
|
||||
with pytest.raises(AssertionError, match="never collected"):
|
||||
assert_metrics_covered(
|
||||
registered_but_never_created, otel_metrics, scope_name=SCOPE
|
||||
)
|
||||
|
||||
|
||||
def test_metrics_covered_skips_optional_attributes(otel_metrics):
|
||||
name = f"toyplugin.optattr.{next(_metric_ids)}"
|
||||
error_type = reg.Attribute("toyplugin.error", "Only on failure.", optional=True)
|
||||
registry = _toy_metric_registry(name, attributes=(OUTCOME, error_type))
|
||||
counter = toy_meter.create_counter(name, unit="{job}")
|
||||
counter.add(1, {OUTCOME: "ok"}) # no error attribute - and that is fine
|
||||
otel_metrics.collect()
|
||||
assert_metrics_covered(registry, otel_metrics, scope_name=SCOPE)
|
||||
|
||||
|
||||
def test_metrics_scope_filter_ignores_other_scopes(otel_metrics):
|
||||
# Core's own metrics are in the reader too; a plugin's conformance run
|
||||
# must not fail because of them.
|
||||
name = f"toyplugin.scoped.{next(_metric_ids)}"
|
||||
registry = _toy_metric_registry(name)
|
||||
counter = toy_meter.create_counter(name, unit="{job}")
|
||||
counter.add(1, {OUTCOME: "ok"})
|
||||
other_meter = otel_metrics_api.get_meter("someone-else-metrics", "1.0")
|
||||
stranger = other_meter.create_counter(f"stranger.{next(_metric_ids)}", unit="x")
|
||||
stranger.add(1)
|
||||
otel_metrics.collect()
|
||||
assert_metrics_conform(registry, otel_metrics, scope_name=SCOPE)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue