# Why does an instance of a GenAI agent span report zero `genai.usage.output_tokens` while the tool-call child span carries a full `genai.tool.call.arguments` payload **Date of Record:** 2026 November 14 **Scope:** Applies to OpenTelemetry GenAI semantic conventions (v1.27+ `genai.*` attribute set) emitted by auto-instrumentation or hand-rolled span emitters for agent runtimes that perform multi-turn tool invocation. Does not apply to single-shot chat completions without tool calls, nor to providers that return usage on a streaming terminal chunk only after `finish_reason=tool_calls` (that is a separate, provider-side race). ## Core Distinction The distinction that matters is between **token accounting as a property of the model response** and **token accounting as a property of the agent turn**. The GenAI conventions attach `genai.usage.input_tokens` and `genai.usage.output_tokens` to the span that represents *a model invocation*. An agent span (`genai.operation.name = "invoke_agent"` or the older `execute_tool`/`chat` split) is a *parent* span that may wrap many model invocations. When the instrumentation emits the agent span, it frequently has no usage attributes of its own because usage is summed from children — but the convention does not mandate that the parent carry an aggregate. The result is a span tree where the agent span looks "free" and only the leaf model spans carry usage. The non-obvious failure is that **tool-call child spans are not model spans**, so a naive aggregator that sums `genai.usage.*` over all spans in the trace will double-count nothing and under-count everything: the tool span carries `genai.tool.call.arguments` and `genai.tool.call.id` but *never* usage, and the agent span carries neither usage nor arguments. The only spans that carry usage are the `chat`/`text_completion` leaf spans, which may be siblings of tool spans under the agent span, not ancestors. ## What Was Observed In a LangGraph-style agent runtime instrumented with the OpenTelemetry GenAI conventions, a single user turn that triggered one tool call produced the following span tree (attributes abbreviated): ``` invoke_agent (genai.operation.name=invoke_agent, genai.agent.name=planner) ├── chat (genai.operation.name=chat, genai.request.model=gpt-4o, │ genai.usage.input_tokens=1841, genai.usage.output_tokens=97, │ genai.response.finish_reasons=["tool_calls"]) ├── execute_tool (genai.operation.name=execute_tool, genai.tool.name=search, │ genai.tool.call.id=call_abc, genai.tool.call.arguments={"q":"..."}) └── chat (genai.operation.name=chat, genai.request.model=gpt-4o, genai.usage.input_tokens=2103, genai.usage.output_tokens=142, genai.response.finish_reasons=["stop"]) ``` Observed facts: 1. The `invoke_agent` span has **no** `genai.usage.*` attributes. A dashboard summing usage by `genai.operation.name` grouped by span will show `invoke_agent` at zero. 2. The `execute_tool` span has **no** `genai.usage.*` attributes and is not expected to — it is not a model call. 3. The two `chat` spans each carry their own usage, and the second `chat` span's `input_tokens` (2103) already includes the tool result appended to the conversation. Summing `input_tokens` across the two chat spans (1841 + 2103) **double-counts** the original prompt prefix because the second call re-sends it. 4. The tool span's `genai.tool.call.arguments` is the only place the tool input is recorded, and it is *not* reflected in any token count — tool payloads are not billed as model tokens and are not tokenized by the instrumentation. ## Condition of Validity This record is valid when all of the following hold: - The instrumentation follows the GenAI conventions such that usage is attached to model-invocation spans (`chat`, `text_completion`, `embeddings`) and *not* propagated to the agent span. This is the behavior of `opentelemetry-instrumentation-openai` and the OpenLLMetry GenAI bridge as of late 2026. - The agent runtime uses a loop where each model call is a distinct span and tool execution is a distinct sibling span. - The consumer is computing cost or token throughput by summing `genai.usage.*` across spans rather than by reading a single authoritative span. It is **not** valid when the runtime emits a single `chat` span per agent turn with internal tool calls folded in (some frameworks do this), or when a custom processor has already aggregated usage onto the parent span via a span processor — in that case the parent *does* carry totals and the aggregation is safe. ## Decision Boundary The boundary is: **does the aggregator sum over leaf model spans, or over all spans?** If the former, the math is correct but the agent span will read zero and must be excluded from cost dashboards. If the latter, tool spans contribute zero (harmless) but the agent span also contributes zero (harmless) *and* the double-counting of re-sent prompt prefixes across sequential chat spans is the real error — a 2-turn tool loop can inflate apparent input tokens by 50–80% versus the provider's actual billing, because the provider bills the full context on each call but your dashboard is summing per-call inputs as if they were disjoint. The correct invariant to enforce at emission or aggregation time: **`invoke_agent` span usage, if present, must equal the sum of its descendant `chat` span usage minus any re-sent prefix, and must never be summed together with its descendants.** In practice, the only safe rule is to sum usage over spans where `genai.operation.name` is in `{chat, text_completion}` and *not* to emit usage on the agent span at all, or to emit it only as a denormalized convenience copy that downstream tools are instructed to ignore. ## Operational Effect The operational consequence is a silent cost-attribution error: agent traces appear cheap (agent span = 0 tokens) while the true cost lives in a sibling `chat` span that a naive top-level query misses, and per-turn token dashboards overstate input tokens for multi-turn tool loops. Fixes that "just copy usage up to the parent" make it worse by creating two sources of truth that can diverge when a tool call fails mid-turn and the loop retries the model call without emitting a new chat span. The durable fix is a span processor that tags every span with `genai.usage.is_aggregate=true|false` and a query contract that filters on it, plus a guard in the emitter that refuses to attach usage attributes to a span whose `genai.operation.name` is `invoke_agent` or `execute_tool`. ```python class GenAIUsageInvariantError(RuntimeError): """Raised when a GenAI span violates the usage-attribution invariant.""" _MODEL_OPS = frozenset({"chat", "text_completion", "embeddings"}) _AGGREGATE_OPS = frozenset({"invoke_agent", "execute_tool"}) def validate_span_usage(span_attrs: dict) -> None: """Enforce that usage attributes live only on model-invocation spans. span_attrs is the flattened attribute dict of a single emitted span, e.g. {"genai.operation.name": "invoke_agent", "genai.usage.input_tokens": 1841, ...} """ op = span_attrs.get("genai.operation.name") has_usage = any(k.startswith("genai.usage.") for k in span_attrs) has_tool_args = "genai.tool.call.arguments" in span_attrs if op in _AGGREGATE_OPS and has_usage: raise GenAIUsageInvariantError( f"span op={op!r} carries genai.usage.* attributes; usage must be " f"attached only to model-invocation spans {sorted(_MODEL_OPS)}. " f"Found keys: {[k for k in span_attrs if k.startswith('genai.usage.')]}" ) if op in _MODEL_OPS and has_tool_args: raise GenAIUsageInvariantError( f"span op={op!r} carries genai.tool.call.arguments; tool payloads " f"belong on execute_tool spans, not on model spans." ) if op not in _MODEL_OPS | _AGGREGATE_OPS: raise GenAIUsageInvariantError( f"span op={op!r} is not a recognized GenAI operation; refusing to " f"attribute usage without a known operation name." ) def aggregate_trace_usage(spans: list[dict]) -> dict: """Sum usage over leaf model spans only; return per-op breakdown.""" totals = {"input_tokens": 0, "output_tokens": 0, "model_spans": 0} for attrs in spans: validate_span_usage(attrs) if attrs.get("genai.operation.name") in _MODEL_OPS: totals["input_tokens"] += attrs.get("genai.usage.input_tokens", 0) totals["output_tokens"] += attrs.get("genai.usage.output_tokens", 0) totals["model_spans"] += 1 if totals["model_spans"] == 0: raise GenAIUsageInvariantError( "trace contains no model-invocation spans; usage is undefined. " "Do not fall back to agent-span usage — it is not authoritative." ) return totals ```