# What happens when an instance reports GenAI token usage on agent spans without matching tool-call attributes: a silent double-counting boundary in OpenTelemetry GenAI semantic conventions **Date of Record:** 2026 November 8 **Scope:** Applies to OpenTelemetry instrumentation of LLM agent runtimes that emit `gen_ai.*` semantic conventions on both `invoke_agent` and `execute_tool` spans, and to any downstream cost/usage aggregator that sums `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` across a trace. Does not apply to single-shot chat completions without tool loops, nor to spans that only carry `gen_ai.usage.*` on the parent `invoke_agent` span with no child tool spans. ## Core Distinction The GenAI semantic conventions treat token usage as an attribute of the model invocation that produced it. In an agent runtime, a single logical "agent turn" is a loop: the model is invoked, it emits tool calls, the runtime executes them, the results are fed back, and the model is invoked again. The convention-compliant way to represent this is one `invoke_agent` (or `chat`) span per model call, each carrying its own `gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens`, with `execute_tool` child spans carrying `gen_ai.tool.name`, `gen_ai.tool.call.id`, and `gen_ai.tool.type`. The non-obvious boundary: **if an implementation also copies the cumulative loop usage onto the wrapping agent span, and simultaneously emits per-call usage on the inner chat spans, a naive trace-wide sum double-counts every token.** The conventions do not prohibit a parent span from carrying usage; they simply do not define it as an aggregate. There is no `gen_ai.usage.aggregate` flag, no `is_cumulative` boolean, and no required relationship between parent and child usage attributes. The invariant "sum of `gen_ai.usage.*` over a trace equals billed tokens" is therefore an *assumption of the consumer*, not a guarantee of the producer. The second half of the boundary is tool-call attribution. A `chat` span that emits `gen_ai.tool.call.id` on the span (rather than on a discrete event) implies exactly one tool call per span. Agents that batch multiple tool calls into one model response must emit one span per call, or emit them as span events with `gen_ai.tool.call.id` on each event. If an implementation emits a single `execute_tool` span with a comma-joined `gen_ai.tool.name` list, the attribute is no longer conformant (the convention specifies a single tool name), and any join keyed on tool name silently drops or misattributes calls. ## What Was Observed In a multi-step agent deployment (planner → retrieval tool → synthesis → code-execution tool → final answer), instrumentation was added at two layers by two teams: - The **runtime layer** wrapped the entire agent loop in one `invoke_agent` span and, at loop exit, set `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` to the *sum* of all model calls in the loop. This was done because the runtime had the totals readily available and the team read "usage belongs on the agent span" in the conventions. - The **SDK layer** emitted one `chat` span per model invocation with its own per-call `gen_ai.usage.*`, plus `execute_tool` child spans with `gen_ai.tool.name` and `gen_ai.tool.call.id`. Result: a trace with 4 model calls and 3 tool calls reported `2 × actual` tokens to the cost aggregator, because the aggregator summed `gen_ai.usage.input_tokens` over *all* spans in the trace. The error was invisible in per-span dashboards (each span looked correct in isolation) and only surfaced when the monthly bill diverged from the trace-derived estimate by a factor that scaled with agent loop depth. Traces with a single model call (depth 1) were correct; the divergence grew monotonically with loop depth, which is the signature of this failure. A secondary observation: the `execute_tool` spans for the batched retrieval step used `gen_ai.tool.name = "search,rerank"` because the planner emitted two calls in one response and the runtime executed them in one span. The `gen_ai.tool.call.id` was set to the first call's id only. Downstream tool-latency attribution undercounted `rerank` entirely and attributed its duration to `search`. ## Condition of Validity The double-counting failure is *latent* (no error, no warning) under all of the following: 1. The parent agent span carries `gen_ai.usage.*` **and** at least one descendant span also carries `gen_ai.usage.*`. 2. The consumer aggregates usage with a flat `sum()` over spans in the trace, rather than selecting a single span per logical invocation (e.g., deepest `chat` span, or spans where `gen_ai.operation.name == "chat"`). 3. The producer has not set a disambiguating attribute. There is no standard one; in practice teams invent `gen_ai.usage.scope = "cumulative" | "per_call"`, which is non-conformant and invisible to conformant consumers. The failure is *masked* when the consumer deduplicates by span kind (only summing `chat` spans), or when the producer only sets usage on leaf spans. It becomes *visible* when loop depth exceeds 1 and the aggregator is compared against a billing source. For tool attribution, the invariant that holds is: **one `execute_tool` span per `gen_ai.tool.call.id`, with a single `gen_ai.tool.name`**. Violations are detectable by checking for `,` or whitespace-separated lists in `gen_ai.tool.name`, or by counting distinct `gen_ai.tool.call.id` values versus `execute_tool` span count. ## Decision Boundary Choose the emission strategy by asking: *who owns the loop?* - If the **agent runtime owns the loop** (it decides when to re-invoke the model), the runtime should emit one `invoke_agent` span per loop *iteration*, not per logical agent invocation, and must **not** set cumulative usage on any wrapping span. If a wrapping span is required for trace structure, it must be a plain `Span` with no `gen_ai.usage.*` attributes — only structural attributes like `gen_ai.agent.name`. - If the **SDK owns the model call** and the runtime merely orchestrates, the runtime must not set usage at all; the SDK's per-call `chat` spans are the sole source of truth. - If both layers must emit usage (e.g., for independent billing), the consumer contract must be explicit: aggregate only over spans where `gen_ai.operation.name` is `chat` or `text_completion`, and treat `invoke_agent` usage as a *cross-check*, not a summand. For tool calls: never batch. Emit one `execute_tool` span per `gen_ai.tool.call.id`, even when the model returned them in a single response. The span's parent should be the `chat` span that produced the call, and `gen_ai.tool.call.id` must match the id in the model's tool-call payload exactly (it is the join key). ## Operational Effect The practical consequence is that **trace-derived token accounting is only trustworthy if the producer enforces the "usage on leaves only" invariant, and no OpenTelemetry validator enforces it.** A conformant-looking trace can be off by an integer multiple equal to the agent loop depth. The multiplier is not constant across traces, so aggregate error rates look like noise rather than a systematic bug, which delays detection. The mitigation that works in practice is a producer-side assertion at span close: if a span carries `gen_ai.usage.*` and has any descendant span that also carries `gen_ai.usage.*`, emit a diagnostic (log or span event) naming both spans. This converts a silent billing error into a visible instrumentation warning at the point of emission, where the owning team can fix it, rather than at the aggregator, where ownership is ambiguous. The following guard encodes both invariants — no usage on non-leaf spans, and single tool name per `execute_tool` span — as a runtime check suitable for wrapping a span processor or an exporter: ```python from __future__ import annotations from dataclasses import dataclass, field class GenAISemconvViolation(RuntimeError): """Raised when a span violates the GenAI usage-attribution invariants.""" @dataclass class SpanRecord: span_id: str parent_id: str | None operation_name: str # gen_ai.operation.name attributes: dict[str, object] = field(default_factory=dict) def has_usage(self) -> bool: return any( k in self.attributes for k in ("gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens") ) def _descendants(span: SpanRecord, all_spans: list[SpanRecord]) -> list[SpanRecord]: children = [s for s in all_spans if s.parent_id == span.span_id] out: list[SpanRecord] = [] for c in children: out.append(c) out.extend(_descendants(c, all_spans)) return out def validate_usage_attribution(spans: list[SpanRecord]) -> None: """Enforce: a span carrying gen_ai.usage.* must have no descendant that also carries it. A violation means a trace-wide sum of usage attributes will double-count. """ for span in spans: if not span.has_usage(): continue for desc in _descendants(span, spans): if desc.has_usage(): raise GenAISemconvViolation( f"span {span.span_id} ({span.operation_name}) carries gen_ai.usage.* " f"but descendant {desc.span_id} ({desc.operation_name}) also carries it; " f"trace-wide usage sum will double-count. Set usage on leaves only." ) def validate_tool_call_attribution(spans: list[SpanRecord]) -> None: """Enforce: each execute_tool span names exactly one tool and one call id.""" for span in spans: if span.operation_name != "execute_tool": continue name = span.attributes.get("gen_ai.tool.name") call_id = span.attributes.get("gen_ai.tool.call.id") if not isinstance(name, str) or not name: raise GenAISemconvViolation( f"execute_tool span {span.span_id} missing gen_ai.tool.name" ) if any(sep in name for sep in (",", ";", " ")): raise GenAISemconvViolation( f"execute_tool span {span.span_id} has batched tool name {name!r}; " f"emit one span per gen_ai.tool.call.id" ) if not isinstance(call_id, str) or not call_id: raise GenAISemconvViolation( f"execute_tool span {span.span_id} missing gen_ai.tool.call.id" ) def validate_trace(spans: list[SpanRecord]) -> None: validate_usage_attribution(spans) validate_tool_call_attribution(spans) ```