From 2a05263ab87feaa4f3fddbfa363ab54e4ddf3627 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Wed, 2 Sep 2026 12:55:47 -0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA --- datasette/telemetry_registry.py | 53 +++++++++--- datasette/telemetry_testing.py | 118 ++++++++++++++++++++++++-- docs/changelog.rst | 2 +- docs/internals.rst | 4 +- docs/plugin_telemetry.rst | 14 +++- docs/telemetry_doc.py | 8 +- tests/test_telemetry_registry.py | 52 +++++------- tests/test_telemetry_testing_kit.py | 124 ++++++++++++++++++++++++++++ 8 files changed, 310 insertions(+), 65 deletions(-) diff --git a/datasette/telemetry_registry.py b/datasette/telemetry_registry.py index aa9a7b91..19a08055 100644 --- a/datasette/telemetry_registry.py +++ b/datasette/telemetry_registry.py @@ -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 diff --git a/datasette/telemetry_testing.py b/datasette/telemetry_testing.py index 3aa3aff7..65a945b1 100644 --- a/datasette/telemetry_testing.py +++ b/datasette/telemetry_testing.py @@ -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 diff --git a/docs/changelog.rst b/docs/changelog.rst index a754402a..a0a34530 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 ` 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 ` 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. diff --git a/docs/internals.rst b/docs/internals.rst index 5f8fb437..b6828173 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -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. diff --git a/docs/plugin_telemetry.rst b/docs/plugin_telemetry.rst index 1999da4a..8bf5d1d0 100644 --- a/docs/plugin_telemetry.rst +++ b/docs/plugin_telemetry.rst @@ -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: diff --git a/docs/telemetry_doc.py b/docs/telemetry_doc.py index f8aa22dd..e08a2b45 100644 --- a/docs/telemetry_doc.py +++ b/docs/telemetry_doc.py @@ -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) diff --git a/tests/test_telemetry_registry.py b/tests/test_telemetry_registry.py index b638997b..0072b2ce 100644 --- a/tests/test_telemetry_registry.py +++ b/tests/test_telemetry_registry.py @@ -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" ) diff --git a/tests/test_telemetry_testing_kit.py b/tests/test_telemetry_testing_kit.py index ba2bc435..cac55c19 100644 --- a/tests/test_telemetry_testing_kit.py +++ b/tests/test_telemetry_testing_kit.py @@ -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)