# What structural signals indicate token-accounting divergence between agent spans and their tool-call children in OpenTelemetry GenAI traces **Date of Record:** 2026 November 7 **Scope:** Applies to OpenTelemetry GenAI semantic conventions (`gen_ai.*`) as emitted for agent and tool-call spans, specifically the propagation of token usage attributes across parent/child span relationships in multi-agent and tool-invoking agent systems. Does not apply to single-shot LLM inference spans with no child tool-call spans, nor to non-GenAI spans. ## Core Distinction The GenAI semantic conventions define token accounting attributes — `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` — as properties of a *model invocation*, not of an *agent turn*. An agent span (`gen_ai.operation.name = "invoke_agent"` or equivalent) is a container; the tokens it "consumed" are the sum of the model invocations it caused. The non-obvious boundary is this: **a tool-call span is not a model invocation, but the agent's orchestration of a tool call frequently triggers a *second* model invocation whose span may or may not be parented correctly.** When token attributes are aggregated naively by summing across all descendant spans, systems double-count whenever the tool-call child span carries its own `gen_ai.usage.*` attributes copied from the orchestration model call, while the agent span *also* reports the aggregate. There is no convention-level rule forbidding tool spans from carrying usage attributes, and many instrumentations populate them "for convenience." The structural signal of divergence is therefore not the presence of tokens on a tool span — it is the *coexistence* of nonzero `gen_ai.usage.*` on a `gen_ai.operation.name = "execute_tool"` span together with a parent agent span whose usage is already an aggregate over the same invocation. ## What Was Observed In a multi-agent trace where a planner agent invokes a search tool, the following span tree was emitted: - `invoke_agent` (planner), `gen_ai.usage.input_tokens = 1840`, `output_tokens = 212` - └─ `execute_tool` (search), `gen_ai.usage.input_tokens = 1840`, `output_tokens = 212` (copied) - └─ `chat` (model call that decided to call the tool), `gen_ai.usage.input_tokens = 1840`, `output_tokens = 212` The three spans are in a single ancestry chain, and each carries the same usage numbers. A naive rollup that sums `gen_ai.usage.input_tokens` over all spans in the trace reports 5520 input tokens for what was a single 1840-token invocation. The signal is structural: identical usage triples appearing on a `chat` span and on its `execute_tool` sibling/uncle *within the same ancestry path*, with the agent ancestor reporting a value equal to (not greater than) the leaf. Genuine aggregation produces a strictly greater parent value; copied attributes produce equality. A second observed pattern: in a two-agent handoff (planner → executor), the executor's `invoke_agent` span was parented to the *tool-call span* that triggered the handoff rather than to the planner's agent span. This is a valid span link per the conventions (the handoff is a causal, not hierarchical, relationship), but it means the executor's tokens are *not* descendants of the planner's agent span. Rollups keyed on `parent_span_id` ancestry silently drop the executor's usage entirely. The signal is a `gen_ai.agent.name` change across a `link` boundary with no corresponding ancestry edge. ## Condition of Validity The divergence diagnosis holds only when all of the following are true: 1. The instrumentation emits `gen_ai.operation.name` on every span, distinguishing `chat` (model invocation) from `execute_tool` from `invoke_agent`. Without this discriminator, usage attributes cannot be attributed to a span *kind*. 2. Usage attributes are reported per-span, not only on the root. If the SDK emits usage exclusively on the root agent span, no double-count is possible and this analysis is moot. 3. The trace is complete (no sampling drop of intermediate spans). A dropped `chat` span makes the tool span's copied usage look like the *only* record of the invocation, and the "fix" of deduplicating by equality would then under-count. It does **not** hold when usage attributes are legitimately reported on a tool span because the tool *itself* invokes a model (e.g., an LLM-based reranker inside a retrieval tool). In that case the tool span's usage is real and additive, not copied, and equality with the parent is coincidental. The discriminator is whether the tool span has a `chat` descendant: if the tool span is a *leaf* with usage and no model-call child, the usage is either copied or belongs to an un-instrumented model call — both require a different reconciliation. ## Decision Boundary The operational decision is whether to sum token usage over all spans or to sum only over `chat` spans (and, when absent, over agent spans as a fallback). The boundary condition: - **Sum over `chat` spans only** when `gen_ai.operation.name` is reliably present and every model invocation is instrumented. This is correct and avoids double-count. - **Sum over `invoke_agent` spans only** when child model spans are not instrumented (common in early SDK adoption). Correct, but loses per-invocation granularity. - **Never sum over all spans** — this is the failure mode. The equality signal (`tool.usage == parent.usage`) is the tell that a given trace is in the double-count regime. For cross-agent propagation, the boundary is: **ancestry is not causality.** A handoff via span link means a `parent_span_id`-keyed rollup is wrong by construction. The correct key is the trace-level `gen_ai.conversation.id` (or equivalent session attribute) when present; otherwise the link graph must be walked, not the parent chain. ## Operational Effect Teams that aggregate by naive span-sum over-report token spend by a factor equal to the average number of tool calls per agent turn (commonly 1.5–3× in retrieval-heavy agents), and this error is *invisible* in per-trace dashboards because it scales uniformly. The failure surfaces only when reconciling against provider billing, at which point the mismatch is attributed to "sampling" or "retries" rather than to span-tree double-counting. The fix is a validation gate at trace-ingest time: detect the equality signature and either deduplicate or reject the trace as ambiguous, rather than silently summing. ```python from dataclasses import dataclass from typing import Iterable class TokenAccountingAmbiguity(RuntimeError): """Raised when a trace's span tree cannot be unambiguously token-accounted.""" pass @dataclass class Span: span_id: str parent_span_id: str | None operation: str # "chat" | "execute_tool" | "invoke_agent" input_tokens: int = 0 output_tokens: int = 0 def reconcile_tokens(spans: Iterable[Span]) -> int: spans = list(spans) by_id = {s.span_id: s for s in spans} # Detect copied-usage signature: a tool span whose usage equals its # nearest chat ancestor's usage. That trace is in the double-count regime # and must not be summed over all spans. for s in spans: if s.operation != "execute_tool" or s.input_tokens == 0: continue anc = by_id.get(s.parent_span_id) if s.parent_span_id else None while anc is not None: if anc.operation == "chat" and ( anc.input_tokens == s.input_tokens and anc.output_tokens == s.output_tokens ): raise TokenAccountingAmbiguity( f"tool span {s.span_id} duplicates chat ancestor " f"{anc.span_id} usage ({s.input_tokens}/{s.output_tokens}); " "summing all spans would double-count" ) anc = by_id.get(anc.parent_span_id) if anc.parent_span_id else None # Safe path: sum only model-invocation spans. chats = [s for s in spans if s.operation == "chat"] if not chats: raise TokenAccountingAmbiguity( "no chat spans present; cannot distinguish copied tool usage " "from genuine un-instrumented model calls" ) return sum(s.input_tokens + s.output_tokens for s in chats) ```