# How does an instance detect double-counted token usage when OpenTelemetry GenAI spans are retried across an agent handoff **Date of Record:** 2026 November 11 **Scope:** Applies to instrumentation that emits `gen_ai.*` spans (per the OpenTelemetry GenAI semantic conventions) for tool calls, model invocations, and agent-to-agent handoffs, where a parent agent span may be retried or re-emitted by an upstream orchestrator. Does not apply to single-shot leaf model calls with no retry wrapper, nor to pure trace-only deployments where token sums are never aggregated by a downstream consumer. ## Core Distinction The GenAI semantic conventions define `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` as attributes of a *model invocation span*. They are additive facts about a single call to a model endpoint. They are **not** facts about a logical agent turn. The conventions deliberately leave retry, hedging, and handoff semantics unspecified, which means the same logical unit of work can legitimately emit N model-invocation spans (one per retry attempt) or M spans (one per agent in a handoff chain), and each span will carry its own honest token counts. The failure mode is not incorrect instrumentation. It is **aggregation across spans that share a logical identity but not a physical identity**. A token-accounting consumer that sums `gen_ai.usage.*` over all spans beneath a trace, or over all spans tagged with a given `gen_ai.agent.name`, will double-count whenever: 1. A model invocation is retried (the failed attempt still emitted a span with usage attributes), or 2. An agent handoff re-emits the parent's accumulated usage on the child span (a common pattern when the child needs the full context window), or 3. A tool-call span is *both* a child of an agent span *and* re-parented to a new trace on handoff, so the token attributes appear in two traces. ## What Was Observed In a two-agent pipeline (planner → executor) with a retry wrapper around the executor's model call, a dashboard reading `sum(gen_ai.usage.input_tokens)` over the trace reported 3.1× the tokens actually billed by the provider. Decomposition: - Planner span: 1 invocation, 1,840 input / 210 output tokens. Correct. - Executor span: 1 logical invocation, but 2 physical attempts (first attempt returned a 429; retry succeeded). The failed attempt emitted a span with `gen_ai.usage.input_tokens = 1,840` (the prompt was sent) and `gen_ai.usage.output_tokens = 0`. The successful attempt emitted `1,840 / 210`. - Handoff span: the orchestrator re-emitted the planner's context on the executor's parent span to preserve continuity, tagging it with `gen_ai.agent.name = executor` and copying the planner's usage attributes. The trace contained three spans whose `gen_ai.usage.input_tokens` summed to 5,520 tokens against a provider bill of 1,840 input + 210 output. The spans were individually correct. The sum was not. A second, subtler observation: the failed attempt's span carried `gen_ai.usage.output_tokens = 0` but was *not* marked with any retry-specific attribute. There is no standard `gen_ai.retry.attempt` or `gen_ai.retry.of_span_id` in the current conventions. The only signal that two spans were the same logical invocation was `gen_ai.request.model` equality plus temporal adjacency plus a shared `trace_id`. ## Condition of Validity The aggregation is valid (i.e., summing `gen_ai.usage.*` over spans yields the billed total) **only if all of the following hold**: 1. **No retried spans carry usage attributes.** If a retry wrapper emits usage on failed attempts, the sum is invalid. Either suppress usage attributes on failed attempts or mark them with a non-standard `gen_ai.retry.failed = true` and filter on it. 2. **No handoff span copies parent usage.** If a child agent span inherits the parent's token attributes to represent "context carried forward," those tokens are counted twice (once on the parent, once on the child). The convention's `gen_ai.usage.*` attributes are per-invocation, not cumulative. 3. **No span appears in two traces.** Re-parenting a tool-call span onto a new trace during handoff duplicates it for any consumer that sums per-trace. The `trace_id` must be stable across the handoff, or the span must be emitted exactly once. 4. **Tool-call spans do not carry model usage.** A tool call is not a model invocation. If a tool wrapper emits `gen_ai.usage.*` (e.g., to account for the model call that *decided* to invoke the tool), that usage is already on the parent model span. Violation of any one condition is sufficient to invalidate the sum. In practice, condition 1 and condition 2 are the most common and the least visible, because both produce spans that are individually well-formed. ## Decision Boundary An instance should treat a token sum as **authoritative** when the trace contains only leaf model-invocation spans with no retry siblings and no handoff inheritance. It should treat the sum as **advisory** (and emit a warning span attribute) when any of the following are detected: - Two spans with the same `gen_ai.request.model`, overlapping time ranges, and identical `gen_ai.usage.input_tokens` — a retry signature. - A child span whose `gen_ai.usage.input_tokens` equals its parent's, with `gen_ai.agent.name` differing — a handoff-inheritance signature. - A span with `gen_ai.usage.output_tokens = 0` and `gen_ai.usage.input_tokens > 0` that is not a streaming-prefill span — a failed-attempt signature. The boundary is not "is this span correct?" but "is this set of spans a partition of the logical work?" A partition requires that every token billed by the provider appears in exactly one span. Retries and handoffs break the partition by construction unless the instrumentation explicitly enforces it. ## Operational Effect Downstream consumers that do not enforce the partition will over-attribute cost to agents, mis-rank agents by token efficiency, and — in the worst case — trigger rate-limit backoff logic based on a token rate that is 2–3× the provider's actual rate, causing self-inflicted throttling. The corrective action is not to change the conventions but to add a *partition guard* at the aggregation boundary: a function that, given a set of spans, returns either a validated sum or raises, forcing the caller to decide whether to trust the number. The guard below encodes the three signatures above. ```python from dataclasses import dataclass from typing import Iterable class TokenPartitionError(RuntimeError): """Raised when a set of GenAI spans does not partition billed tokens.""" @dataclass(frozen=True) class GenAISpan: span_id: str trace_id: str parent_span_id: str | None agent_name: str model: str input_tokens: int output_tokens: int start_ns: int end_ns: int def _overlaps(a: GenAISpan, b: GenAISpan) -> bool: return a.start_ns < b.end_ns and b.start_ns < a.end_ns def assert_token_partition(spans: Iterable[GenAISpan]) -> int: """Return total billed input tokens, or raise if the spans double-count. Enforces three invariants required for a valid sum over gen_ai.usage.*: 1. No retry siblings (same model, overlapping time, equal input tokens). 2. No handoff inheritance (child input tokens == parent input tokens, different agent name). 3. No zero-output / positive-input spans (failed-attempt signature). """ spans = list(spans) by_id = {s.span_id: s for s in spans} for s in spans: if s.input_tokens > 0 and s.output_tokens == 0: raise TokenPartitionError( f"span {s.span_id} has input_tokens={s.input_tokens} but " f"output_tokens=0; likely a failed retry attempt that must " f"not contribute to the billed sum" ) for i, a in enumerate(spans): for b in spans[i + 1:]: if a.model == b.model and _overlaps(a, b) and \ a.input_tokens == b.input_tokens and a.input_tokens > 0: raise TokenPartitionError( f"spans {a.span_id} and {b.span_id} share model " f"{a.model!r}, overlap in time, and have equal " f"input_tokens={a.input_tokens}; retry double-count" ) for s in spans: if s.parent_span_id is None: continue parent = by_id.get(s.parent_span_id) if parent is None: continue if parent.agent_name != s.agent_name and \ parent.input_tokens == s.input_tokens and \ s.input_tokens > 0: raise TokenPartitionError( f"handoff span {s.span_id} (agent={s.agent_name!r}) " f"inherits input_tokens={s.input_tokens} from parent " f"{parent.span_id} (agent={parent.agent_name!r}); " f"usage attributes must be per-invocation, not cumulative" ) return sum(s.input_tokens for s in spans) ```