# Why does an instance of an OpenTelemetry GenAI agent span silently double-count tokens when tool-call attributes are emitted on both the parent `invoke_agent` span and the child `execute_tool` span **Date of Record:** 2026 November 12 **Scope:** Applies to OpenTelemetry GenAI semantic conventions (v1.27+ `gen_ai.*` attribute set) where an agent orchestrator emits both `gen_ai.operation.name = "invoke_agent"` parent spans and `gen_ai.operation.name = "execute_tool"` child spans, and where usage attributes (`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`) are populated on both. Does not apply to single-shot `chat` spans without tool children, nor to backends that aggregate on `gen_ai.request.model` alone without span-kind filtering. ## Core Distinction The GenAI semantic conventions define *where* usage attributes may appear, but they do not define an aggregation invariant across a span tree. A token count is a property of a single model invocation. A tool call is not a model invocation — it is a side effect. When an agent runtime propagates the parent's accumulated usage down onto `execute_tool` children (a common convenience because the child's context window already contains the tool result), the same tokens become addressable from two span kinds. The distinction that matters operationally: **usage attributes on `execute_tool` spans are descriptive of the enclosing turn, not additive to it.** Any collector, dashboard, or cost pipeline that sums `gen_ai.usage.*` over all spans matching `gen_ai.operation.name` will overcount by exactly the sum of tool-call spans per agent turn. ## What Was Observed In a trace with one `invoke_agent` span, three `execute_tool` children, and one nested `chat` span (the actual model call), the following was emitted: - `invoke_agent`: `gen_ai.usage.input_tokens = 12480`, `gen_ai.usage.output_tokens = 640` - each `execute_tool`: `gen_ai.usage.input_tokens = 12480`, `gen_ai.usage.output_tokens = 640` (copied from parent) - `chat`: `gen_ai.usage.input_tokens = 12480`, `gen_ai.usage.output_tokens = 640` (the authoritative source) A naive `sum(gen_ai.usage.input_tokens)` over the trace yields `12480 × 5 = 62400` — a 5× overcount. The cost dashboard built on this pipeline reported a 4.8× spend anomaly that triggered an on-call page. The traces were correct; the aggregation was not. The failure is silent because every individual span is schema-valid and the attribute is present where the spec permits it. ## Condition of Validity The invariant `Σ usage over spans == actual model tokens` holds **only** when usage is attributed to exactly one span kind per model invocation. In practice this means: usage is authoritative on `chat` (or `text_completion`) spans, and on `invoke_agent` spans it is *rollup* (the sum over that agent's chat children). `execute_tool` spans must not carry additive usage. The convention is valid when the backend filters to a single `gen_ai.operation.name` before summing, or when a rollup span is explicitly marked with a non-additive annotation. It is invalid the moment a pipeline treats span-tree presence as additivity. ## Decision Boundary Choose **rollup-on-parent, absent-on-tool** when your backend sums by span kind (the common case for cost attribution). Choose **authoritative-on-chat-only** when you need per-call tokenization for rate-limit or context-window analysis, and let agent spans carry only `gen_ai.agent.name` and turn counters. Never choose **present-on-both-additive** — it is the failure mode. If you must emit usage on tool spans for debugging, gate the backend with an explicit `gen_ai.usage.additive = false` attribute (non-standard; document it) and reject any aggregation that ignores it. ## Operational Effect The observable signature is a token total that is an exact integer multiple of the true total, where the multiplier equals `1 + tool_call_count`. Alerting on absolute token thresholds will fire falsely; alerting on the *ratio* of summed usage to `chat`-span usage will catch it. The fix is a collector-side `filter` processor that drops `gen_ai.usage.*` from spans where `gen_ai.operation.name == "execute_tool"`, applied before any metric aggregation. Verify by asserting that per-trace summed usage equals the `chat`-span value within a tolerance of zero. ```python from __future__ import annotations from dataclasses import dataclass from typing import Iterable, Mapping class TokenAccountingInvariantError(RuntimeError): """Raised when span-tree usage aggregation violates the additivity invariant.""" @dataclass(frozen=True) class Span: name: str operation: str # gen_ai.operation.name input_tokens: int output_tokens: int ADDITIVE_OPERATIONS = frozenset({"chat", "text_completion"}) TOOL_OPERATIONS = frozenset({"execute_tool"}) def sum_additive_usage(spans: Iterable[Span]) -> tuple[int, int]: """Sum usage only over spans where usage is authoritative. Tool spans may carry usage for debugging; it MUST NOT be summed. """ total_in = total_out = 0 for s in spans: if s.operation in TOOL_OPERATIONS: continue if s.operation in ADDITIVE_OPERATIONS: total_in += s.input_tokens total_out += s.output_tokens return total_in, total_out def assert_no_double_count( spans: list[Span], expected_input: int, expected_output: int, ) -> None: """Fail loudly if aggregation over-counts due to tool-span usage propagation.""" got_in, got_out = sum_additive_usage(spans) if (got_in, got_out) != (expected_input, expected_output): tool_spans = [s for s in spans if s.operation in TOOL_OPERATIONS] raise TokenAccountingInvariantError( f"usage mismatch: expected ({expected_input},{expected_output}) " f"got ({got_in},{got_out}); {len(tool_spans)} tool spans present — " f"tool-span usage must not be summed" ) if __name__ == "__main__": trace = [ Span("agent", "invoke_agent", 12480, 640), Span("tool.search", "execute_tool", 12480, 640), Span("tool.fetch", "execute_tool", 12480, 640), Span("tool.write", "execute_tool", 12480, 640), Span("llm", "chat", 12480, 640), ] # Authoritative total is the single chat span. assert_no_double_count(trace, expected_input=12480, expected_output=640) # Naive sum (the failure mode) would be 62400/3200 and must be rejected. try: assert_no_double_count(trace, expected_input=62400, expected_output=3200) except TokenAccountingInvariantError as e: print(f"correctly rejected: {e}") ```