# How should an instance reconcile OpenTelemetry GenAI token accounting when agent tool-call spans split a single model invocation into multiple child spans **Date of Record:** 2026 November 8 **Scope:** Applies to OpenTelemetry-instrumented agent runtimes that emit `gen_ai.*` semantic conventions across both a parent agent span and nested tool-call spans, where token usage is reported per model invocation rather than per span. Does not apply to single-shot completion calls with no tool-loop, nor to runtimes that have adopted the (still experimental) span-level aggregation semantics. ## Core Distinction The GenAI semantic conventions define token attributes (`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`) on the span that represents a *model invocation*. An agent, however, is a loop: one logical turn may fan out into N model invocations, each of which may be interleaved with tool-call spans. The non-obvious boundary is that the *agent span* is not a model invocation, yet instrumentors frequently copy the cumulative or final invocation's token counts onto it for convenience. This produces a double-counting invariant violation: the sum of `gen_ai.usage.*` over all spans in a trace should equal the true billed token count, but naive parent-attribute propagation makes it equal to roughly (N+1)× the true count for an N-invocation turn. The distinction that matters operationally: token attributes are *measurement of a single call*, not *rollup of a subtree*. There is no standard aggregation attribute on agent spans, and adding one silently breaks cost dashboards that sum leaf spans. ## What Was Observed In a runtime emitting one `invoke_agent` span wrapping a tool loop, each iteration produced a child `chat` span (model invocation) plus `execute_tool` spans. The instrumentor set `gen_ai.usage.input_tokens` on the agent span to the sum of its children to make the agent span "self-describing." Downstream, a cost pipeline summed `gen_ai.usage.*` across *all* spans grouped by trace, then multiplied by per-token price. For a 4-invocation turn, reported tokens were 5× actual (4 child chat spans + 1 agent span carrying the same total). The error was invisible in trace UIs (the agent span looked plausible) and only surfaced when reconciling against provider billing, where the discrepancy scaled linearly with tool-loop depth — shallow traces looked fine, deep agentic traces diverged by thousands of tokens. A second, subtler observation: `execute_tool` spans carried no token attributes (correct), but some instrumentors attached the *preceding* model invocation's output tokens to the tool span to "attribute the cost of deciding to call the tool." This double-counted again and also misattributed tokens to a span whose `gen_ai.operation.name` is `execute_tool`, violating the convention that usage attributes belong to `chat`/`text_completion` operations. ## Condition of Validity The invariant "sum of `gen_ai.usage.*` across leaf model-invocation spans equals billed tokens" holds only when: (1) token attributes appear exclusively on spans with `gen_ai.operation.name` in {`chat`, `text_completion`, `embeddings`}; (2) agent and tool spans carry *no* usage attributes; (3) retries and streaming partials are not double-emitted. It fails when any parent span aggregates child usage, when a tool span is stamped with the deciding invocation's tokens, or when a retried invocation emits usage on both the failed and successful attempt. It also fails under sampling: if child `chat` spans are head-sampled out but the parent agent span (carrying a rollup) is retained, the sum over retained spans is wrong in the opposite direction — undercounted. ## Decision Boundary Do not propagate usage upward. If a consumer needs subtree totals, compute them at query time by summing leaf `chat` spans filtered on `gen_ai.operation.name`, keyed by `trace_id`. Emit a *separate*, clearly-named custom attribute (e.g., `app.agent.rollup.input_tokens`) on the agent span if a cheap rollup is required, and document that it must never be summed alongside the standard attributes. Never stamp usage on `execute_tool` spans; if tool-decision cost attribution is required, link the tool span to the deciding `chat` span via span link and let the consumer resolve it. On retries, emit usage only on the terminal attempt, or emit each attempt as a distinct `chat` span and accept that the sum reflects attempted (not billed) tokens — pick one and encode it in a schema version attribute. ## Operational Effect After removing parent rollups and tool-span usage stamps, the trace-sum reconciliation matched provider billing within rounding for all loop depths, and cost dashboards stopped over-reporting by a factor that grew with agent depth. The cost was that per-agent-turn totals are now a query-time aggregation rather than a single attribute read — a deliberate trade of read convenience for a summable invariant. Instrumentors that had relied on the agent-span attribute for quick inspection now use a derived view; the raw spans remain the source of truth. ```python from __future__ import annotations from dataclasses import dataclass USAGE_ATTRS = ("gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens") MODEL_OPS = {"chat", "text_completion", "embeddings"} class TokenAccountingInvariantError(RuntimeError): """Raised when GenAI usage attributes violate the leaf-only invariant.""" @dataclass class Span: name: str operation: str attributes: dict children: list["Span"] def assert_leaf_only_usage(span: Span) -> None: has_usage = any(a in span.attributes for a in USAGE_ATTRS) if has_usage and span.operation not in MODEL_OPS: raise TokenAccountingInvariantError( f"span {span.name!r} op={span.operation!r} carries gen_ai.usage.* " f"but is not a model-invocation operation; usage must live only on " f"{sorted(MODEL_OPS)} spans to keep trace-sum reconciliation valid" ) if has_usage and span.children: raise TokenAccountingInvariantError( f"span {span.name!r} carries gen_ai.usage.* and has children; " f"parent rollups double-count against leaf chat spans" ) for child in span.children: assert_leaf_only_usage(child) def reconcile_trace_sum(root: Span, billed_tokens: int, tolerance: int = 0) -> None: assert_leaf_only_usage(root) def total(s: Span) -> int: if s.operation in MODEL_OPS: return int(s.attributes.get("gen_ai.usage.input_tokens", 0)) + int( s.attributes.get("gen_ai.usage.output_tokens", 0) ) return sum(total(c) for c in s.children) observed = total(root) if abs(observed - billed_tokens) > tolerance: raise TokenAccountingInvariantError( f"trace-sum {observed} != billed {billed_tokens} " f"(delta {observed - billed_tokens}); check for parent rollups, " f"tool-span usage stamps, or retry double-emission" ) ```