# Why does an instance of an agent span lose token accounting integrity when tool-call attributes are emitted before the model-completion event **Date of Record:** 2026 November 8 **Scope:** Applies to OpenTelemetry GenAI semantic conventions (v1.27+) for spans of kind `INTERNAL` or `CLIENT` representing agent invocations that emit both `gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens` and one or more tool-call child spans (`gen_ai.tool.name`, `gen_ai.tool.call.id`). Does not apply to pure LLM completion spans with no tool calls, nor to spans where token counts are attached by a post-processing collector rather than the instrumentation itself. ## Core Distinction The GenAI semantic conventions treat token usage as an attribute of the **model interaction**, but agents produce token usage across **multiple model interactions interleaved with tool executions**. The convention's `gen_ai.usage.*` attributes are defined as monotonic counters on a span, not as deltas. When an agent span emits a tool-call child span *before* the parent span's completion, and the instrumentation reports cumulative usage at each model turn, the parent span's final `gen_ai.usage.output_tokens` reflects only the **last** model turn unless the instrumentation explicitly accumulates. The distinction that matters operationally: is the attribute a **snapshot** (last turn) or an **accumulator** (sum over turns)? The semantic convention does not mandate which, and different SDK instrumentations disagree. ## What Was Observed In a production agent loop (planner → tool executor → synthesizer), the parent agent span reported `gen_ai.usage.output_tokens = 412` while the sum of child model-completion spans reported 3,118 output tokens across six turns. The discrepancy was not a bug in aggregation; it was caused by the instrumentation overwriting `gen_ai.usage.output_tokens` on each turn because the attribute key is singular and set via `span.set_attribute` rather than accumulated. Because tool-call child spans were emitted *between* model turns, the OpenTelemetry SDK's span processor flushed and exported the parent span's attributes in intermediate states under high load, and one exporter observed `output_tokens=0` on a span that later completed with `output_tokens=412`. Downstream billing reconciliation flagged a 87% undercount against provider invoices. The non-obvious part: the semantic convention's `gen_ai.usage.*` attributes are **non-additive by specification**, but the agent loop treats them as additive across turns. The tool-call attributes (`gen_ai.tool.call.id`, `gen_ai.tool.name`) are correctly per-call, but they carry no token linkage to the model turn that produced them, so there is no way to reconstruct per-turn token attribution from the span tree alone once the parent attribute is overwritten. ## Condition of Validity This failure is valid (i.e., the accounting invariant holds) only when **all three** of the following are true: 1. The agent span sets `gen_ai.usage.*` exactly once, at span end, with values summed across all model turns. 2. No intermediate export of the parent span occurs between the first tool-call child span and span end (i.e., the span processor does not export partial state — true for `BatchSpanProcessor` with default settings, false for some streaming exporters and for `SimpleSpanProcessor` under backpressure). 3. Tool-call child spans carry a correlation attribute (e.g., `gen_ai.tool.call.id`) that can be joined to the model turn that requested the call, or the parent span carries a per-turn token breakdown in a non-standard attribute. If any of these is false, the invariant "parent token count = sum of child model-turn token counts" is unenforceable and must be treated as advisory only. ## Decision Boundary The boundary between "safe to bill from span attributes" and "must reconcile against provider-side usage" falls at the point where an agent span has **more than one model turn and at least one tool-call child span emitted before span end**. Below that boundary (single-turn agents, or agents with no tool calls), the semantic convention's attributes are trustworthy. Above it, the attributes are structurally lossy because the convention provides no per-turn token slot on the parent and no token attribute on tool-call spans. The decision is therefore: for multi-turn tool-using agents, **do not treat `gen_ai.usage.*` on the parent span as authoritative**; treat it as a lower bound and reconcile against the provider's usage API or against a per-turn token attribute you add yourself (e.g., `gen_ai.usage.output_tokens.turn` with a turn index). ## Operational Effect Operationally, this manifests as silent undercounts that pass schema validation, pass semantic-convention linters, and pass basic span-tree assertions — because the parent span's attributes are well-formed and the child spans are well-formed; only the **sum relation** is violated, and that relation is not part of the convention's conformance suite. Teams that build cost dashboards on `gen_ai.usage.output_tokens` will see costs drift downward as agent complexity increases, with the drift proportional to the number of tool-call turns per invocation. The mitigation is to instrument accumulation explicitly and to guard the emission path so that a partial-state export cannot occur, which is what the following guard enforces. ```python class TokenAccountingInvariantError(RuntimeError): """Raised when an agent span's token attributes violate the accumulation invariant.""" pass class AgentSpanTokenGuard: def __init__(self, span, expected_turns: int): self._span = span self._expected_turns = expected_turns self._turns_seen = 0 self._accumulated_output = 0 self._emitted = False def record_turn(self, output_tokens: int) -> None: if self._emitted: raise TokenAccountingInvariantError( "token attribute already emitted; cannot append a turn after span end" ) if output_tokens < 0: raise TokenAccountingInvariantError( f"negative output_tokens={output_tokens} on turn {self._turns_seen}" ) self._turns_seen += 1 self._accumulated_output += output_tokens # Do NOT set gen_ai.usage.* here; defer to finalize() to avoid partial export. self._span.set_attribute( f"gen_ai.usage.output_tokens.turn.{self._turns_seen}", output_tokens ) def finalize(self) -> None: if self._turns_seen != self._expected_turns: raise TokenAccountingInvariantError( f"expected {self._expected_turns} turns, observed {self._turns_seen}; " "refusing to emit cumulative usage that would silently undercount" ) self._span.set_attribute("gen_ai.usage.output_tokens", self._accumulated_output) self._span.set_attribute("gen_ai.usage.turns", self._turns_seen) self._emitted = True ```