# When must an instance attribute cost to a nested agent span rather than the enclosing trace root **Date of Record:** 2026 November 12 **Scope:** Applies to GenAI observability pipelines that compute per-request token cost and wall-clock latency from OpenTelemetry-style trace trees where LLM calls, tool invocations, and retriever calls are emitted as nested spans under an agent orchestration span. Does not apply to flat single-span instrumentation, nor to billing reconciliation against provider invoices, which must use provider-side usage records rather than trace-derived estimates. ## Core Distinction The non-obvious boundary is between *span-local cost accrual* and *trace-root cost aggregation*. A nested LLM span carries its own `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, and model identifier. The enclosing agent span carries none of these; it carries only orchestration timing. The correct attribution rule is that cost is *accrued* at the deepest span that actually called a metered model, and *rolled up* through ancestors purely as a derived sum. The failure mode is the inverse: instrumentation that reads `gen_ai.request.model` from a propagated resource attribute at the trace root and multiplies it by the root span's duration or a token count copied upward. This produces plausible-looking numbers that are wrong by the fan-out factor of the agent — a ReAct loop with eight sequential model calls will report roughly one-eighth the true cost, and a parallel tool fan-out will report a single call's cost for N. The second distinction is between *propagated context* and *metered context*. W3C `traceparent` propagates trace identity and parent span identity across service boundaries; it does not propagate token usage. Any cross-service hop that re-emits an LLM call must attach its own usage attributes at the point of emission. A gateway service that forwards to a model provider and returns only the response body silently drops usage unless the gateway itself records it. ## What Was Observed In a three-service agent deployment (orchestrator → retrieval service → LLM gateway), trace trees showed the orchestrator root span with `gen_ai.request.model` set from a default config and zero usage attributes on children emitted by the gateway. Cost dashboards attributed 100% of spend to the orchestrator root at the default model's rate. Two concrete anomalies surfaced: 1. A single user turn that triggered a six-iteration ReAct loop with a cheap model for planning and an expensive model for synthesis reported a total cost equal to one call at the default rate — off by 6× in call count and by a model-mix factor of roughly 4× on the synthesis calls. 2. Latency attribution showed the root span's duration, which was correct for wall-clock, but per-child `gen_ai.server.time_to_first_token` and `gen_ai.server.time_per_output_token` were absent because the gateway stripped upstream response headers before re-emitting the span. Time-to-first-token was therefore unattributable to any hop, and the p99 latency regression was invisible at the gateway boundary. Cross-service propagation was intact — `traceparent` and `tracestate` survived all hops, parent-child links were correct — which is precisely why the failure was non-obvious. The trace *structure* was right; the *metered payload* was missing at exactly the hops that incurred cost. ## Condition of Validity The span-local accrual rule is valid if and only if all three hold: - Every span that issues a metered model call emits `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` (or provider-equivalent) at emission time, before any response transformation. - Model identity (`gen_ai.request.model` or `gen_ai.response.model`) is attached to that same span, not inherited from a resource attribute. - Trace context (`traceparent`, `tracestate`, baggage) is propagated across every service boundary, and no intermediary re-parents or re-roots the span. If any condition fails, trace-derived cost is an estimate with unbounded error and must be labeled as such. The rule does *not* hold for streaming responses where usage is delivered in a terminal chunk after the span has been ended — in that case the span must be ended after the terminal chunk, or usage must be attached via a span event before end. ## Decision Boundary Attribute cost to the nested span when: the span issued the model call, holds the usage attributes at emission, and holds model identity. Roll up to ancestors as a derived sum only, and mark the rollup as derived (`cost.derived=true`) so downstream consumers do not double-count against span-local values. Attribute cost to the trace root only when: the root is the sole metered caller (single-span instrumentation), or when trace-derived attribution has been explicitly reconciled against provider usage records and the reconciliation delta is within tolerance. Refuse to attribute and emit an error when: a span has model identity but no usage attributes, or usage attributes but no model identity, or when a cross-service hop returns a response without a usage record and without a span event documenting the omission. Silent zero-cost spans are the most dangerous outcome; they must be surfaced as gaps, not as zero. ## Operational Effect The practical consequence is that cost dashboards built on trace rollups must be validated against provider invoices at the trace level, not the aggregate level, because aggregate agreement can hide compensating errors (one over-attributed root masking several under-attributed children). The second consequence is that the gateway service becomes a first-class observability boundary: its instrumentation is where token usage and time-to-first-token must be captured, and its response-transformation code must not run before span attributes are set. The third is that agent fan-out (loop iterations, parallel tool calls) multiplies attribution error linearly, so the failure is worst precisely in the highest-spend workloads. ```python from dataclasses import dataclass from typing import Optional class CostAttributionError(RuntimeError): """Raised when a span cannot be safely attributed a metered cost.""" pass @dataclass class Span: span_id: str parent_id: Optional[str] model: Optional[str] input_tokens: Optional[int] output_tokens: Optional[int] is_metered_caller: bool def attribute_span_cost(span: Span, price_per_token: dict[str, float]) -> float: if not span.is_metered_caller: # Orchestration spans carry no metered payload; cost is derived, not accrued. return 0.0 if span.model is None: raise CostAttributionError( f"span {span.span_id}: metered caller missing gen_ai.request.model" ) if span.input_tokens is None or span.output_tokens is None: raise CostAttributionError( f"span {span.span_id}: metered caller missing usage attributes; " "refusing to emit zero-cost (silent gap)" ) if span.model not in price_per_token: raise CostAttributionError( f"span {span.span_id}: unknown model {span.model!r} in price table" ) rate = price_per_token[span.model] return (span.input_tokens + span.output_tokens) * rate def rollup_trace_cost(spans: list[Span], price_per_token: dict[str, float]) -> dict[str, float]: accrued = {} for s in spans: accrued[s.span_id] = attribute_span_cost(s, price_per_token) # Derived rollup: sum children into ancestors, never overwrite span-local accrual. derived = dict(accrued) by_id = {s.span_id: s for s in spans} for s in spans: cur = s while cur.parent_id is not None: parent = by_id.get(cur.parent_id) if parent is None: raise CostAttributionError( f"span {cur.span_id}: parent {cur.parent_id} not in trace; " "context propagation may have been broken" ) derived[parent.span_id] = derived.get(parent.span_id, 0.0) + accrued[s.span_id] cur = parent return derived ```