# What happens when an instance double-counts tool-call tokens across nested agent spans **Date of Record:** 2026 November 10 **Scope:** Applies to OpenTelemetry traces in which a GenAI agent span emits `gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens` while its child tool-execution spans (or nested agent spans) also emit usage attributes that are subsequently aggregated by a collector, backend rollup, or cost pipeline. Does not apply to flat, non-nested spans where each span owns a disjoint token budget, nor to spans that emit usage only at the root. ## Core Distinction The GenAI semantic conventions define token usage as an attribute of the span that *caused* the model invocation — `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` on the span whose `gen_ai.operation.name` is `chat` (or `text_completion`). They do **not** define a rule for composing usage across a span tree. This is the non-obvious boundary: a parent agent span that wraps a reasoning loop and a child tool span that internally calls a model both legitimately carry usage attributes, and both are correct in isolation. The failure is not in emission but in *aggregation* — any consumer that sums `gen_ai.usage.*` over all spans in a trace will double-count the child's tokens inside the parent's total, because the parent's usage already includes the child's model calls when the agent loop is synchronous. The distinction that matters operationally: **usage attributes are per-span facts, not per-tree accounting.** There is no `gen_ai.usage.exclusive` flag, no parent-reference attribute, and no convention that says the parent must subtract child usage. The spec is silent, and silence is where the invariant breaks. ## What Was Observed A retrieval agent was instrumented with a root `invoke_agent` span, a child `chat` span for the planning call, and a child `execute_tool` span for a `search` tool that itself issued an internal `chat` call to summarize results. All three spans emitted usage attributes. The observed trace: - root `invoke_agent`: `input_tokens=1840`, `output_tokens=310` - child `chat` (planning): `input_tokens=1200`, `output_tokens=180` - child `execute_tool` (search): `input_tokens=0`, `output_tokens=0` (tool wrapper emits none) - grandchild `chat` (summarize, under the tool span): `input_tokens=640`, `output_tokens=130` The agent's own accounting (what it reported to the user) was 1840/310 — correct, because the loop summed its own model calls. The collector's rollup, which summed every span carrying `gen_ai.usage.*`, reported 3680/620. The discrepancy is exactly the sum of all descendant usage: 1200+640 and 180+130. Note the tool wrapper emitted zeros rather than nothing — a second, subtler bug: `0` is not the same as absent, and a naive `sum()` treats it as a real data point while a `count()` treats it as a span that "has usage." Two further observations from the same incident: (1) the backend's cost dashboard was 2× the invoice, and the discrepancy was attributed to "sampling" for three days before the span-tree shape was inspected; (2) when the agent was later refactored to async fan-out, the parent stopped including child usage and the *same* rollup became correct — the aggregation code did not change, only the emission semantics of the parent did. The rollup was never right; it was accidentally right for one topology. ## Condition of Validity The double-count is only a defect under a specific conjunction: 1. A span and at least one of its descendants both emit `gen_ai.usage.*`, **and** 2. the ancestor's usage value is *inclusive* of the descendant's model calls (true for synchronous agent loops, false for fire-and-forget fan-out where the parent returns before children complete), **and** 3. the consumer aggregates by summing over all spans rather than selecting a designated accounting span. Remove any one and the arithmetic is fine. This is why the bug survives code review: in the fan-out topology (condition 2 false) the summing rollup is correct, and in the single-span topology (condition 1 false) it is trivially correct. Only the synchronous nested topology — the most common agent shape — violates it. The condition is also version-sensitive: the conventions are still stabilizing, and a future `gen_ai.usage.*` composition rule would invalidate this record. ## Decision Boundary Choose the accounting strategy before instrumenting, not after: - **Root-only accounting (recommended for cost):** emit `gen_ai.usage.*` only on the span designated as the trace's accounting root; children emit model-call spans with *no* usage attributes, or with usage attributes and an explicit `gen_ai.usage.scope=exclusive` custom attribute. Aggregation selects spans where scope is absent or `inclusive`. - **Leaf-only accounting (recommended for latency attribution):** emit usage only on `chat` spans; the agent's total is the sum of leaf `chat` spans. This is robust to topology changes but loses the "what did the agent as a whole cost" number when a tool calls out to an external billed service that does not emit OTel spans. - **Never:** sum over all spans. If a backend does this by default, disable the rollup or add a scope attribute and filter. The boundary test: *does any span in the tree have a descendant that also emits usage?* If yes, summing is wrong. Detect this at instrumentation time by walking the span tree and asserting no span both emits usage and has a usage-emitting descendant unless it is marked inclusive. ## Operational Effect The practical consequence is that token accounting and cost attribution become topology-dependent and silently drift as agents are refactored. A synchronous loop refactored to async fan-out halves the reported cost without any change in actual spend; a fan-out refactored to sequential doubles it. Dashboards built on summed usage are not measuring spend — they are measuring span nesting depth. The fix is a scope attribute plus a collector-side filter, and a startup assertion that rejects traces where inclusive and exclusive usage coexist unmarked. Enforce it at emit time; by the time the number reaches a dashboard it is indistinguishable from a real regression. ```python from __future__ import annotations from dataclasses import dataclass class TokenAccountingError(RuntimeError): """Raised when a span tree violates the usage-composition invariant.""" @dataclass(frozen=True) class Span: span_id: str parent_id: str | None name: str input_tokens: int | None # None means attribute absent output_tokens: int | None usage_scope: str | None # None, "inclusive", or "exclusive" def validate_usage_tree(spans: list[Span]) -> None: """Reject trees where an inclusive span has a usage-emitting descendant. A span that emits usage and has a descendant that also emits usage is only safe if the ancestor is explicitly marked inclusive AND the descendant is marked exclusive, so a summing rollup can filter on scope. """ by_id = {s.span_id: s for s in spans} children: dict[str, list[Span]] = {} for s in spans: if s.parent_id is not None: children.setdefault(s.parent_id, []).append(s) def emits_usage(s: Span) -> bool: return s.input_tokens is not None or s.output_tokens is not None def descendants(s: Span): for c in children.get(s.span_id, []): yield c yield from descendants(c) for s in spans: if not emits_usage(s): continue for d in descendants(s): if not emits_usage(d): continue if s.usage_scope != "inclusive": raise TokenAccountingError( f"span {s.span_id!r} ({s.name}) emits usage and has " f"usage-emitting descendant {d.span_id!r} ({d.name}) " f"but is not marked usage_scope='inclusive'; a summing " f"rollup will double-count {d.input_tokens}/{d.output_tokens}" ) if d.usage_scope != "exclusive": raise TokenAccountingError( f"descendant {d.span_id!r} of inclusive span {s.span_id!r} " f"must be marked usage_scope='exclusive' to be filtered" ) def rollup_tokens(spans: list[Span]) -> tuple[int, int]: """Sum usage over spans that a rollup should count: not exclusive children.""" validate_usage_tree(spans) tin = tout = 0 for s in spans: if s.usage_scope == "exclusive": continue tin += s.input_tokens or 0 tout += s.output_tokens or 0 return tin, tout ```