Skip to content

Commit b875ea6

Browse files
Complete 0067 embedding metrics (#239)
Wire the section 11 GenAI metric instruments to the embedding event, flipping proposal 0067 from partial to implemented. - otel: _record_embedding_metrics records the same token.usage + operation.duration histograms from the terminal EmbeddingEvent / EmbeddingFailedEvent, with operation="embeddings" and input-only tokens (an embedding call has no output tokens); duration every call including a failure (error.type), token only when usage is present. - conformance: un-defer fixtures 089 (usage) and 143 (no-usage); mark 0067 implemented. Rerank metrics (109) stay deferred, out of scope per the proposal.
1 parent ab875e9 commit b875ea6

4 files changed

Lines changed: 224 additions & 34 deletions

File tree

conformance.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -649,9 +649,9 @@ since = "0.14.0"
649649
# Spec v0.68.0 (proposal 0067). OTel GenAI metrics (observability §11 new
650650
# Metrics section + conformance-adapter §6.9 metric-capture primitive).
651651
[proposals."0067"]
652-
status = "partial"
653-
since = "0.15.0"
654-
note = "OTel GenAI metrics (observability §11): an opt-in enable_metrics flag (default off, normative name) on the bundled OTelObserver, independent of span emission (§11.1). When on, two OA-namespaced histograms record per provider-call ATTEMPT from the python-internal LlmRetryAttemptEvent (the per-attempt LLM-span source since 0050): openarmature.gen_ai.client.token.usage ({token}; two observations -- input + output token counts from the response usage record, openarmature.gen_ai.token.type dim) and openarmature.gen_ai.client.operation.duration (s; once per attempt INCLUDING failed attempts, error.type dim on failure), both configured with the §11.2 explicit bucket advisories. Dimensions: openarmature.gen_ai.operation ('chat'), gen_ai.request.model + gen_ai.system (recognized-core, used directly), openarmature.gen_ai.token.type, error.type. The Meter comes from the configured MeterProvider (injectable; falls back to the OTel global, which is the no-op meter when none is set). PARTIAL: the embedding-call metrics (the §11 embedding path, fixture 089) are deferred -- the embedding capability (proposal 0059, observability §5.5.8 / §5.5.9) now ships (v0.16.0) and dispatches EmbeddingEvent, so the remaining gap is wiring the §11 metric instruments to the embedding event (they record from the LLM per-attempt event only today). The LLM path (fixtures 088 / 090 / 091) is implemented and wired via a private MeterProvider + InMemoryMetricReader (the §6.9 metric-capture primitive). No Langfuse change (metrics are OTel-only). Streaming / server / rerank metrics + the cutover to the upstream gen_ai.client.* instrument names are out of scope per the proposal."
652+
status = "implemented"
653+
since = "0.17.0"
654+
note = "OTel GenAI metrics (observability §11): an opt-in enable_metrics flag (default off, normative name) on the bundled OTelObserver, independent of span emission (§11.1). When on, two OA-namespaced histograms record per provider-call ATTEMPT from the python-internal LlmRetryAttemptEvent (the per-attempt LLM-span source since 0050): openarmature.gen_ai.client.token.usage ({token}; two observations -- input + output token counts from the response usage record, openarmature.gen_ai.token.type dim) and openarmature.gen_ai.client.operation.duration (s; once per attempt INCLUDING failed attempts, error.type dim on failure), both configured with the §11.2 explicit bucket advisories. Dimensions: openarmature.gen_ai.operation ('chat'), gen_ai.request.model + gen_ai.system (recognized-core, used directly), openarmature.gen_ai.token.type, error.type. The Meter comes from the configured MeterProvider (injectable; falls back to the OTel global, which is the no-op meter when none is set). The §11 embedding-call metrics are now wired (v0.17.0): _record_embedding_metrics records the SAME two instruments from the terminal EmbeddingEvent / EmbeddingFailedEvent with operation='embeddings' and token.type='input' (an embedding call has input tokens only -- EmbeddingUsage.input_tokens, no output); duration records every embedding call including a failure (error.type), token.usage only when a usage record is present. Un-defers fixtures 089 (usage: token + duration) and 143 (no-usage: duration only, no token observation). The LLM path (fixtures 088 / 090 / 091) is implemented and wired via a private MeterProvider + InMemoryMetricReader (the §6.9 metric-capture primitive). No Langfuse change (metrics are OTel-only). Streaming / server / rerank metrics (fixture 109 stays deferred) + the cutover to the upstream gen_ai.client.* instrument names are out of scope per the proposal."
655655

656656
# Spec v0.57.0 (proposal 0068). Failure-isolation event structured cause
657657
# chain (pipeline-utilities §6.3). ``caught_exception`` gains a ``chain`` of

src/openarmature/observability/otel/observer.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,10 @@ async def __call__(
839839
# completion spans per §5.5.8); only the payload / GenAI-semconv
840840
# attribute subsets are gated, inside the handler.
841841
if isinstance(event, EmbeddingEvent | EmbeddingFailedEvent):
842+
# §11 embedding metrics (proposal 0067) record per call, independent
843+
# of span emission (§11.1), gated only on enable_metrics.
844+
if self.enable_metrics:
845+
self._record_embedding_metrics(event)
842846
self._handle_embedding(event)
843847
return
844848
# Proposal 0060 rerank observability (observability §5.5.13): emit the
@@ -1852,6 +1856,35 @@ def _handle_tool_call(self, event: ToolCallEvent | ToolCallFailedEvent) -> None:
18521856
self._run_enrichers(span, event)
18531857
span.end(end_time=end_time_ns)
18541858

1859+
def _record_embedding_metrics(self, event: EmbeddingEvent | EmbeddingFailedEvent) -> None:
1860+
# §11 (proposal 0067): the embedding-call analog of _record_llm_metrics,
1861+
# recorded from the terminal embedding event onto the SAME two
1862+
# instruments (the operation dim separates chat from embeddings).
1863+
# operation is "embeddings"; an embedding call has input tokens only
1864+
# (EmbeddingUsage.input_tokens, no output). Duration records every call
1865+
# including a failure (error.type = the §7 category); token.usage only
1866+
# when a usage record is present (a failure carries none).
1867+
if self._duration_histogram is None or self._token_histogram is None:
1868+
return
1869+
base_dims: dict[str, str] = {
1870+
"openarmature.gen_ai.operation": "embeddings",
1871+
"gen_ai.request.model": event.model,
1872+
"gen_ai.system": event.provider,
1873+
}
1874+
if event.latency_ms is not None:
1875+
duration_dims = dict(base_dims)
1876+
error_category = getattr(event, "error_category", None)
1877+
if error_category is not None:
1878+
duration_dims["error.type"] = error_category
1879+
self._duration_histogram.record(event.latency_ms / 1000.0, duration_dims)
1880+
usage = getattr(event, "usage", None)
1881+
input_tokens = getattr(usage, "input_tokens", None) if usage is not None else None
1882+
if input_tokens is not None:
1883+
self._token_histogram.record(
1884+
input_tokens,
1885+
{**base_dims, "openarmature.gen_ai.token.type": "input"},
1886+
)
1887+
18551888
# Spec: observability §5.5.8 (proposal 0059) embedding span. Payload
18561889
# gating is §5.5.4 + the §5.5.5 truncation contract; gen_ai.operation.name
18571890
# is deferred per the stable-only adoption policy.

tests/conformance/test_observability.py

Lines changed: 76 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,11 @@ def _reset_otel_global_tracer_provider(restore_to: object) -> None:
270270
"137-langfuse-embedding-failure-observation",
271271
"139-otel-embedding-no-usage-input-tokens-omitted",
272272
"140-langfuse-embedding-no-usage-usagedetails-omitted",
273+
# proposal 0067 §11 embedding metrics: token.usage (input only) +
274+
# operation.duration, operation="embeddings", recorded from the terminal
275+
# embedding event. 089 (usage) + 143 (no-usage: duration only).
276+
"089-embedding-metrics-token-and-duration",
277+
"143-embedding-metrics-no-usage-no-token-observation",
273278
# v0.16.0 — proposal 0060 rerank observability (0060b). A calls_rerank
274279
# node awaits CohereRerankProvider.rerank() inside the node body; the
275280
# typed RerankEvent / RerankFailedEvent drive the typed-event collector
@@ -299,16 +304,11 @@ def _reset_otel_global_tracer_provider(restore_to: object) -> None:
299304
)
300305

301306

302-
_EMBEDDING_METRICS_DEFER = (
303-
"embedding observability (074-083 + 137) is wired (proposal 0059b); only the "
304-
"§11 embedding-metrics path stays deferred -- the OTelObserver records metrics "
305-
"from the LLM per-attempt event, not yet from EmbeddingEvent"
306-
)
307-
308307
_RERANK_DEFER = (
309-
"rerank observability (099-108 + 138 + 141/142) is wired (proposal 0060b); only the "
310-
"§11 rerank-metrics path stays deferred -- the OTelObserver records metrics from the "
311-
"LLM per-attempt event, not yet from RerankEvent (rides proposal 0067, cf. 089)"
308+
"rerank observability (099-108 + 138 + 141/142) is wired (proposal 0060b); the "
309+
"§11 rerank-metrics path stays deferred -- rerank metrics are OUT OF SCOPE for "
310+
"proposal 0067 (embedding metrics shipped; rerank is explicitly excluded per the "
311+
"proposal), with no later PR in this release"
312312
)
313313

314314

@@ -327,17 +327,11 @@ def _reset_otel_global_tracer_provider(restore_to: object) -> None:
327327
"039-nested-lineage-augmentation": (
328328
"nested-case Langfuse harness wiring not yet implemented (proposal 0045 nested fan-out)"
329329
),
330-
# Embedding observability (proposal 0067 §11). 074-083 + 137 are wired
331-
# (proposal 0059b -- see _SUPPORTED_FIXTURES). Only the §11 embedding-
332-
# metrics path (089) stays deferred: the OTelObserver records metrics from
333-
# the LLM per-attempt event only; extending the metric instruments to the
334-
# embedding path rides the §11 embedding-metrics work, not 0059b.
335-
"089-embedding-metrics-token-and-duration": _EMBEDDING_METRICS_DEFER,
336330
# Rerank observability (proposal 0060). 099-108 + 138 + 141/142 are wired
337-
# (proposal 0060b -- see _SUPPORTED_FIXTURES). Only the §11 rerank-metrics
338-
# path (109) stays deferred with the embedding-metrics sibling (089): the
339-
# OTelObserver records metrics from the LLM per-attempt event only;
340-
# extending the instruments to the rerank path rides proposal 0067.
331+
# (proposal 0060b -- see _SUPPORTED_FIXTURES). The §11 rerank-metrics path
332+
# (109) stays deferred: rerank metrics are out of scope for proposal 0067
333+
# (embedding metrics 089/143 now run; rerank is explicitly excluded per the
334+
# proposal), with no later PR in this release.
341335
"109-rerank-metrics-token-and-duration": _RERANK_DEFER,
342336
# ---- v0.16.0 spec-pin bump (v0.70.1 -> v0.84.0): new fixtures for
343337
# proposals deferred to their own later PRs of this release. ----
@@ -411,18 +405,6 @@ def _reset_otel_global_tracer_provider(restore_to: object) -> None:
411405
"136-langfuse-parallel-branches-dispatch-span": (
412406
"Langfuse parallel-branches dispatch-span parity (proposal 0088) not-yet implemented"
413407
),
414-
# ---- Proposal 0093 (nullable provider usage records). 139/140 (embedding
415-
# no-usage) and 141/142 (rerank no-usage) are wired -- see
416-
# _SUPPORTED_FIXTURES. Only 143 stays deferred, and NOT for a 0093 reason:
417-
# it asserts the §11 GenAI token METRIC is not observed when a call reports
418-
# no usage, and the OTelObserver records metrics from the LLM per-attempt
419-
# event only -- the embedding metric path does not exist yet. It defers
420-
# behind the same gate as 089 (proposal 0067), whose implementation would
421-
# realize it. 0093 itself changes nothing in §11. ----
422-
"143-embedding-metrics-no-usage-no-token-observation": (
423-
"embedding no-usage metric rides the deferred §11 embedding-metrics "
424-
"path (proposal 0067; cf. 089), not a proposal 0093 gap"
425-
),
426408
}
427409

