# Which invariants prevent span-model collapse in OpenTelemetry GenAI multi-turn agent trajectories **Date of Record:** 2026 November 11 **Scope:** Applies when modeling a multi-turn agent (tool-calling loop, planner/executor, or ReAct-style trajectory) as OpenTelemetry spans under the GenAI semantic conventions, specifically when deciding whether to emit one span per turn versus one span per logical operation. Does not apply to single-shot inference calls, pure RAG retrieval pipelines without iterative tool use, or batch offline evaluation where no live trace context exists. ## Core Distinction The GenAI semantic conventions define `gen_ai.*` attributes for a *single model invocation* (one request/response pair), not for a *trajectory*. A multi-turn agent is a sequence of model invocations interleaved with tool executions. The non-obvious boundary is this: a trajectory is **not** a span. It is a *parent context* that must be reconstructed from a tree of child spans. The failure mode is treating the trajectory as a single long-lived span whose attributes are mutated per turn — which silently violates the immutability invariant of span attributes and the append-only invariant of span events. The distinction that matters operationally: **turn identity is a child-span concern; trajectory identity is a trace/root-span concern.** Collapsing them produces spans that are neither valid turns nor valid trajectories. ## What Was Observed In a planner/executor agent emitting one root span `invoke_agent` with `gen_ai.operation.name=chat` and mutating `gen_ai.usage.input_tokens` on every turn: - Token accounting double-counted. Backends that aggregate `gen_ai.usage.*` across spans summed cumulative values, inflating cost by a factor of the turn count. - `gen_ai.response.finish_reasons` was overwritten each turn; the final value reflected only the last turn, hiding earlier `tool_calls` terminations that were the actual causal driver of the trajectory. - Span duration became the full trajectory wall-clock, so per-turn latency (the only signal useful for detecting a slow tool) was unrecoverable. - When a turn failed and was retried, there was no distinct span to mark the retry; the root span simply continued, so error attribution pointed at the trajectory rather than the turn. The inverse failure also occurred: emitting one span per turn *without* a distinct trajectory root produced orphaned spans that sampling dropped independently, yielding partial trajectories that looked like complete ones. ## Condition of Validity The following invariants must hold for the span model to be interpretable: 1. **Attribute immutability per span.** Attributes set at span start or on `set_attribute` before end are fixed. A span MUST NOT represent more than one model invocation. Cumulative token counts belong on an aggregating parent, never mutated onto a single span. 2. **One invocation, one span.** Each `gen_ai.operation.name=chat` span maps to exactly one request/response. `gen_ai.response.finish_reasons` is a list for a single response, not across turns. 3. **Trajectory root is structural, not semantic.** The root span carries `gen_ai.agent.name`/`gen_ai.conversation.id` and no per-turn usage attributes; usage is summed by the backend from children. 4. **Tool spans are siblings of turn spans, not children of the model span.** A tool call is caused by a turn but executed outside the model invocation; parenting it under the chat span corrupts the latency decomposition. 5. **Trace context propagation across turns is explicit.** The turn span's parent is the trajectory root (or the previous turn, for strict causal chains) — never implicitly inherited from an ambient context that may be a different request. These hold when a real trace context spans the whole trajectory. They fail when turns are executed across process boundaries without propagated context (async workers, queue-based executors) — in that case the trajectory root must be reconstructed by `gen_ai.conversation.id`, and the invariants become best-effort. ## Decision Boundary - If turns run in one process with a live context → emit trajectory root + one child span per turn + sibling tool spans. Enforce invariants 1–5. - If turns run across processes/queues → emit per-turn spans with a stable `gen_ai.conversation.id` and a synthetic root; do **not** mutate a shared span. Accept that duration-based trajectory latency is unavailable. - If a turn is retried → emit a new span per attempt with `retry.count`; never reuse the failed span. - If you cannot satisfy invariant 1 → you are not modeling a trajectory; you are logging, and should use span events or logs, not attributes. ## Operational Effect Enforcing these invariants restores three things: correct cost aggregation (sum over children equals true cost), recoverable per-turn latency for tool-slowdown detection, and correct error attribution (failed turn, not failed trajectory). The cost is span cardinality — a 20-turn agent yields ~40 spans — which interacts with sampling. The operational rule: sample at the trajectory root and force-include all descendant turn/tool spans, or you will produce partial trajectories indistinguishable from complete ones. Detecting collapse post hoc is possible by checking for `gen_ai.usage.input_tokens` monotonic increase within a single span across events — a signature that a span was mutated rather than one span per turn. ```python class SpanModelCollapseError(RuntimeError): """Raised when a trajectory is modeled as a single mutated span.""" pass def validate_trajectory_spans(spans): """ spans: list of dicts with keys: span_id, parent_id, operation, attributes (dict), events (list) Enforces: one model invocation per chat span; no cumulative token mutation across events; tool spans are siblings of turn spans. """ for s in spans: if s["operation"] != "chat": continue token_series = [ e["attributes"].get("gen_ai.usage.input_tokens") for e in s.get("events", []) if "gen_ai.usage.input_tokens" in e.get("attributes", {}) ] token_series = [t for t in token_series if t is not None] if len(token_series) > 1: strictly_increasing = all( b >= a for a, b in zip(token_series, token_series[1:]) ) if strictly_increasing: raise SpanModelCollapseError( f"span {s['span_id']} mutates gen_ai.usage.input_tokens " f"across events ({token_series}); a chat span must map to " f"exactly one model invocation. Emit one span per turn." ) finish = s["attributes"].get("gen_ai.response.finish_reasons") if isinstance(finish, list) and len(finish) > 1 and len(set(finish)) > 1: raise SpanModelCollapseError( f"span {s['span_id']} has heterogeneous finish_reasons {finish} " f"indicating multiple turns collapsed into one span." ) by_id = {s["span_id"]: s for s in spans} for s in spans: if s["operation"] not in ("execute_tool",): continue parent = by_id.get(s["parent_id"]) if parent is not None and parent["operation"] == "chat": raise SpanModelCollapseError( f"tool span {s['span_id']} parented under chat span " f"{parent['span_id']}; tool spans must be siblings of turn " f"spans to preserve latency decomposition." ) return True ```