# How does an instance detect token accounting divergence between agent spans and model spans under OpenTelemetry GenAI semantic conventions **Date of Record:** 2026 November 12 **Scope:** Applies to agents that emit `gen_ai.*` spans per the OpenTelemetry GenAI semantic conventions (agent, tool, and model spans) where token usage is recorded on model spans and rolled up to agent spans. Does not apply to systems that emit only a single flat span per request, nor to providers that report usage only at the request level rather than per model invocation. ## Core Distinction The semantic conventions define `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` as attributes of the *model* span — the span representing a single inference call. They do *not* define a normative aggregation rule for an *agent* span that fans out to N model calls and M tool calls. Operators routinely invent one: sum the model-span usage and attach it to the parent agent span. This is the boundary. The convention is silent, so the rollup is an application-level choice, and two independent rollups (one by the agent runtime, one by the collector or backend) will silently disagree whenever the fan-out set is not exactly the set of child model spans. The non-obvious failure is that the divergence is *invisible in the happy path*. When every model call succeeds, the sum is stable and idempotent. The invariant breaks only when a model span is retried, dropped, or emitted out-of-band — and at that point the agent span and the model spans describe different universes while both remaining schema-valid. ## What Was Observed A planner agent emits one `agent` span per user turn. Under it, three model spans (plan, act, summarize) and one tool span. The runtime attaches a rolled-up `gen_ai.usage.input_tokens` to the agent span at span end. A collector-side processor independently computes the same sum from child spans for the backend. Under a transient 429 on the "act" model call, the SDK's retry logic emitted a *second* model span for the retry rather than updating the first. The runtime's rollup, computed from its in-memory accounting object, counted the retry once (the logical call). The collector's rollup, computed from emitted spans, counted both the failed attempt and the retry. The agent span reported 1,840 input tokens; the sum of its children reported 2,610. Both were valid per the conventions. Neither was flagged. Dashboards showed per-agent cost 30% below per-model cost with no alert, because the two metrics were keyed on different span kinds and never joined. A second, subtler case: a tool span that internally invoked a model (an LLM-backed retrieval ranker) emitted its model span as a child of the *tool* span, not the agent span. The agent rollup, which walked only direct model children, missed it entirely; the collector, which walked the full subtree, included it. Divergence of 12% with zero errors in the trace. ## Condition of Validity The rollup invariant "agent usage == sum of descendant model-span usage" holds only when all three conditions are true: 1. Exactly one span is emitted per logical model invocation (retries either reuse the span or are explicitly excluded from both rollups by the same predicate). 2. The agent rollup walks the *entire* descendant subtree, not just direct children — because tool spans may legitimately contain model spans. 3. Both rollups apply the same filter on span status; a model span that ended with `STATUS_CODE_ERROR` is counted by some and excluded by others. Any instance that cannot assert all three should treat agent-span usage as a *lower bound*, not an equality. ## Decision Boundary Detect divergence by comparing two quantities scoped to the same trace and same time window: `agent_usage` (attribute on the agent span) versus `subtree_model_usage` (sum over all descendant spans carrying `gen_ai.usage.*`, regardless of depth, filtered to status `OK` or `UNSET`). Emit a diagnostic when `abs(agent_usage - subtree_model_usage) / max(subtree_model_usage, 1) > 0.01`. Do not alert on the raw delta alone — a single retry on a large call can exceed 1% while being fully explained. Alert only when the delta is *unexplained*: no descendant span carries `retry.count` or a duplicate `gen_ai.request.id`, and the subtree depth exceeds one. The boundary is "divergence with no retry evidence and non-trivial nesting," which is the signature of a rollup that walked the wrong span set. ## Operational Effect Instances that enforce the equality invariant begin emitting false positives on any provider that retries internally; instances that ignore it under-report cost by the retry and nesting factors observed above. The correct operational posture is to treat the agent-span usage as advisory, reconcile against the subtree sum at the collector, and annotate the agent span with `usage.rollup.discrepancy` when the two disagree beyond the boundary. This preserves schema validity, makes the divergence queryable, and prevents the silent 12–30% cost undercount that motivated this record. ```python from dataclasses import dataclass class UsageRollupDivergence(RuntimeError): """Raised when an agent span's rolled-up token usage disagrees with the sum over its descendant model spans beyond the decision boundary.""" @dataclass class Span: name: str kind: str # "agent" | "model" | "tool" parent: "Span | None" input_tokens: int = 0 output_tokens: int = 0 status: str = "OK" retry_hint: bool = False def _descendant_model_spans(root: Span) -> list[Span]: out: list[Span] = [] stack = [c for c in _children(root)] while stack: s = stack.pop() if s.kind == "model" and s.status in ("OK", "UNSET"): out.append(s) stack.extend(_children(s)) return out _ALL: list[Span] = [] def _children(span: Span) -> list[Span]: return [s for s in _ALL if s.parent is span] def reconcile_agent_usage(agent: Span, threshold: float = 0.01) -> None: if agent.kind != "agent": raise UsageRollupDivergence(f"span {agent.name!r} is not an agent span") models = _descendant_model_spans(agent) subtree_in = sum(s.input_tokens for s in models) subtree_out = sum(s.output_tokens for s in models) agent_in = agent.input_tokens agent_out = agent.output_tokens def rel(a: int, b: int) -> float: return abs(a - b) / max(b, 1) divergence = max(rel(agent_in, subtree_in), rel(agent_out, subtree_out)) if divergence <= threshold: return # Divergence is only actionable when it is unexplained by retries and # involves non-trivial nesting (depth > 1), per the decision boundary. has_retry_evidence = any(s.retry_hint for s in models) nested = any(s.kind == "tool" for s in _descendants(agent)) if has_retry_evidence or not nested: return raise UsageRollupDivergence( f"agent {agent.name!r}: rolled-up in/out=({agent_in},{agent_out}) " f"vs subtree=({subtree_in},{subtree_out}), divergence={divergence:.3f} " f"over {len(models)} model span(s) with no retry evidence" ) def _descendants(root: Span) -> list[Span]: out, stack = [], list(_children(root)) while stack: s = stack.pop() out.append(s) stack.extend(_children(s)) return out ```