Story 1618: gen_ai.* — speaking the observability tools' native LLM language
Why this exists (the catastrophe it prevents). Every model call PRAMAAN makes goes through one door — OpenRouterAdapter — and that door already opens a span around the egress HTTP call (Chore-1575). But the span spoke only HTTP: http.request.method, http.response.status_code, and a PRAMAAN-private model_id. To Datadog, Grafana, and the X-Ray GenAI views — all of which, as of 2026, read the OpenTelemetry gen_ai.* semantic convention natively — those spans were mute: a span that costs real money on every firing, carrying zero of the dimensions (model, token counts, cache hits) those tools index, chart, and alert on. The observability bill was being paid and the LLM-cost signal was being thrown on the floor. Worse, the single most material cost lever — Anthropic prompt caching via OpenRouter, where a cache read is ~10% the price of a fresh input token — was completely invisible: we could not tell a 10,000-token cached prompt from a 10,000-token cold one. This Story teaches both inference spans to speak gen_ai.*, reusing token accounting the broker already hands back, so the spend becomes legible to the tools without a single new dependency or a second tracing path.
gen_ai.* amendment. No new span — this enriches the two existing closed-registry manual spans (INFERENCE_OPENROUTER_CHAT_COMPLETIONS, INFERENCE_OPENROUTER_GENERATION). The closed-registry ManualSpanName stays the span name; gen_ai.* ride as attributes (the semconv "{operation} {model}" span-name guidance is deliberately not adopted — the registry name is the contract). No prompt/response content ever reaches a span: gen_ai.input/output.messages stay off — they are PII (ADR-124 denylist). One tracing system only (ADOT layer); this touches no tracer APIs, only the approved set_observability_attributes helper.🛰️ Satellite: one door, now legible to the tools
OpenRouterAdapter — the ONLY model-provider callsite (ADR-120 D14)
│
├─ _post(body) ─────────────────▶ POST /chat/completions
│ span: INFERENCE_OPENROUTER_CHAT_COMPLETIONS (CLIENT)
│ request-side (at span open):
│ gen_ai.operation.name = "chat"
│ gen_ai.provider.name = "openrouter"
│ gen_ai.request.model = body["model"] (model_id kept as alias)
│ response-side (from the egress usage accounting):
│ gen_ai.response.model
│ gen_ai.usage.input_tokens / output_tokens
│ gen_ai.usage.cache_read.input_tokens ◀── cached_tokens
│ gen_ai.usage.cache_creation.input_tokens ◀── cache_write_tokens
│
└─ fetch_generation_cost(id) ────▶ GET /generation?id=
span: INFERENCE_OPENROUTER_GENERATION (CLIENT)
gen_ai.operation.name = "chat"
gen_ai.provider.name = "openrouter"
gen_ai.response.model (when the backfill exposes it; no tokens here)
The key idea: the token numbers were already in the egress response — OpenRouter returns a full usage block (we even ask for it with usage:{include:true}). The chat span closes around that very HTTP exchange, so reading the response's own usage while the span is open is reusing the source, not adding a second one. Two spans, the same one tracing system, now carrying the dimensions the vendor tools index.
✈️ Airplane: the change surface (one file of production code)
| File | Role |
|---|---|
src/shared/inference/openrouter_adapter.py | New gen_ai.* constants (_GEN_AI_PROVIDER, _GEN_AI_OPERATION_CHAT); two pure best-effort helpers _gen_ai_chat_response_attributes / _gen_ai_generation_response_attributes that read the egress body for usage/model semconv; request-side gen_ai.* stamped at each span's open; response-side stamped via set_observability_attributes while the span is still open. |
tests/unit/test_openrouter_adapter.py | New: chat span carries the full gen_ai.* set including cache read/creation tokens on a mocked OpenRouter response; cache keys are omitted when the response has no prompt_tokens_details; the generation span carries operation/provider + response model. Asserts no gen_ai.input/output.messages reach the span. |
tests/unit/shared/observability/test_manual_span.py | New: the gen_ai.* prefix admits the usage/cache keys the inference spans stamp, and those keys are never promoted to a searchable X-Ray annotation (ADR-124 denylist intact) — verifying the allowlist without weakening the closed registry. |
The manual_span allowlist needed no code change: gen_ai. was already in STANDARD_SPAN_ATTRIBUTE_PREFIXES. The Story's "extend the allowlist" is satisfied by verifying that prefix admits exactly the keys we use — pinned by a test so a future tightening can't silently break inference observability.
🔬 Microscopic: reading the usage block the broker already sent
Title every line by its reason. OpenRouter's usage object reports Anthropic prompt caching under prompt_tokens_details: cached_tokens is a cache read, cache_write_tokens is cache creation. Those map one-to-one onto the OTel cache semconv:
def _gen_ai_chat_response_attributes(response):
try:
payload = response.json()
except ValueError:
return {} # best-effort telemetry — never raise into
if not isinstance(payload, dict): # the request path (the authoritative
return {} # HR-21 parse lives in complete())
attrs = {}
model = payload.get("model")
if isinstance(model, str) and model:
attrs["gen_ai.response.model"] = model
usage = payload.get("usage")
if isinstance(usage, dict):
# prompt_tokens → input, completion_tokens → output
... set gen_ai.usage.input_tokens / output_tokens ...
details = usage.get("prompt_tokens_details")
if isinstance(details, dict):
# cached_tokens → cache_read (the ~10x cost saving)
# cache_write_tokens → cache_creation (the one-time write cost)
... set gen_ai.usage.cache_read/cache_creation.input_tokens ...
return attrs # only keys with a value — never None
InferenceResult) is validated through Pydantic, never cast from a raw dict. But span attributes are not a result — they are non-authoritative observability that must never break the work it observes (ADR-124). So the span enrichment reads the egress body defensively: a malformed or partial body simply yields no attributes rather than raising. It reads only token/model accounting — never message content — so no PII can leak onto the span even on a surprising payload. This is the same posture the span already had toward response.status_code.Three properties the enrichment must hold, and why:
| Property | Why it matters |
|---|---|
| Cache tokens set only when present | A non-caching call has no prompt_tokens_details. Stamping a zero would be noise that reads as "cache existed and missed". The helper emits a cache key only when the broker exposes a value — the absent-block first-run state is exercised by a dedicated test. |
| No content, ever | gen_ai.input/output.messages carry the prompt and completion — PII. They stay opt-in/off; the helper never touches choices[].message. A test asserts neither key appears on the span. |
| Not promoted to X-Ray annotations | gen_ai.* is allowed as a span attribute via prefix, but the X-Ray annotation allowlist is CUSTOM ∪ STANDARD (no prefix match), so token counts can never become a high-cardinality searchable annotation (ADR-124). Pinned by a test. |
pytest tests/unit/test_openrouter_adapter.py&tests/unit/shared/observability/test_manual_span.py— 32 passed (new gen_ai.* + cache-token + no-PII + allowlist assertions).- Inference + observability + guardrail unit sweep — 145 passed.
ruff checkclean on changed files;mypy src/shared/inference/openrouter_adapter.pyclean.- No route/DTO/trust-boundary change →
make openapiproduces no snapshot diff; no migration → single Alembic head unchanged.
One-sentence recall: both OpenRouter inference spans now carry the OTel gen_ai.* semconv — operation, provider, request/response model, input/output tokens, and Anthropic prompt-cache read/creation tokens — read best-effort from the usage block the broker already returns, so vendor-native LLM cost/observability works without a new dependency, a new span, or any prompt/response content on the span.