428410

@@ -743,6 +725,8 @@ async def test_observability_fixture(fixture_path: Path) -> None:
743725
"137-langfuse-embedding-failure-observation",
744726
"139-otel-embedding-no-usage-input-tokens-omitted",
745727
"140-langfuse-embedding-no-usage-usagedetails-omitted",
728+
"089-embedding-metrics-token-and-duration",
729+
"143-embedding-metrics-no-usage-no-token-observation",
746730
}:
747731
await _run_embedding_fixture(spec)
748732
elif fixture_id in {
@@ -4687,6 +4671,8 @@ async def _run_embedding_case(case: Mapping[str, Any]) -> None:
46874671
exporter: Any = None
46884672
otel_observer: Any = None
46894673
langfuse_client: Any = None
4674+
metrics_observer: Any = None
4675+
reader: Any = None
46904676
if "observers" in expected:
46914677
for collector in collectors.values():
46924678
graph.attach_observer(collector)
@@ -4704,6 +4690,20 @@ async def _run_embedding_case(case: Mapping[str, Any]) -> None:
47044690
if "disable_provider_payload" in lf_cfg:
47054691
lf_kwargs["disable_provider_payload"] = bool(lf_cfg["disable_provider_payload"])
47064692
graph.attach_observer(LangfuseObserver(**lf_kwargs))
4693+
if "metrics" in expected:
4694+
# §11 embedding metrics (proposal 0067, fixtures 089/143): a metrics
4695+
# observer with a private MeterProvider + InMemoryMetricReader (the §6.9
4696+
# capture primitive), mirroring _run_metrics_case for the LLM path.
4697+
from opentelemetry.sdk.metrics import MeterProvider as SdkMeterProvider
4698+
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
4699+
4700+
reader = InMemoryMetricReader()
4701+
metrics_observer = OTelObserver(
4702+
span_processor=SimpleSpanProcessor(InMemorySpanExporter()),
4703+
enable_metrics=bool(case.get("enable_metrics", False)),
4704+
meter_provider=SdkMeterProvider(metric_readers=[reader]),
4705+
)
4706+
graph.attach_observer(metrics_observer)
47074707

47084708
try:
47094709
if expected_error is not None:
@@ -4717,6 +4717,8 @@ async def _run_embedding_case(case: Mapping[str, Any]) -> None:
47174717
await provider.aclose()
47184718
if otel_observer is not None:
47194719
otel_observer.shutdown()
4720+
if metrics_observer is not None:
4721+
metrics_observer.shutdown()
47204722

47214723
if "observers" in expected:
47224724
for obs_name, obs_spec in cast("dict[str, Any]", expected["observers"]).items():
@@ -4740,6 +4742,49 @@ async def _run_embedding_case(case: Mapping[str, Any]) -> None:
47404742
_assert_langfuse_observation_tree(
47414743
trace, cast("list[dict[str, Any]]", expected["langfuse_trace"].get("observations") or [])
47424744
)
4745+
if "metrics" in expected and reader is not None:
4746+
points = _collect_metric_points(reader)
4747+
_assert_metric_points(points, cast("list[dict[str, Any]]", expected.get("metrics") or []))
4748+
_assert_embedding_metrics_invariants(case, points)
4749+
4750+
4751+
def _assert_embedding_metrics_invariants(
4752+
case: Mapping[str, Any],
4753+
points: Sequence[tuple[str, float, int, dict[str, Any]]],
4754+
) -> None:
4755+
# Named invariants from the embedding-metrics fixtures (proposal 0067):
4756+
# 089 (usage) asserts the token observation is input-only (no output); 143
4757+
# (no usage) asserts the duration metric records but NO token.usage. The
4758+
# metrics: list asserts presence; these cover the "no observation" and
4759+
# "input-only" claims it cannot express. Fail loudly on an unmapped name.
4760+
invariants = cast("dict[str, Any]", case["expected"].get("invariants") or {})
4761+
_known = {
4762+
"no_token_usage_observation_when_usage_absent",
4763+
"duration_recorded_when_usage_absent",
4764+
"embedding_records_input_token_only",
4765+
"no_output_token_observation_for_embedding",
4766+
}
4767+
unknown = set(invariants) - _known
4768+
assert not unknown, f"unhandled embedding-metrics invariant(s): {sorted(unknown)}"
4769+
token_points = [p for p in points if p[0] == "openarmature.gen_ai.client.token.usage"]
4770+
duration_points = [p for p in points if p[0] == "openarmature.gen_ai.client.operation.duration"]
4771+
input_token_points = [p for p in token_points if p[3].get("openarmature.gen_ai.token.type") == "input"]
4772+
output_token_points = [p for p in token_points if p[3].get("openarmature.gen_ai.token.type") == "output"]
4773+
if invariants.get("no_token_usage_observation_when_usage_absent"):
4774+
assert not token_points, (
4775+
f"no-usage embedding MUST record no token.usage observation; got {token_points}"
4776+
)
4777+
if invariants.get("duration_recorded_when_usage_absent"):
4778+
assert duration_points, "no-usage embedding MUST still record operation.duration"
4779+
if invariants.get("embedding_records_input_token_only"):
4780+
assert input_token_points and not output_token_points, (
4781+
"embedding token.usage MUST record input only; "
4782+
f"input={input_token_points} output={output_token_points}"
4783+
)
4784+
if invariants.get("no_output_token_observation_for_embedding"):
4785+
assert not output_token_points, (
4786+
f"embedding MUST record no output token observation; got {output_token_points}"
4787+
)
47434788

47444789

47454790
# A calls_rerank node constructs a CohereRerankProvider on an httpx.MockTransport

0 commit comments

Comments
 (0)