Skip to content

Commit 052b839

Browse files
Observability LLM payload + GenAI semconv (v0.17.0) (#61)
* chore: bump spec submodule + spec_version to v0.17.0 Pin to v0.17.0 (proposal 0024 accepted — LLM span payload + GenAI semconv). Submodule, pyproject.toml [tool.openarmature].spec_version, and the runtime __spec_version__ move in lockstep per the three-pin drift guard in tests/test_smoke.py. * feat(observability): LLM payload + GenAI semconv Realize spec v0.17.0 §5.5 expansion and the five python-only items from the observability friction roundup. §5.5.1 input/output payload: openarmature.llm.input.messages, openarmature.llm.output.content, openarmature.llm.request.extras — JSON-encoded with sorted keys, default-off via disable_llm_payload, truncated per §5.5.5 at payload_max_bytes (default 64 KiB, minimum 256). Inline image bytes are redacted at the provider before reaching the event payload, not gated by any observer flag. §5.5.2 / §5.5.3 GenAI semconv: gen_ai.system (caller-overridable per OpenAIProvider for non-OpenAI endpoints), gen_ai.request.model, gen_ai.response.{model,id,finish_reasons}, gen_ai.usage.{input, output}_tokens, plus gen_ai.request.{temperature,max_tokens,top_p, seed} when set. Opt-out via disable_genai_semconv. OTelObserver knobs: resource= for service.name, span_processor accepts SpanProcessor | Sequence[SpanProcessor], attribute_enrichers hook fires before every span.end() the observer issues. LlmEventPayload (renamed from _LlmEventState, moved to openarmature.observability.llm_event) and LLM_NAMESPACE are now public — third-party Provider impls and custom observers can interoperate against a stable shape. _LlmEventState retained as a deprecated alias for one release. Response gains response_id and response_model typed fields sourcing the new gen_ai.response.* attributes from the wire payload. Fix the prompt-context cross-task propagation bug: the worker task running deliver_loop snapshots its Context at invoke()-entry, so with_active_prompt(...) blocks opened later inside node bodies were invisible to the observer. Capture current_prompt_result() / current_prompt_group() at dispatch time in _make_llm_event (running in the node task where the ContextVars are set) and put the snapshot on LlmEventPayload; observer reads from the payload. Tests: 10 new conformance fixture drivers (012-021), assertion-helper module, RuntimeConfigSpec directive shape, end-to-end #3 regression test exercising the real cross-task boundary. Reset OTel global tracer provider state in test finally blocks via the SDK's private Once primitive so cross-suite runs no longer leak the global provider into subsequent tests. * docs: LLM payload + GenAI semconv release notes CHANGELOG [Unreleased] entry describing the eight friction-roundup items shipped against spec v0.17.0. README adds a sibling pitch bullet under the existing "doesn't double-export" framing covering the dual openarmature.llm.* + gen_ai.* attribute story and the default-off / privacy posture on payload emission. Concepts/observability page gains seven new subsections under the existing OpenTelemetry coverage: the LLM provider span, the default-off payload attributes with truncation + image-redaction subsections, the Resource constructor knob, multi-processor fan-out, attribute_enrichers, the public LlmEventPayload / LLM_NAMESPACE contract, and BatchSpanProcessor flush behaviour under fast teardown. Model-providers/authoring page expands the "Observability spans" bullet from one-line claim to a runnable dispatch sketch that third-party Provider authors can copy directly. * examples: surface prompt context + service.name on OTel spans Example 03 (observer-hooks) gains a Resource carrying service.name so its OTel spans match the shape every production backend expects. Comment expanded to mention the auto-emitted gen_ai.* attributes now surfacing on openarmature.llm.complete spans. Example 07 (multimodal-prompt) gets the OTelObserver wiring its docstring has been claiming all along — the example's headline teach was that with_active_prompt_group plus with_active_prompt stamps openarmature.prompt.* attributes on LLM-call spans, but without an attached observer the propagation was unobservable. The console exporter now prints those spans so the prompt-context story is end-to-end visible. The cross-task ContextVar fix makes this work for real (previously the worker task's stale Context snapshot silently dropped both attribute families). Per-example docs page and the examples index updated for the new --all-extras requirement on demo 07. * fix: address PR #61 review threads - Drop the speculative _LlmEventState backwards-compat alias and the two test-side imports + constructor sites that referenced it (no real downstream consumer to protect). CodeQL's unused-global warnings clear as a side-effect. - Move LLM_NAMESPACE from otel/observer.py to llm_event.py so the core openarmature.observability package no longer pulls the OTel backend (and opentelemetry-sdk) into its import chain. Users without the [otel] extra can now import LLM_NAMESPACE / LlmEventPayload cleanly. - Reconcile the stale "subclasses State" header comment on llm_event.py with the actual BaseModel-based implementation. - Widen attribute_enrichers' type to Sequence[Callable[[Span, NodeEvent | None], None]] to match how _run_enrichers calls it; drop the cast("Any", event) workaround. - Add try/finally + await provider.aclose() to the new #3 regression test so its httpx.AsyncClient doesn't leak. - Collapse the vestigial if/isinstance/else branch in the payload- fixture driver to one append. 729 passed, pyright + ruff clean.
1 parent 9ab4be6 commit 052b839

22 files changed

Lines changed: 1862 additions & 165 deletions

File tree

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,39 @@ All notable changes to `openarmature-python` are documented in this file.
44

55
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The package follows [Semantic Versioning](https://semver.org/); pre-1.0 minor bumps may carry behavioral changes per [spec governance](https://github.com/LunarCommand/openarmature-spec/blob/main/GOVERNANCE.md).
66

7+
## [Unreleased]
8+
9+
LLM-provider span payload and GenAI semconv release. Pinned spec
10+
jumps from v0.16.1 to v0.17.0 (proposal 0024 / observability §5.5
11+
expansion). The trigger was a friction report from a downstream
12+
agent integrating OA with Langfuse over OTLP: LLM spans rendered
13+
"naked" (model + tokens only), prompt linkage silently dropped at
14+
the dispatch-worker task boundary, and every backend needed a
15+
per-service attribute-mapping shim. This release clears all eight
16+
items in that report.
17+
18+
### Added
19+
20+
- **`openarmature.llm.input.messages` / `openarmature.llm.output.content` / `openarmature.llm.request.extras` span attributes (spec §5.5.1).** When the OTel observer is constructed with `disable_llm_payload=False`, LLM spans carry the messages sent, the assistant response content, and the `RuntimeConfig` extras bag — JSON-encoded with sorted keys, no insignificant whitespace, UTF-8. Default-off (the flag is `disable_llm_payload: bool = True`) because the payload may contain PII the user hasn't audited; opt in deliberately. Subject to the §5.5.5 truncation contract.
21+
- **GenAI semantic-conventions attributes (spec §5.5.2 + §5.5.3).** LLM spans now carry `gen_ai.system`, `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.response.finish_reasons` (single-element string array), `gen_ai.response.id`, and per-set `gen_ai.request.{temperature,max_tokens,top_p,seed}` (only set fields — absence is meaningful per §5.5.2). The existing `openarmature.llm.*` attribute set is preserved alongside; both namespaces emit. Default-on (`disable_genai_semconv: bool = False`); opt out when an external auto-instrumentation library (OpenInference, opentelemetry-instrumentation-openai, etc.) is the canonical source of GenAI attributes for your stack.
22+
- **`OTelObserver(resource=...)` constructor argument.** Optional `opentelemetry.sdk.resources.Resource` passed to the private `TracerProvider`. Lets callers set `service.name` / `service.version` directly rather than via `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES` environment variables (which had to be set BEFORE constructing the observer to take effect — a footgun the explicit kwarg avoids).
23+
- **Multi-processor support on `OTelObserver`.** The `span_processor` constructor argument now accepts a `SpanProcessor | Sequence[SpanProcessor]`. Multi-destination export (e.g., HyperDX + Langfuse on one observer) becomes a one-line constructor call instead of a per-service `CompoundSpanProcessor` workaround.
24+
- **`OTelObserver(attribute_enrichers=...)` hook.** Sequence of `Callable[[Span, NodeEvent | None], None]` invoked just before the observer ends each span. Lets users add backend-specific attributes (custom `langfuse.*` keys, vendor span kinds, etc.) without subclassing or mutating `span._attributes` post-`on_end`. The event is `None` on synthetic close sites (subgraph dispatch, detached root, fan-out instance, invocation span, shutdown drain); enrichers that need per-event context short-circuit on `None`. Exceptions are caught and warned, never propagated to the dispatch worker.
25+
- **`OTelObserver(payload_max_bytes=...)` truncation cap.** Per-attribute byte cap for the §5.5.1 payload attributes. Default 65,536 (64 KiB) per attribute; minimum 256 bytes (rejected at construction). The truncation algorithm (spec §5.5.5) emits the largest UTF-8 code-point-aligned prefix that fits within `cap - len(marker)` bytes followed by the marker `…[truncated, M bytes total]`. Inline image bytes are unconditionally redacted at the provider before any cap applies (see Image redaction below).
26+
- **`OpenAIProvider(genai_system="openai")` constructor argument.** Default `"openai"`; override for non-OpenAI endpoints that speak the OpenAI Chat Completions wire format (vLLM, LM Studio, llama.cpp, sglang). Surfaces as the `gen_ai.system` span attribute. No base-URL sniffing happens — the same host:port could be any of several servers, and a wrong inference is worse than the explicit opt-in.
27+
- **`openarmature.observability.LLM_NAMESPACE` and `openarmature.observability.LlmEventPayload` public exports.** The `("openarmature.llm.complete",)` sentinel namespace used by the LLM-provider hook and the payload shape backend observers consume. Third-party `Provider` implementations can dispatch their own LLM events via `current_dispatch()(NodeEvent(..., namespace=LLM_NAMESPACE, pre_state=LlmEventPayload(...)))`; custom observers can recognize the same sentinel and read attributes off the payload. Previously private (`_LLM_NAMESPACE`, `_LlmEventState`); the old underscore-prefixed names are no longer exported.
28+
- **`Response.response_id` and `Response.response_model` typed fields.** Mirror the wire response's `id` and `model` fields when the provider returns them. Surface as `gen_ai.response.id` and `gen_ai.response.model` per spec §5.5.3; also useful for downstream cross-referencing with provider-side billing or audit logs without reaching into `Response.raw`.
29+
30+
### Changed
31+
32+
- **Prompt-context attribute propagation now survives the dispatch-worker task boundary.** Previously the OTel observer read `current_prompt_result()` / `current_prompt_group()` from inside `_handle_llm_event`, which runs in the engine's delivery-worker task. `asyncio.create_task(deliver_loop(queue))` snapshots the current Context at task creation, before any node body runs — so the ContextVars set by `with_active_prompt(...)` were never visible to the worker. `openarmature.prompt.*` attributes silently went missing on the LLM span. Fixed by capturing both ContextVars at dispatch time inside the `OpenAIProvider.complete()` call (which runs in the node task, where `with_active_prompt` IS active) and threading the snapshots through the `LlmEventPayload`. The observer reads from the payload, not the ContextVar.
33+
- **Inline image bytes are redacted at the provider, not the observer.** Image content blocks with `ImageSourceInline` are serialized with `source` replaced by `{type: "inline_redacted", byte_count: N}` per §5.5.5 *before* the payload reaches the observability dispatch queue. Defense-in-depth: bytes never leave the provider in event form, so custom observers subscribing to the LLM event (enabled by `LlmEventPayload` being public) cannot accidentally leak raw image bytes regardless of their implementation. `media_type` and `detail` are preserved at the image-block level per llm-provider §3.1.2. URL-form images pass through unchanged.
34+
- **`OTelObserver.shutdown()` docstring documents the `BatchSpanProcessor` flush gotcha.** Under fast or unusual teardown orderings (e.g., FastAPI TestClient teardown that closes the event loop before the batch processor's export thread finishes), spans can appear dropped. Documented workarounds: call `provider.force_flush(timeout_millis=…)` explicitly before `shutdown()`, or use `SimpleSpanProcessor` in tests.
35+
36+
### Notes
37+
38+
- **Pinned spec version bumped to v0.17.0.** Per the additive-only governance rule (proposal 0024 adds; never renames), implementations passing v0.16.1 conformance fixtures continue to pass under v0.17.0; the new fixtures (012-021) add cases without modifying existing ones.
39+
740
## [0.7.0] — 2026-05-23
841

942
Docs-and-examples release. Pinned spec stays at v0.16.1; no

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ The engine awaits each save before advancing. A crash immediately after a `compl
5353
**Observability that doesn't double-export.**<br>
5454
The OpenTelemetry mapping mandates a private `TracerProvider`. That prevents the trap where global-provider auto-instrumentation libraries (OpenInference, Langfuse v3, etc.) emit duplicate spans alongside the framework's. Your spans flow exactly where you point them; no surprise fan-out to vendor backends you didn't configure.
5555

56+
**LLM spans LLM-aware backends can actually read.**<br>
57+
Each `provider.complete()` call emits a dedicated `openarmature.llm.complete` span carrying both the framework's `openarmature.llm.*` attributes and the cross-vendor OpenTelemetry GenAI semantic conventions (`gen_ai.system`, `gen_ai.request.*`, `gen_ai.response.*`, `gen_ai.usage.*`). Langfuse, Phoenix, Honeycomb's LLM lens — they render generations correctly out of the box, no per-service attribute-mapping shim required. Input/output payload emission is opt-in (`disable_llm_payload=False`), default-off because the payload may contain PII; image bytes are unconditionally redacted at the provider so they never enter the observability stream.
58+
5659
## Hello World
5760

5861
About a hundred lines that show the engine in action. Three reducer policies declared on one state class. Three LLM calls each returning typed structured output (Pydantic class on two, raw JSON Schema dict on the third). Conditional routing as a pure function of state, not a hidden state machine. An observer attached at compile time that sees every node boundary the engine emits. Requires Python 3.12 or later and an OpenAI-compatible endpoint (defaults to OpenAI public API; works against any local server too).

docs/concepts/observability.md

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,226 @@ join semantics survive even when trace boundaries don't.
333333
The non-detached default is what you want most of the time: one
334334
trace per outermost invocation, with subgraphs and fan-out instances
335335
as nested spans.
336+
337+
### LLM provider spans
338+
339+
When an `OpenAIProvider` (or any [custom Provider](../model-providers/authoring.md)
340+
that wires the dispatch hook) is used inside a graph with `OTelObserver`
341+
attached, each `provider.complete()` call emits a dedicated span named
342+
`openarmature.llm.complete`, parented under the calling node's span.
343+
The span carries two attribute families.
344+
345+
**`openarmature.llm.*` (always on).** The framework's canonical
346+
namespace: model identifier, finish reason, token counts, prompt
347+
identity from `with_active_prompt(...)`, error category on failure.
348+
Set unconditionally whenever the LLM span itself emits.
349+
350+
**`gen_ai.*` (OpenTelemetry GenAI semantic conventions, default on).**
351+
Cross-vendor attribute names every LLM-aware backend reads
352+
(Langfuse, Phoenix, Honeycomb's LLM lens, OpenInference-aware
353+
tools). Emitted alongside the OA namespace:
354+
355+
- `gen_ai.system``"openai"` by default; override per provider
356+
instance to `"vllm"` / `"lm_studio"` / `"llama_cpp"` / etc. when
357+
the OpenAI Chat Completions wire format is hitting a non-OpenAI
358+
endpoint:
359+
360+
```python
361+
provider = OpenAIProvider(
362+
base_url="http://vllm.internal:8000",
363+
model="meta-llama/Llama-3-8B-Instruct",
364+
genai_system="vllm",
365+
)
366+
```
367+
368+
- `gen_ai.request.model` / `gen_ai.response.model` — the bound
369+
model and (when the provider returns one) the more-specific
370+
identifier in the response body.
371+
- `gen_ai.request.temperature` / `max_tokens` / `top_p` / `seed`
372+
— only emitted for fields the caller actually set; absence on
373+
the span means "not supplied," distinct from a zero value.
374+
- `gen_ai.usage.input_tokens` / `output_tokens` — token counts.
375+
- `gen_ai.response.finish_reasons` — single-element string array.
376+
- `gen_ai.response.id` — when the provider returns one.
377+
378+
Disable the GenAI semconv set with `OTelObserver(disable_genai_semconv=True)`
379+
when an external auto-instrumentation library (OpenInference,
380+
`opentelemetry-instrumentation-openai`) is already the canonical
381+
source on your stack.
382+
383+
### LLM payload attributes
384+
385+
By default, LLM spans do **not** carry the messages sent or the
386+
response content. Opt in with `disable_llm_payload=False`:
387+
388+
```python
389+
observer = OTelObserver(
390+
span_processor=SimpleSpanProcessor(exporter),
391+
disable_llm_payload=False,
392+
)
393+
```
394+
395+
This surfaces three attributes:
396+
397+
- `openarmature.llm.input.messages` — JSON-encoded message array
398+
(the spec §3 message shape: `{role, content, tool_calls?, …}`).
399+
- `openarmature.llm.output.content` — the assistant's response
400+
content string verbatim. Omitted for tool-call-only responses
401+
with empty content.
402+
- `openarmature.llm.request.extras` — JSON-encoded `RuntimeConfig`
403+
extras bag (provider-specific pass-through fields like
404+
`frequency_penalty`). Omitted when empty.
405+
406+
**Default-off is deliberate.** The payload may contain PII the user
407+
hasn't audited; opting in is a separate decision from opting into
408+
observability. The flag name keeps symmetry with `disable_llm_spans`:
409+
the default value (`True`) reads as "the observer disables payload
410+
emission by default."
411+
412+
#### Truncation
413+
414+
Each payload attribute is capped at `payload_max_bytes` UTF-8 bytes
415+
(default 64 KiB, minimum 256). When the serialized value exceeds the
416+
cap, the observer emits the largest UTF-8-code-point-aligned prefix
417+
that fits within `cap - len(marker)` bytes followed by the marker:
418+
419+
```
420+
…[truncated, M bytes total]
421+
```
422+
423+
where M is the pre-truncation byte length. The marker is appended
424+
outside any JSON encoding — a truncated attribute is *not* parseable
425+
JSON, which is the clean signal backend code can use to detect
426+
truncation without a separate flag.
427+
428+
#### Inline image redaction (always on)
429+
430+
Image content blocks with `ImageSourceInline` are redacted at the
431+
provider, *before* the payload reaches the observer:
432+
433+
```json
434+
{
435+
"type": "image",
436+
"source": {"type": "inline_redacted", "byte_count": 4096},
437+
"media_type": "image/png",
438+
"detail": "auto"
439+
}
440+
```
441+
442+
The `media_type` and `detail` fields are preserved at the image-block
443+
level (per llm-provider §3.1.2); only `source` is replaced. URL-form
444+
images pass through unchanged — the URL is a short string and is
445+
informative for trace readers.
446+
447+
Redaction is **not** gated by `disable_llm_payload` and is **not**
448+
configurable. Inline image bytes never leave the provider in event
449+
form, so custom observers consuming
450+
[`LlmEventPayload`](#publishing-llm-events-for-custom-observers)
451+
cannot accidentally leak raw bytes regardless of how they're
452+
written.
453+
454+
### Identifying the service: `Resource`
455+
456+
Pass an `opentelemetry.sdk.resources.Resource` to set
457+
`service.name` / `service.version` / etc. without relying on the
458+
`OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES` environment
459+
variables (which had to be set *before* `OTelObserver()`
460+
construction to take effect):
461+
462+
```python
463+
from opentelemetry.sdk.resources import Resource
464+
465+
observer = OTelObserver(
466+
span_processor=SimpleSpanProcessor(exporter),
467+
resource=Resource.create({"service.name": "claims-pipeline"}),
468+
)
469+
```
470+
471+
### Fanning out to multiple backends
472+
473+
The `span_processor` argument accepts either a single processor or
474+
a sequence. Multi-destination export (HyperDX + Langfuse from one
475+
observer) is a one-line construct:
476+
477+
```python
478+
observer = OTelObserver(
479+
span_processor=[
480+
BatchSpanProcessor(OTLPSpanExporter(endpoint=HYPERDX_URL)),
481+
BatchSpanProcessor(OTLPSpanExporter(endpoint=LANGFUSE_URL)),
482+
],
483+
)
484+
```
485+
486+
Every registered processor receives every span.
487+
488+
### Adding backend-specific attributes: `attribute_enrichers`
489+
490+
When a backend needs attributes the framework doesn't emit
491+
(custom `langfuse.observation.*` keys, Honeycomb derived fields,
492+
etc.), the `attribute_enrichers` hook fires just before every
493+
`span.end()` call:
494+
495+
```python
496+
def langfuse_observation_kind(span, event):
497+
if span.name == "openarmature.llm.complete":
498+
span.set_attribute("langfuse.observation.type", "generation")
499+
500+
observer = OTelObserver(
501+
span_processor=processor,
502+
attribute_enrichers=[langfuse_observation_kind],
503+
)
504+
```
505+
506+
Each enricher receives the live `Span` plus the `NodeEvent` that
507+
triggered the close (or `None` on synthetic close sites — subgraph
508+
dispatch, detached root, fan-out instance, invocation span,
509+
shutdown drain). Setting attributes inside this hook works
510+
correctly; doing it from a `SpanProcessor.on_end` callback does
511+
not, because the framework has already called `span.end()` and the
512+
OTel SDK silently drops `set_attribute` on ended spans.
513+
514+
Exceptions raised by an enricher are caught and warned, never
515+
propagated.
516+
517+
### Publishing LLM events for custom observers
518+
519+
`openarmature.observability.LLM_NAMESPACE` and
520+
`openarmature.observability.LlmEventPayload` are part of the public
521+
API. A custom observer subscribing to the dispatch stream can
522+
recognize the LLM-event sentinel namespace and read the typed
523+
payload directly:
524+
525+
```python
526+
from openarmature.observability import LLM_NAMESPACE, LlmEventPayload
527+
528+
async def my_llm_observer(event):
529+
if event.namespace != LLM_NAMESPACE:
530+
return
531+
payload = event.pre_state
532+
if not isinstance(payload, LlmEventPayload):
533+
return
534+
# payload.model, payload.input_messages (already image-redacted),
535+
# payload.output_content, payload.request_params,
536+
# payload.response_id, payload.active_prompt, ...
537+
```
538+
539+
A custom `Provider` that wants to participate in the same span
540+
emission protocol dispatches `NodeEvent(namespace=LLM_NAMESPACE,
541+
pre_state=LlmEventPayload(...))` via `current_dispatch()`. See
542+
[Authoring providers](../model-providers/authoring.md) for the
543+
full pattern.
544+
545+
### Flushing under fast teardown
546+
547+
`OTelObserver.shutdown()` calls `provider.shutdown()` on the private
548+
`TracerProvider`, which per OTel SDK contract flushes every
549+
registered span processor. Under unusual teardown orderings — for
550+
example, FastAPI's `TestClient` teardown that closes the event loop
551+
before a `BatchSpanProcessor`'s export thread finishes — spans can
552+
appear dropped. Two workarounds:
553+
554+
- Call `observer._provider.force_flush(timeout_millis=...)`
555+
explicitly before `shutdown()`.
556+
- Use `SimpleSpanProcessor` instead of `BatchSpanProcessor` in
557+
tests; it exports synchronously and is unaffected by teardown
558+
timing.

0 commit comments

Comments
 (0)