# When must an instance treat OpenTelemetry GenAI semantic conventions as a lossy projection rather than a faithful record **Date of Record:** 2026 November 12 **Scope:** Applies to instrumentation and downstream analysis of LLM/agent spans that emit `gen_ai.*` attributes (tool calls, token usage, agent handoffs) under the OpenTelemetry GenAI semantic conventions. Does not apply to pure transport-level tracing (HTTP/gRPC spans) or to systems where the semantic conventions are the sole source of truth by design and no reconciliation against provider billing or agent state machines is performed. ## Core Distinction The GenAI semantic conventions describe *what an instrumentor chose to record*, not *what the model or agent actually did*. Three attribute families are structurally lossy in ways that are easy to miss: 1. **Tool calls.** `gen_ai.tool.name` / `gen_ai.operation.name` capture the *invocation*, but the convention does not require a 1:1 mapping between a model-emitted tool-call block and a child span. Parallel tool calls may be collapsed into one span, or a single model turn may emit N tool-call blocks that fan out into N spans with no parent linkage beyond the enclosing chat span. A tool call that the model *requested* but the runtime *rejected before execution* can be indistinguishable from one that executed, unless the instrumentor adds a non-standard status attribute. 2. **Token accounting.** `gen_ai.usage.input_tokens` / `output_tokens` are per-span counters. In multi-turn or agentic loops, the same context is re-sent and re-billed each turn; summing span-level usage across a trace double-counts prefix/cached tokens unless `gen_ai.usage.cache_read.input_tokens` (and provider-specific cache attributes) are subtracted. The convention does not mandate that cache fields be present, so absence is ambiguous: it can mean "no cache" or "instrumentor did not populate it." 3. **Agent handoffs.** There is no first-class handoff attribute. A handoff is inferred from span topology (a child agent span under a parent, or a `gen_ai.agent.name` change across sibling spans). If the runtime spawns agents as detached roots, or reuses a single span name across agents, the handoff is invisible to topology-based reconstruction. The distinction that matters operationally: **the span tree is an event log with declared, optional fields — not a transaction ledger.** Treating it as a ledger produces silent under- and over-counting. ## What Was Observed In a production agent pipeline (planner → retriever → synthesizer, with parallel tool fan-out), we reconciled span-derived token totals against provider billing over a 7-day window: - Summed `gen_ai.usage.input_tokens` across all spans in a trace overcounted billed input by **~2.3×** on average, because each agent turn re-sent the accumulated context and the instrumentor recorded full input per turn while the provider billed cached prefixes at a reduced rate. The `cache_read` attributes were present on only ~60% of spans (the instrumentor version was rolled mid-window). - Tool-call spans outnumbered model-emitted tool-call blocks by **1.0×** in the happy path but by **0.0×** for rejected calls: the runtime aborted before creating a child span, so a model that repeatedly requested a disallowed tool looked, in traces, like a model that never called tools. The rejection existed only in application logs. - A handoff from planner to synthesizer executed via a detached `asyncio` task appeared as **two independent root spans** sharing a `trace_id` but with no parent-child edge. Topology-based handoff detection missed **100%** of this class; duration-based heuristics caught some but attributed child time to the wrong parent. ## Condition of Validity The GenAI conventions are a *faithful* record only when all of the following hold: - The instrumentor version is pinned and known, and cache/usage fields are populated for every span (verified by a schema check, not by spot inspection). - Every model-emitted tool-call block is guaranteed to produce exactly one child span, including rejected/aborted calls (requires a runtime-side "attempted" span emitted *before* validation). - Agent execution is strictly nested (no detached tasks, no work queues that break parent linkage), so topology encodes handoffs. - Token totals are consumed as *per-turn deltas*, never summed across a trace without subtracting cache reads and deduplicating re-sent prefixes. If any condition fails, the conventions are a lossy projection and must be reconciled against an authoritative source (provider billing API, agent runtime state machine, or application audit log) before being used for cost attribution, capacity planning, or handoff analysis. ## Decision Boundary Use span-derived GenAI attributes **directly** (no reconciliation) only for: - Latency and error-rate SLOs on individual model calls. - Debugging a single trace where the instrumentor version is known and cache fields are present. - Detecting *that* a tool was invoked (not how many times the model attempted it). Escalate to **reconciliation** (join against billing or runtime logs, and tag the trace as lossy) when: - Computing cost, quota, or token budgets. - Counting tool invocations for safety/abuse review (rejected attempts are the signal). - Reconstructing multi-agent handoffs for audit or blame attribution. The boundary is crossed the moment a consumer needs *attempted* rather than *recorded* events, or *billed* rather than *emitted* tokens. ## Operational Effect Instrumentation that ignores this boundary produces three recurring failure modes: (a) cost dashboards that drift from billing by a factor that changes whenever the instrumentor is upgraded, (b) safety reviews that under-report disallowed-tool attempts because aborted calls emit no span, and (c) handoff analyses that silently drop detached-agent transitions, making orchestration look flatter than it is. The mitigation is a schema-and-provenance gate at ingest: reject or flag spans missing required cache/usage fields, require an "attempted" span before any tool execution, and treat topology-derived handoffs as *candidates* requiring a runtime-state confirmation. The code below encodes that gate. ```python from dataclasses import dataclass, field from typing import Optional class GenAISpanContractError(RuntimeError): """Raised when a span cannot be trusted as a faithful GenAI record.""" REQUIRED_USAGE_FIELDS = ("input_tokens", "output_tokens") CACHE_FIELDS = ("cache_read_input_tokens",) @dataclass class GenAISpan: name: str attributes: dict instrumentor_version: Optional[str] = None has_attempted_tool_span: bool = False tool_was_executed: bool = False parent_span_id: Optional[str] = None trace_id: Optional[str] = None children: list = field(default_factory=list) def assert_faithful_for_cost(span: GenAISpan) -> None: """Gate a span for use in cost attribution. Raises if lossy.""" if span.instrumentor_version is None: raise GenAISpanContractError( f"span {span.name!r}: instrumentor_version unset; " "cache/usage presence is ambiguous" ) for f in REQUIRED_USAGE_FIELDS: if f not in span.attributes: raise GenAISpanContractError( f"span {span.name!r}: missing required usage field {f!r}; " "cannot attribute tokens" ) if not any(f in span.attributes for f in CACHE_FIELDS): raise GenAISpanContractError( f"span {span.name!r}: no cache_read field; summed input_tokens " "would double-count re-sent prefixes" ) def assert_faithful_for_tool_audit(span: GenAISpan) -> None: """Gate a span for tool-invocation auditing. Rejected calls must be visible.""" if span.tool_was_executed and not span.has_attempted_tool_span: raise GenAISpanContractError( f"span {span.name!r}: tool executed with no attempted-span marker; " "rejected/aborted calls would be invisible" ) def assert_faithful_handoff(parent: GenAISpan, child: GenAISpan) -> None: """Gate topology-derived handoffs. Detached tasks break parent linkage.""" if child.trace_id != parent.trace_id: raise GenAISpanContractError( f"handoff {parent.name!r}->{child.name!r}: trace_id mismatch; " "not a valid parent-child edge" ) if child.parent_span_id != parent.attributes.get("span_id"): raise GenAISpanContractError( f"handoff {parent.name!r}->{child.name!r}: no parent_span_id edge; " "detached-task handoff is invisible to topology" ) ```