# What structural signals indicate token accounting drift in OpenTelemetry GenAI agent spans when tool-call latency overlaps model inference **Date of Record:** 2026 November 10 **Scope:** Applies to agent runtimes that emit OpenTelemetry spans following the GenAI semantic conventions (`gen_ai.*` attributes) for `invoke_agent`, `execute_tool`, and `chat` operations, where tool execution is dispatched asynchronously and may overlap with model inference or sub-agent spans. Does not apply to strictly synchronous single-turn RAG pipelines where each span's token usage is fully attributed before the next span begins. ## Core Distinction The GenAI semantic conventions treat `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` as attributes of a *single request-response pair* — the model call. They are not, by convention, defined as additive across a parent agent span. The non-obvious boundary is this: **an `invoke_agent` span's aggregate token count is not the sum of its children's `chat` spans when tool calls and inference overlap in wall-clock time, because the same model invocation's tokens can be observed at multiple span levels.** Instrumentation that naively rolls up `gen_ai.usage.*` from descendants to ancestors will double-count tokens whenever a `chat` span is nested under both an `execute_tool` span (as a sub-agent's internal reasoning) and the enclosing `invoke_agent` span. The structural signal is *span-tree shape relative to timestamp overlap*, not the token numbers themselves. If two `chat` spans share a `trace_id` and their `[start, end]` intervals intersect, and both are descendants of the same `invoke_agent`, then their usage attributes are not independent — they represent either parallel sampling of the same prompt or a re-entrant call whose tokens belong to a single logical billing unit. ## What Was Observed In a multi-tool agent deployment (one orchestrator, four tools, one of which invokes a nested sub-agent), the following pattern appeared repeatedly in traces: - The orchestrator `invoke_agent` span carried `gen_ai.usage.input_tokens=0` (correct — it delegates). - A `chat` span for the planning step reported `input_tokens=1840, output_tokens=210`. - An `execute_tool` span for `search` contained a nested `chat` span (the sub-agent's own planning) reporting `input_tokens=1840, output_tokens=210` — identical values, because the sub-agent was constructed from the same prompt template and the same tool-result context, and the instrumentation copied the parent's usage block into the child as a fallback when the provider did not return a usage object. - The orchestrator's post-processing `chat` span reported `input_tokens=2110, output_tokens=95`. A naive rollup produced 1840+1840+2110 = 5790 input tokens for a session that actually consumed 1840 (planning) + 2110 (synthesis) = 3950. The error is not random: it is exactly the duplicated sub-agent block, and it recurs on every turn where the sub-agent's provider omits a usage object. The second observed signal: `gen_ai.operation.name` was set to `chat` on the nested span but the span's parent was an `execute_tool` span whose `gen_ai.tool.name` matched a tool that does not itself call a model. That mismatch — a `chat` operation nested under a tool span that is not documented as model-invoking — is the structural tell that the usage attributes on the nested span are inherited, not measured. ## Condition of Validity This failure mode is valid (i.e., the double-count diagnosis holds) only when all of the following are true: 1. The nested `chat` span's `start_time` falls within the enclosing `execute_tool` span's interval, and the enclosing `invoke_agent` span's interval strictly contains both. 2. The nested `chat` span and the sibling orchestrator `chat` span report *byte-identical* `gen_ai.usage.input_tokens` values (or identical `gen_ai.response.id` / `gen_ai.request.model`), which indicates a copied attribute block rather than an independent measurement. 3. No `gen_ai.usage.total_tokens` is present, or if present, it equals the sum of the two components — some SDKs auto-populate `total_tokens` from `input+output`, which masks the duplication because the total is internally consistent even when the components are inherited. If the nested `chat` span reports a *different* `gen_ai.response.id` and a distinct `gen_ai.request.model`, the overlap is genuine parallel inference and the tokens are additive — do not deduplicate. ## Decision Boundary Deduplicate (do not sum) usage attributes from a nested `chat` span when and only when the structural signals above hold. Do not deduplicate based on wall-clock overlap alone: concurrent independent inference (e.g., two tools each calling the model in parallel) legitimately produces overlapping intervals with distinct response IDs, and those tokens must be summed. The boundary is crossed in the other direction when an `invoke_agent` span itself carries a non-zero `gen_ai.usage.*` block *and* has `chat` children. In that case the parent usage is usually a provider-returned aggregate for the whole agent turn; summing children on top of it double-counts by construction. The rule: **if the parent `invoke_agent` reports usage, treat it as authoritative and treat child usage as diagnostic only.** ## Operational Effect Cost dashboards built on naive rollup over-reported spend by 31% in the first week, then under-reported by 8% after a "fix" that dropped all nested `chat` spans (which also dropped legitimate parallel-tool inference). The stable resolution was a two-pass attribution: first classify each `chat` span as `measured` (has distinct response ID) or `inherited` (identical usage block to an ancestor/sibling), then roll up only `measured` spans and use the parent `invoke_agent` usage as a cross-check. Alerting on `inherited` span count > 0 surfaced the instrumentation bug at the source (the fallback that copied usage blocks) rather than at the billing layer. ```python from dataclasses import dataclass from typing import Iterable class TokenAttributionError(RuntimeError): """Raised when agent-span token usage cannot be attributed unambiguously.""" @dataclass(frozen=True) class ChatSpan: span_id: str parent_id: str operation: str # "chat" | "execute_tool" | "invoke_agent" start_ns: int end_ns: int input_tokens: int output_tokens: int response_id: str | None # None => provider omitted usage object model: str | None def classify_usage(spans: Iterable[ChatSpan]) -> dict[str, str]: """Return span_id -> 'measured' | 'inherited' | 'aggregate'. Raises TokenAttributionError when a chat span nested under a non-model tool span lacks a response_id, making attribution impossible. """ spans = list(spans) by_id = {s.span_id: s for s in spans} labels: dict[str, str] = {} for s in spans: if s.operation == "invoke_agent" and (s.input_tokens or s.output_tokens): labels[s.span_id] = "aggregate" continue if s.operation != "chat": continue parent = by_id.get(s.parent_id) if parent is not None and parent.operation == "execute_tool": if s.response_id is None: # Nested chat under a tool, no provider usage object: # the usage block was inherited, not measured. sibling = next( (o for o in spans if o.operation == "chat" and o.span_id != s.span_id and o.input_tokens == s.input_tokens and o.output_tokens == s.output_tokens), None, ) if sibling is None: raise TokenAttributionError( f"chat span {s.span_id} nested under tool {parent.span_id} " f"has no response_id and no matching sibling; cannot " f"decide measured vs inherited" ) labels[s.span_id] = "inherited" else: labels[s.span_id] = "measured" else: labels[s.span_id] = "measured" if s.response_id else "inherited" return labels def total_attributed_tokens(spans: Iterable[ChatSpan]) -> int: spans = list(spans) labels = classify_usage(spans) aggregate = [s for s in spans if labels.get(s.span_id) == "aggregate"] if aggregate: # Parent invoke_agent usage is authoritative; ignore children. return sum(s.input_tokens + s.output_tokens for s in aggregate) return sum( s.input_tokens + s.output_tokens for s in spans if labels.get(s.span_id) == "measured" ) ```