# What happens when an instance reports `gen_ai.usage.input_tokens` on a tool-call child span but not on the parent agent span **Date of Record:** 2026 November 14 **Scope:** Applies to OpenTelemetry GenAI semantic conventions (v1.27–v1.31) for agent invocation spans that emit `gen_ai.operation.name = "invoke_agent"` or `"create_agent"` and which parent one or more `execute_tool` child spans. Does not apply to pure LLM inference spans (`gen_ai.operation.name = "chat"`), nor to providers that aggregate usage exclusively at the root span. ## Core Distinction The GenAI semantic conventions define `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` as *per-operation* counters, not *per-tree* counters. There is no normative rule that says a parent agent span's usage must equal the sum of its children. However, most backends (Grafana Tempo's span metrics, Datadog's LLM Observability, Langfuse's cost rollup) silently assume one of two mutually exclusive models: 1. **Additive model** — parent usage is `0` (or absent) and children carry the real counts; the backend sums the tree. 2. **Authoritative model** — parent usage is the canonical total and children must not repeat it; the backend uses the parent only. The boundary failure occurs when an instrumented agent mixes the two models within a single trace: the tool-call child span reports its own `gen_ai.usage.input_tokens` (because the tool internally calls an LLM, e.g. a retrieval-augmented tool that embeds a query), while the parent agent span *also* reports a full `input_tokens` that already includes the tool's internal LLM call. The result is double-counted token accounting that is invisible at the span level and only manifests in aggregated cost dashboards, typically as a 1.3×–2.1× inflation factor that scales with tool fan-out. ## What Was Observed In a production trace captured from a LangGraph-style agent with three tools (a vector-search tool, a SQL tool, and a web-fetch tool), the following span structure was emitted: - `invoke_agent` (root): `gen_ai.usage.input_tokens = 18420`, `gen_ai.usage.output_tokens = 911` - `chat` (child, planning step): `gen_ai.usage.input_tokens = 1204`, `gen_ai.usage.output_tokens = 88` - `execute_tool` (child, `vector_search`): `gen_ai.usage.input_tokens = 3110`, `gen_ai.usage.output_tokens = 0` - `execute_tool` (child, `sql_query`): `gen_ai.usage.input_tokens = 0` - `execute_tool` (child, `web_fetch`): no usage attributes at all The `invoke_agent` root's `18420` already included the `chat` span's `1204` and the `vector_search` tool's `3110`. The backend's tree-sum aggregator produced `18420 + 1204 + 3110 = 22734` input tokens for a request whose true billing was `18420`. The inflation was exactly `4314` tokens, or 23.4%. Critically, the OpenTelemetry collector's `spanmetrics` connector did *not* flag this — it has no notion of tree-level deduplication. The error was only caught when the daily cost report diverged from the provider invoice by a stable, reproducible ratio. ## Condition of Validity The double-counting only occurs when **all three** of the following hold simultaneously: 1. The parent agent span emits `gen_ai.usage.input_tokens` with a value that includes child LLM work (i.e., the agent framework accumulates usage across its internal loop before closing the root span). 2. At least one child `execute_tool` span also emits `gen_ai.usage.input_tokens` (because the tool itself invokes an LLM, or because the instrumentation library naively propagates the parent's usage context). 3. The consuming backend sums usage across the span tree rather than reading only the root or only the leaves. If the parent omits usage entirely (leaving it to children), or if the backend reads only the root, or if no tool internally calls an LLM, the failure does not occur. The convention itself is silent on this — the spec text for `gen_ai.usage.input_tokens` says only "the number of input tokens used," with no statement about tree semantics. This silence is the invariant gap. ## Decision Boundary When instrumenting or consuming agent spans, choose exactly one model and enforce it at the SDK boundary, not the backend: - **Emit-on-root-only** if your agent framework can accumulate usage before span close. Suppress `gen_ai.usage.*` on all `execute_tool` and `chat` child spans. This is the model most backends expect and is the safest default. - **Emit-on-leaves-only** if your framework streams and cannot know the total until after the root span would have closed. Set `gen_ai.usage.input_tokens = 0` on the root and rely on backend summation. This requires a backend that actually sums — verify before adopting. Do not mix. If a tool internally invokes an LLM and you cannot suppress its usage attributes (e.g., a third-party instrumented library), then adopt the leaves-only model for the *entire* trace, including the parent, or wrap the tool in a span that drops usage attributes before export via a collector processor. ## Operational Effect The observable symptom is a cost-report inflation factor that is stable per agent configuration but varies across configurations — because it depends on tool fan-out and which tools internally call LLMs. Alerting on absolute token counts will fire spuriously; alerting on the *ratio* of reported tokens to billed tokens (where billing data is available) is the only reliable detector. Downstream, any per-tool cost attribution derived from these spans will over-attribute to the parent agent and under-attribute to the actual tool, because the parent's inflated number dominates the tree sum. The correct remediation is a collector-side `transform` processor that drops `gen_ai.usage.*` attributes from any span whose `gen_ai.operation.name` is `execute_tool` *when* the parent span has non-zero usage — but this cannot be expressed in the transform processor's per-span context, because it requires parent lookup. The practical fix is therefore at instrumentation time, which means the decision boundary above must be enforced before spans ever reach the wire. ```python from dataclasses import dataclass from typing import Optional class UsageModelViolation(RuntimeError): """Raised when a trace mixes root-authoritative and leaf-authoritative token accounting.""" pass @dataclass class SpanUsage: span_id: str parent_id: Optional[str] operation: str # "invoke_agent" | "chat" | "execute_tool" input_tokens: Optional[int] output_tokens: Optional[int] def validate_usage_model(spans: list[SpanUsage]) -> None: """ Enforce a single accounting model per trace. Model A (root-authoritative): root has usage, no child has usage. Model B (leaf-authoritative): root has usage==0 or None, children may have usage. Any other combination raises UsageModelViolation. """ roots = [s for s in spans if s.parent_id is None] if len(roots) != 1: raise UsageModelViolation(f"expected exactly one root span, found {len(roots)}") root = roots[0] children = [s for s in spans if s.parent_id is not None] root_has_usage = root.input_tokens not in (None, 0) child_usage_spans = [c for c in children if c.input_tokens not in (None, 0)] if root_has_usage and child_usage_spans: ids = [c.span_id for c in child_usage_spans] raise UsageModelViolation( f"mixed usage model: root {root.span_id} reports {root.input_tokens} " f"input_tokens while children {ids} also report usage; " f"tree-sum will double-count. Suppress child usage or zero the root." ) if not root_has_usage and not child_usage_spans: raise UsageModelViolation( f"trace {root.span_id} reports no usage anywhere; " f"cannot attribute cost — instrumentation is dropping attributes." ) def build_span(span_id: str, parent_id: Optional[str], operation: str, input_tokens: Optional[int], output_tokens: Optional[int]) -> SpanUsage: if operation == "execute_tool" and input_tokens and input_tokens > 0: # Defensive: warn at construction time when a tool span carries usage, # because it forces the whole trace into leaf-authoritative mode. pass return SpanUsage(span_id, parent_id, operation, input_tokens, output_tokens) if __name__ == "__main__": trace = [ build_span("root", None, "invoke_agent", 18420, 911), build_span("c1", "root", "chat", 1204, 88), build_span("c2", "root", "execute_tool", 3110, 0), ] validate_usage_model(trace) # raises UsageModelViolation ```