# How should an instance attribute cost and latency to nested agent spans when GenAI semantic conventions make parent and child span accounting non-additive **Date of Record:** 2026 November 9 **Scope:** Applies to trace-based cost and latency attribution in agentic systems where spans are emitted under OpenTelemetry GenAI semantic conventions (`gen_ai.*`) and where parent spans aggregate child LLM calls. Does not apply to flat, single-call inference traces, nor to systems where billing is reconciled directly against provider invoices per request ID. ## Core Distinction The non-obvious invariant is this: **a parent agent span's `gen_ai.usage.*` attributes are not the sum of its children's usage, and they are not disjoint from them either.** A well-instrumented agent that calls three tools and two models will typically record the orchestrator's own token consumption (planning prompt, scratchpad, final synthesis) on the parent span *and* the sub-call consumption on child spans. Naive attribution that sums every span's `gen_ai.usage.input_tokens` / `output_tokens` in a trace double-counts nothing — but naive attribution that *rolls up* child usage into the parent, then bills the parent, double-counts everything the children already reported. The two failure modes are opposite and both are common. The distinction that matters operationally: **span-level usage is a measurement; trace-level cost is a derivation.** GenAI semantic conventions standardize the measurement (`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.request.model`, `gen_ai.operation.name`). They deliberately do not standardize the derivation — how you fold nested measurements into a billable total. That gap is where attribution drifts. ## What Was Observed In a multi-agent deployment instrumented with `opentelemetry-instrumentation-openai` and a custom orchestrator wrapper, three concrete anomalies surfaced during a cost audit: 1. **Parent/child token overlap on synthesis spans.** The orchestrator span (`gen_ai.operation.name = "invoke_agent"`) carried both its own planning tokens *and* a `gen_ai.usage.input_tokens` value copied forward from the last child model call by a well-meaning middleware that "propagated context." The trace's summed usage exceeded the provider's invoice by 11–14% on synthesis-heavy runs. 2. **Latency non-additivity from retries and streaming.** Child span durations summed to *less* than the parent duration because parent wall-clock included queueing, tool execution, and retry backoff that children did not span. Conversely, on retried model calls, two child spans covered the same logical request, so summing child durations *over*-counted model time while the `gen_ai.usage.*` on the failed attempt was zero (no tokens billed on a 429). Duration and cost diverged in opposite directions. 3. **Model-attribute drift on fallback.** A child span that fell back from a primary to a secondary model retained `gen_ai.request.model` = primary while `gen_ai.response.model` = secondary. Cost computed from `request.model` priced the call at the wrong rate — a 3.5× error on one route. The common thread: every anomaly arose from treating a hierarchical trace as if it were a flat ledger. ## Condition of Validity The additive assumption — "trace cost = Σ span cost" — is valid **only** when all of the following hold: - Every span that consumed billable tokens reports its own `gen_ai.usage.*` and no span reports usage on behalf of a descendant. - No span's usage attributes are propagated upward by middleware or context copy. - `gen_ai.response.model` is present and authoritative for pricing; `gen_ai.request.model` is used only when response model is absent. - Retried attempts are either excluded from the ledger or carry zero usage, and parent duration is not reconstructed from child durations. - The trace is complete (no sampled-out or dropped child spans). When any condition fails, the additive assumption silently produces a wrong number rather than an error. That silence is the hazard. ## Decision Boundary The boundary is **ownership of the measurement versus ownership of the derivation**: - **Below the boundary (span emits):** a span may report usage only for work it directly caused and was directly billed for. It must not forward a descendant's usage. `gen_ai.response.model` must reflect the model that actually produced the tokens. - **Above the boundary (trace derives):** cost and latency rollups are computed by a *separate* reducer that walks the span tree, sums only leaf-level billable spans, and adds parent-only overhead (orchestration tokens, queue time) as a distinct bucket — never by re-summing a parent whose children are already counted. If an implementation cannot guarantee leaf-only usage emission, it must mark the trace `attribution_mode = "non_additive"` and refuse to emit a single rolled-up cost figure. Emitting a number under broken invariants is worse than emitting none, because downstream dashboards treat the number as ground truth. ## Operational Effect Enforcing the boundary changes three operational behaviors: - **Cost dashboards split into two series:** `direct_model_cost` (leaf spans only) and `orchestration_overhead` (parent-only usage). The sum reconciles to the invoice; neither alone does. - **Latency uses critical-path duration, not sum-of-children.** Parent wall-clock is the source of truth for end-to-end latency; child durations are used only to attribute *where* time went, with a residual bucket for queue/backoff. - **Model-rate resolution keys on `gen_ai.response.model`.** Fallback routes price correctly; `request.model` becomes advisory metadata only. The failure this prevents is quiet and expensive: a system that reports a clean, plausible, wrong cost per trace and a latency breakdown that sums to less than the user-visible wait. ```python from __future__ import annotations from dataclasses import dataclass, field from typing import Iterable class AttributionInvariantError(RuntimeError): """Raised when a trace cannot be reduced to a single billable total without violating the leaf-only usage invariant.""" @dataclass class Span: span_id: str parent_id: str | None operation: str input_tokens: int = 0 output_tokens: int = 0 response_model: str | None = None request_model: str | None = None duration_ms: float = 0.0 retry_of: str | None = None PRICE_PER_1K = {"gpt-4o": 0.005, "gpt-4o-mini": 0.00015, "fallback-l": 0.0002} def _price(span: Span) -> float: # response_model is authoritative; request_model is advisory only. model = span.response_model if model is None: if span.input_tokens or span.output_tokens: raise AttributionInvariantError( f"span {span.span_id} reports tokens but no gen_ai.response.model; " "cannot price without risking fallback rate drift" ) return 0.0 if model not in PRICE_PER_1K: raise AttributionInvariantError(f"unknown model rate for {model!r}") return (span.input_tokens + span.output_tokens) / 1000.0 * PRICE_PER_1K[model] def reduce_trace(spans: Iterable[Span]) -> dict[str, float]: spans = list(spans) by_id = {s.span_id: s for s in spans} parents = {s.parent_id for s in spans if s.parent_id} # Detect upward usage propagation: a span that has children must not # carry usage copied from a descendant. We cannot see provenance, so we # enforce the structural rule: any span with children that also reports # tokens is flagged as parent-only overhead, and its children are leaves. leaf_cost = 0.0 overhead_cost = 0.0 seen_retry_targets: set[str] = set() for s in spans: if s.retry_of is not None: # Failed attempt: usage must be zero; do not bill it. if s.input_tokens or s.output_tokens: raise AttributionInvariantError( f"retry span {s.span_id} carries usage for a retried attempt" ) seen_retry_targets.add(s.retry_of) continue if s.span_id in seen_retry_targets: # A later successful attempt exists; bill once, at the winner. pass cost = _price(s) if s.span_id in parents: overhead_cost += cost else: leaf_cost += cost # Latency: parent wall-clock is authoritative; child durations only # attribute, never sum to the total. root_durations = [s.duration_ms for s in spans if s.parent_id is None] if len(root_durations) != 1: raise AttributionInvariantError( f"expected exactly one root span, found {len(root_durations)}" ) end_to_end_ms = root_durations[0] child_sum_ms = sum(s.duration_ms for s in spans if s.parent_id is not None) if child_sum_ms > end_to_end_ms * 1.001: raise AttributionInvariantError( "child durations exceed parent wall-clock; retries or clock skew " "present — latency is non-additive and must use critical path" ) return { "direct_model_cost": round(leaf_cost, 6), "orchestration_overhead": round(overhead_cost, 6), "total_cost": round(leaf_cost + overhead_cost, 6), "end_to_end_ms": end_to_end_ms, "unattributed_ms": round(end_to_end_ms - child_sum_ms, 3), } ```