Skip to content

Commit 338e4b8

Browse files
Add 0082 OTel error-span rendering and metric (#230)
PR B of proposal 0082 (the OTel rendering + the token metric; the Langfuse rendering is the final PR of the split). The per-attempt LlmRetryAttemptEvent now carries the response-side surface on a structured_output_invalid failed attempt (mirroring PR A's terminal event), so the bundled OTel observer renders it on the error openarmature.llm.complete span: a structured_output_invalid failure falls through the failed-attempt early-return to render finish_reason, usage, gated output.content, and the response identity while keeping ERROR status and category. The opt-in token-usage histogram records the failure's usage (it already recorded whenever usage was present; a structured failure now carries it). Also records the OTel exception event on every failed llm.complete span (exception.type / exception.message). The node and invocation spans already did this; the LLM span did not, a gap the 0082 error-span fixtures surfaced. This makes the enriched error_message locator load-bearing. Un-defers observability fixtures 124 (OTel error span, payload on/off) and 125 (token metric); only the Langfuse fixture 123 stays deferred.
1 parent a01f01f commit 338e4b8

8 files changed

Lines changed: 257 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
1010

1111
- **PromptManager service-wide default cache TTL** (proposal 0086, prompt-management §6, spec v0.79.0). `PromptManager` construction accepts an optional `default_cache_ttl_seconds`, applied to any `fetch` or `get` that omits a per-call `cache_ttl_seconds`. Resolution follows a precedence chain: an explicit per-call value (including `0` force-fresh) wins; otherwise the manager default applies; otherwise nothing is forwarded and the backend's own caching governs. An omitted or explicit-`None` per-call value both select the default, so resolution does not depend on argument presence. A negative default is rejected at construction, and the per-call negative rejection is unchanged. `get` delegates to `fetch` and inherits the chain, and the bundled caching backends need no change (they already honor a resolved `cache_ttl_seconds`). Conformance fixture 036 is un-deferred.
1212
- **PromptGroup arity enforcement** (proposal 0080, prompt-management §10 / §11, spec v0.75.0). Constructing a `PromptGroup` with fewer than two members (an empty or single-member group) now raises a categorized `PromptGroupInvalid` at construction, before any render or LLM call. This replaces the previous bare `ValueError` (which pydantic folded into a `ValidationError` carrying no error category) and adds a `prompt_group_invalid` category to the prompt-management error set. `PromptGroupInvalid` is non-transient and is exported from `openarmature.prompts`. Conformance fixture 035 is un-deferred.
13-
- **Structured-output failure diagnostics on the LLM failure event** (proposal 0082, graph-engine §6 + llm-provider §7, spec v0.77.0). A `structured_output_invalid` failure is a completion whose final validation gate failed, so the wire response is intact. `LlmFailedEvent` now carries the response-side surface for that one category (`None` for every other): `output_content` (the content that failed validation, payload-gated), `finish_reason` (the truncation-vs-malformed triage signal, `"length"` vs `"stop"`), `usage`, `response_id`, and `response_model`. The `StructuredOutputInvalid` error additionally exposes `finish_reason` and `usage`, so a caller's exception handler can make a truncation-aware retry decision. Observer rendering of the surface (OTel error span, Langfuse failed Generation) and the token-usage-on-failure metric land in follow-on PRs of the same proposal.
13+
- **Structured-output failure diagnostics on the LLM failure event** (proposal 0082, graph-engine §6 + llm-provider §7, spec v0.77.0). A `structured_output_invalid` failure is a completion whose final validation gate failed, so the wire response is intact. `LlmFailedEvent` now carries the response-side surface for that one category (`None` for every other): `output_content` (the content that failed validation, payload-gated), `finish_reason` (the truncation-vs-malformed triage signal, `"length"` vs `"stop"`), `usage`, `response_id`, and `response_model`. The `StructuredOutputInvalid` error additionally exposes `finish_reason` and `usage`, so a caller's exception handler can make a truncation-aware retry decision. The bundled OTel observer renders this surface on the error `openarmature.llm.complete` span (`ERROR` status plus `finish_reason`, `usage`, and payload-gated `output.content`), and the opt-in `openarmature.gen_ai.client.token.usage` metric now records a structured-output failure's token usage (other failure categories still record none). Rendering the surface on the Langfuse failed Generation lands in a follow-on PR of the same proposal.
14+
15+
### Fixed
16+
17+
- **The OTel `openarmature.llm.complete` span records the exception event on a failed attempt** (observability §4.2). A failed provider-call span carried `ERROR` status but no exception event; the node and invocation spans already recorded it, the LLM span did not. It now records the OTel semconv exception event (`exception.type` / `exception.message`) on every failed attempt, matching the sibling spans. Surfaced while wiring the proposal 0082 error-span fixtures.
1418

1519
## [0.16.0] — 2026-07-18
1620

conformance.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,7 @@ since = "0.16.0"
822822
[proposals."0082"]
823823
status = "partial"
824824
since = "0.17.0"
825-
note = "The graph-engine §6 event surface + llm-provider §7 error surface (PR A). LlmFailedEvent gains five response-side fields (output_content / finish_reason / usage / response_id / response_model), populated ONLY for structured_output_invalid (None for every other category); output_content is payload-bearing, the other four are not gated. StructuredOutputInvalid gains finish_reason / usage (the §7-mandated pair) plus response_id / response_model (internal, for the event builder), attached at the OpenAI provider's parse/validate call site where the intact wire response is in hand; the failed-event builder reads them off the enriched error. Un-defers observability fixtures 120 (truncation, finish_reason=length) / 121 (schema-mismatch, finish_reason=stop) / 122 (null-on-non-body companion), driven via the typed-event collector with a new calls_llm.response_schema harness lever; and llm-provider fixtures 022 / 023, whose carries block now asserts finish_reason + usage (the carries reader gained subset matching for a mapping-valued field like usage). PARTIAL: the OTel error-span rendering (124), Langfuse failed-Generation rendering (123), and the §11.2 token-usage-on-failure metric (125) are deferred to the OTel+metrics and Langfuse PRs -- they render the surface the event now carries."
825+
note = "The graph-engine §6 event surface + llm-provider §7 error surface (PR A). LlmFailedEvent gains five response-side fields (output_content / finish_reason / usage / response_id / response_model), populated ONLY for structured_output_invalid (None for every other category); output_content is payload-bearing, the other four are not gated. StructuredOutputInvalid gains finish_reason / usage (the §7-mandated pair) plus response_id / response_model (internal, for the event builder), attached at the OpenAI provider's parse/validate call site where the intact wire response is in hand; the failed-event builder reads them off the enriched error. Un-defers observability fixtures 120 (truncation, finish_reason=length) / 121 (schema-mismatch, finish_reason=stop) / 122 (null-on-non-body companion), driven via the typed-event collector with a new calls_llm.response_schema harness lever; and llm-provider fixtures 022 / 023, whose carries block now asserts finish_reason + usage (the carries reader gained subset matching for a mapping-valued field like usage). PR B adds the OTel rendering + the metric: the per-attempt LlmRetryAttemptEvent now carries the same response-side surface on a structured_output_invalid failed attempt, so the OTel error span renders it (a structured_output_invalid failure falls through the failed-attempt early-return to render finish_reason / usage / gated output.content / response id+model while keeping ERROR status + category) and the §11.2 token-usage histogram records the failure's usage (the metric already recorded whenever usage was present; a structured failure now carries it). Un-defers observability fixtures 124 (OTel error span, payload on/off) and 125 (token metric on the failure). PARTIAL: only the Langfuse failed-Generation rendering (123) is deferred to the final PR of the split -- it renders the surface the event carries onto a failed Generation."
826826

827827
# Spec v0.78.0 (proposal 0083). Per-prompt token-budget observability
828828
# (prompt-management §3 / graph-engine §6 / observability §5.5.15).

docs/concepts/observability.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -715,9 +715,11 @@ inside a single call. Each attempt emits its own
715715
first try emits one span at `attempt_index` 0; a call that fails twice
716716
transiently before succeeding emits three spans (indices 0, 1, 2). Each
717717
failed attempt's span carries `ERROR` status plus
718-
`openarmature.error.category`; the final attempt's span carries the
719-
terminal outcome (`OK` on success, `ERROR` on an exhausted or
720-
non-transient failure).
718+
`openarmature.error.category`; a `structured_output_invalid` failure
719+
additionally carries the response-side surface (`finish_reason`,
720+
`usage`, and payload-gated `output.content`), since its wire response
721+
was intact. The final attempt's span carries the terminal outcome
722+
(`OK` on success, `ERROR` on an exhausted or non-transient failure).
721723

722724
`openarmature.llm.attempt_index` is the **call-level** attempt counter,
723725
[independent of the node-level `attempt_index`](llms.md#call-level-vs-node-level-retry):

src/openarmature/graph/events.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -713,7 +713,10 @@ class LlmRetryAttemptEvent:
713713
``error_category`` discriminates the outcome: ``None`` for a
714714
successful attempt (the response-side fields are populated), a
715715
category string for a failed attempt (the response-side fields are
716-
``None`` — no response was received).
716+
``None`` — no response was received — with one exception: a
717+
``structured_output_invalid`` failure carries the response-side surface,
718+
since its wire response was intact and failed only downstream
719+
validation, mirroring :class:`LlmFailedEvent`).
717720
718721
Field set:
719722
@@ -729,9 +732,13 @@ class LlmRetryAttemptEvent:
729732
- response side (``response_id`` / ``response_model`` / ``usage`` /
730733
``finish_reason`` / ``output_content`` / ``output_tool_calls``):
731734
populated on a successful attempt; ``None`` / empty list on a
732-
failed attempt. ``output_tool_calls`` is the source the OTel
733-
observer renders the output tool-call attributes
734-
from (this is the per-attempt event that drives the LLM span).
735+
failed attempt, except a ``structured_output_invalid`` failure,
736+
which populates ``output_content`` / ``finish_reason`` / ``usage`` /
737+
``response_id`` / ``response_model`` from its intact wire response
738+
(``output_tool_calls`` stays empty — a structured failure carries no
739+
tool calls). ``output_tool_calls`` is the source the OTel observer
740+
renders the output tool-call attributes from (this is the per-attempt
741+
event that drives the LLM span).
735742
- failure side (``error_category`` / ``error_message`` /
736743
``error_type``): populated on a failed attempt; ``None`` on a
737744
successful one.

src/openarmature/llm/providers/openai.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -867,11 +867,27 @@ def _build_llm_retry_attempt_event(
867867
)
868868
if exc is None:
869869
raise ValueError("_build_llm_retry_attempt_event requires response or exc")
870+
# Proposal 0082: a structured_output_invalid failed attempt carries the
871+
# response-side surface (the wire response was intact); the OTel observer
872+
# renders it on the error span and the §11 token metric records from it.
873+
# None for every other category. error_message carries the failing locator.
874+
error_message = str(exc)
875+
response_fields: dict[str, Any] = {}
876+
if isinstance(exc, StructuredOutputInvalid):
877+
response_fields = {
878+
"output_content": exc.raw_content,
879+
"finish_reason": exc.finish_reason,
880+
"usage": exc.usage,
881+
"response_id": exc.response_id,
882+
"response_model": exc.response_model,
883+
}
884+
error_message = f"{exc}: {exc.failure_description}"
870885
return LlmRetryAttemptEvent(
871886
**base,
872887
error_category=exc.category,
873888
error_type=type(exc).__name__,
874-
error_message=str(exc),
889+
error_message=error_message,
890+
**response_fields,
875891
)
876892

877893
async def _do_complete(

src/openarmature/observability/otel/observer.py

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,11 +1402,13 @@ def _record_llm_metrics(self, event: LlmRetryAttemptEvent) -> None:
14021402
attempt.
14031403
14041404
Duration is recorded for every attempt (including a failed one,
1405-
carrying ``error.type``); token usage only when the attempt
1406-
returned a usage record (a failed attempt has none). Sourced from
1407-
the per-attempt ``LlmRetryAttemptEvent`` — one duration sample per
1408-
attempt under call-level retry, matching the per-attempt span
1409-
model. The attempt index is deliberately not a dimension
1405+
carrying ``error.type``); token usage only when the attempt carries
1406+
a usage record. Most failed attempts have none, but a
1407+
``structured_output_invalid`` failure does (proposal 0082): the wire
1408+
response was intact, so its token usage records like a completion's.
1409+
Sourced from the per-attempt ``LlmRetryAttemptEvent`` — one duration
1410+
sample per attempt under call-level retry, matching the per-attempt
1411+
span model. The attempt index is deliberately not a dimension
14101412
(cardinality control).
14111413
"""
14121414
if self._duration_histogram is None or self._token_histogram is None:
@@ -1426,9 +1428,10 @@ def _record_llm_metrics(self, event: LlmRetryAttemptEvent) -> None:
14261428
if event.error_category is not None:
14271429
duration_dims["error.type"] = event.error_category
14281430
self._duration_histogram.record(event.latency_ms / 1000.0, duration_dims)
1429-
# Token usage: only when a usage record is present (a failed
1430-
# attempt has none). Two observations for an LLM completion —
1431-
# input + output — each only when its count was reported.
1431+
# Token usage: only when a usage record is present. Most failed
1432+
# attempts have none; a structured_output_invalid failure does
1433+
# (proposal 0082). Two observations for an LLM completion --
1434+
# input + output -- each only when its count was reported.
14321435
usage = event.usage
14331436
if usage is not None:
14341437
if usage.prompt_tokens is not None:
@@ -1450,8 +1453,11 @@ def _handle_typed_llm_retry_attempt(self, event: LlmRetryAttemptEvent) -> None:
14501453
the calling node span, each carrying
14511454
``openarmature.llm.attempt_index`` 0..N-1. A successful attempt
14521455
(``error_category is None``) carries the full response surface
1453-
with OK status; a failed attempt carries ERROR status + the
1454-
error category and no response attributes.
1456+
with OK status; a failed attempt carries ERROR status + the error
1457+
category + the §4.2 exception event. Most failed categories add no
1458+
response attributes, but a ``structured_output_invalid`` failure
1459+
also carries the response surface (payload-gated output), since its
1460+
wire response was intact (proposal 0082).
14551461
"""
14561462
# Mid-call metadata augmentation can't reach this span: the
14571463
# typed event arrives only after complete() returns, and the
@@ -1542,13 +1548,30 @@ def _handle_typed_llm_retry_attempt(self, event: LlmRetryAttemptEvent) -> None:
15421548
start_time=start_time_ns,
15431549
)
15441550
if event.error_category is not None:
1545-
# Failed attempt: ERROR + the §4 category, no response
1546-
# attributes (no response was received).
1551+
# Failed attempt: ERROR + the §4 category + the §4.2 exception
1552+
# event. The per-attempt event carries the error type/message (not
1553+
# the exception object the node span records via record_exception),
1554+
# so add the OTel semconv exception event directly. error_message
1555+
# carries the failing locator for a structured_output_invalid
1556+
# failure (proposal 0082).
15471557
span.set_status(Status(StatusCode.ERROR, description=event.error_category))
15481558
span.set_attribute("openarmature.error.category", event.error_category)
1549-
self._run_enrichers(span, event)
1550-
span.end(end_time=end_time_ns)
1551-
return
1559+
span.add_event(
1560+
"exception",
1561+
attributes={
1562+
"exception.type": event.error_type or "",
1563+
"exception.message": event.error_message or "",
1564+
},
1565+
)
1566+
# Proposal 0082: a structured_output_invalid failure has an intact
1567+
# wire response, so the error span ALSO carries the response-side
1568+
# surface (payload-gated output) rendered below, in addition to
1569+
# ERROR status + category. Every other category received no
1570+
# response -- end the span here.
1571+
if event.error_category != "structured_output_invalid":
1572+
self._run_enrichers(span, event)
1573+
span.end(end_time=end_time_ns)
1574+
return
15521575
usage = event.usage
15531576
if event.finish_reason is not None:
15541577
span.set_attribute("openarmature.llm.finish_reason", event.finish_reason)
@@ -1619,7 +1642,10 @@ def _handle_typed_llm_retry_attempt(self, event: LlmRetryAttemptEvent) -> None:
16191642
"openarmature.llm.output.tool_calls",
16201643
_truncate_for_attribute(serialized_calls, self.payload_max_bytes),
16211644
)
1622-
span.set_status(Status(StatusCode.OK))
1645+
# A structured_output_invalid failure fell through here with ERROR
1646+
# already set (proposal 0082); only a real success sets OK.
1647+
if event.error_category is None:
1648+
span.set_status(Status(StatusCode.OK))
16231649
self._run_enrichers(span, event)
16241650
span.end(end_time=end_time_ns)
16251651

0 commit comments

Comments
 (0)