# Which invariants prevent double-counting of tokens when an agent span and its child tool-call spans both report usage **Date of Record:** 2026 November 7 **Scope:** Applies to OpenTelemetry GenAI semantic-convention instrumentation of agentic loops where a parent `invoke_agent` span and one or more child tool-call spans (`execute_tool`) each emit `genai.usage.input_tokens` / `genai.usage.output_tokens`. Does not apply to single-shot chat spans with no nested tool calls, nor to spans that only emit `genai.usage.*` on a single leaf span. ## Core Distinction The GenAI semantic conventions define usage attributes as *per-operation* measurements, not *cumulative subtree* measurements. A tool-call span that performs an LLM round-trip (the common "tool returns, model re-plans" pattern) legitimately reports its own `genai.usage.input_tokens`. The parent agent span, if instrumented naively, reports the *sum of all model invocations in the agent loop*, which already includes the child's tokens. The non-obvious boundary: **the parent's usage attribute is a total, the child's is a component, and the semantic conventions do not encode which is which.** Both carry the same attribute key with the same unit. A naive collector that sums `genai.usage.input_tokens` across all spans in a trace double-counts every tool-mediated turn. ## What Was Observed In a trace from a three-turn agent (plan → tool call → synthesize), the instrumentation emitted: - `invoke_agent` (root): `genai.usage.input_tokens=4820`, `genai.usage.output_tokens=610` - `execute_tool` (child, first turn): `genai.usage.input_tokens=1440`, `genai.usage.output_tokens=180` - `execute_tool` (child, second turn): `genai.usage.input_tokens=1600`, `genai.usage.output_tokens=210` A backend that aggregates by summing all spans reported 7860 input tokens. The true billed input was 4820. The overcount was 3040 — exactly the two child spans. The failure is silent: no span is malformed, no attribute is out of range, and the trace looks internally consistent. The only signal is that the sum exceeds any single span's value, which is normal for legitimate multi-span traces in other domains. A second, subtler variant: some SDKs emit usage on *both* the agent span and an intermediate "chat completion" span nested inside the tool call, producing a three-level nesting where the middle level is the true per-call measurement and the outer is the rollup. Summing at any level below the root is correct; summing across all levels is wrong by a factor equal to nesting depth. ## Condition of Validity The invariant "sum usage across leaf spans only" holds if and only if: 1. Every `genai.usage.*` attribute on a non-leaf span is a strict superset (rollup) of its descendants' usage, and 2. No span reports usage for work performed outside its own subtree. Condition 1 fails when instrumentation attaches a *delta* (this-turn-only) usage to the parent rather than a rollup — a common bug when the agent loop is instrumented by wrapping each `chat.completions.create` call and separately wrapping the loop. Condition 2 fails when a tool span reports the tokens of the *next* model call it triggered (look-ahead attribution), which some frameworks do to attribute cost to the tool that caused it. In both failure modes, leaf-only summation undercounts. There is no attribute in the current conventions that distinguishes rollup from delta from look-ahead. ## Decision Boundary Choose leaf-only summation when: the instrumentation is known to emit rollups on agent spans (check by asserting parent ≥ sum of children for every trace during a canary window), and the agent loop has no look-ahead attribution. Choose root-only measurement when: exactly one root `invoke_agent` span exists per logical operation and the backend bills per operation rather than per span. Never choose all-span summation. If neither leaf-only nor root-only is verifiable from the instrumentation (e.g., third-party SDK of unknown behavior), fall back to `max(leaf_sum, root_value)` as a lower-bound estimate and flag the trace as `usage_attribution_unverified`. The decision boundary is not "which number is right" but "which number can be proven not to double-count," and the proof requires a structural assertion over the span tree, not a heuristic over attribute values. ## Operational Effect Teams that ship all-span summation see token dashboards inflated by 40–160% on tool-heavy agents, while cost dashboards (fed by provider billing APIs) stay correct. The divergence is attributed to "OTel is wrong" rather than to the aggregation rule, and the fix is delayed because the trace itself looks fine. The durable remedy is a validation gate at ingest: for every `invoke_agent` span, assert `parent.usage.input_tokens >= sum(child.usage.input_tokens)` and reject or quarantine traces that violate it, because a violation means the instrumentation is emitting deltas and leaf-summation will now undercount. The gate converts a silent accounting error into a loud, attributable one. ```python class UsageAttributionError(RuntimeError): """Raised when a span tree's usage attributes cannot be safely aggregated.""" def aggregate_input_tokens(spans: list[dict]) -> int: """Sum input tokens across a trace using leaf-only attribution. Each span is a dict with keys: span_id, parent_id, genai.usage.input_tokens. Invariant enforced: every non-leaf span's usage must be >= the sum of its direct children's usage. Violation means the parent is emitting a delta or look-ahead value, and leaf-summation would undercount. """ by_parent: dict[str | None, list[dict]] = {} for s in spans: by_parent.setdefault(s["parent_id"], []).append(s) for parent_id, children in by_parent.items(): if parent_id is None: continue parent = next((s for s in spans if s["span_id"] == parent_id), None) if parent is None: raise UsageAttributionError( f"orphan span {children[0]['span_id']} references missing parent {parent_id}" ) child_sum = sum(c["genai.usage.input_tokens"] for c in children) if parent["genai.usage.input_tokens"] < child_sum: raise UsageAttributionError( f"parent {parent_id} reports {parent['genai.usage.input_tokens']} " f"input tokens but children sum to {child_sum}; " "parent is emitting a delta, not a rollup — leaf summation unsafe" ) leaves = [s for s in spans if s["span_id"] not in by_parent] return sum(s["genai.usage.input_tokens"] for s in leaves) ```