# What happens when an instance of an OpenTelemetry agent span loses its parent across a tool-call boundary **Date of Record:** 2026 November 11 **Scope:** Applies to OpenTelemetry GenAI semantic conventions (`gen_ai.*` attributes) as emitted by agent frameworks that spawn child spans for tool calls and that attribute token cost to spans. Does not apply to single-shot LLM inference spans without tool execution, nor to traces where the exporter guarantees in-process span parenting (e.g., synchronous SDK export within the same thread). ## Core Distinction The GenAI semantic conventions define `gen_ai.operation.name`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, and tool-call attributes such as `gen_ai.tool.name` and `gen_ai.tool.call.id`. They do **not** define a normative invariant that a tool-call span's `parent_span_id` must resolve to the agent span that issued the call. Parentage is carried by the trace context propagation layer, not by the `gen_ai.*` namespace. The boundary failure is the divergence between two facts that are assumed to be the same event: (a) the agent logically invoked a tool, and (b) the tool span is a descendant of the agent span in the exported trace. When an agent framework serializes a tool call through a queue, a subprocess, or a remote executor, (a) holds while (b) silently fails — the tool span becomes a root span or attaches to an unrelated parent. Cost attribution then double-counts or orphans the tokens consumed during tool execution. ## What Was Observed An agent loop issued a tool call via a worker pool. The agent span carried `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` for the model turn. The tool span, emitted from the worker thread, carried `gen_ai.tool.name` and `gen_ai.tool.call.id` but had `parent_span_id` set to the worker's own root span because the W3C `traceparent` header was not re-injected after the thread handoff. In the exported trace, the tool span appeared as an independent root. Downstream cost aggregation summed `gen_ai.usage.*` per span and per trace; because the tool span's trace ID differed, the tokens billed to the tool executor were attributed to a second trace that had no agent context. The `gen_ai.tool.call.id` matched on both sides, but the aggregation pipeline keyed on `trace_id`, so the join never occurred. A second symptom: retries of the same `gen_ai.tool.call.id` produced multiple orphan tool spans with identical call IDs, and any dedup keyed on `(trace_id, call_id)` failed because the trace IDs diverged. ## Condition of Validity The invariant "a tool span is a descendant of its issuing agent span" holds only when trace context is propagated across every asynchronous or process boundary in the call path. It is valid when: the executor runs in the same thread with an active context; or the handoff explicitly carries `traceparent`/`tracestate`; or the framework re-parents the span via `Link` plus a stored parent context. It is invalid whenever the tool executor starts a fresh context (new root) and the framework does not inject the agent's context. The `gen_ai.tool.call.id` attribute is necessary but not sufficient to recover the relationship — it is a correlation key, not a parent link. ## Decision Boundary Choose between two operational postures: 1. **Strict parentage:** Require that every span with `gen_ai.tool.name` has a resolvable `parent_span_id` whose span carries `gen_ai.operation.name` of an agent/chat operation. Reject or quarantine traces that violate this. Cost is attributed only along the parent chain. This is correct but brittle across remote executors and requires explicit context injection at every boundary. 2. **Correlation-key attribution:** Accept orphan tool spans and join on `gen_ai.tool.call.id` (plus a conversation/session identifier) rather than on parentage. Cost attribution tolerates broken parentage but must deduplicate retries by call ID and must not assume trace locality. The boundary is crossed when tool execution is asynchronous, remote, or retried. Below that boundary (in-process, synchronous), strict parentage is free and should be enforced. Above it, correlation-key attribution is the only robust option, and the aggregation schema must carry `gen_ai.tool.call.id` as a first-class join key. ## Operational Effect When parentage breaks and the pipeline assumes trace locality, token cost is under-attributed to the agent (the tool-side tokens land in an orphan trace) and the agent's effective cost per turn is understated. When retries occur, the same `gen_ai.tool.call.id` appears across multiple traces, and naive summation overcounts. The observable signal is a mismatch between the count of `gen_ai.tool.call.id` values under an agent trace and the count of tool spans carrying that call ID anywhere in the export. Operators should alert on this ratio deviating from 1.0 and should treat any tool span whose `parent_span_id` is empty while a matching call ID exists under an agent span as a propagation defect, not a data-quality artifact. ```python class OrphanedToolSpanError(RuntimeError): """Raised when a gen_ai.tool.* span cannot be linked to its issuing agent span. Indicates trace-context propagation was lost across an async/remote boundary, so token cost cannot be attributed along the parent chain. """ pass def validate_agent_trace(spans: list[dict]) -> None: by_id = {s["span_id"]: s for s in spans} agent_ids = { s["span_id"] for s in spans if s.get("attributes", {}).get("gen_ai.operation.name") in {"chat", "invoke_agent"} } call_ids_by_agent = {} for s in spans: if s["span_id"] in agent_ids: for cid in s.get("attributes", {}).get("gen_ai.tool.call.id", []) or []: call_ids_by_agent.setdefault(cid, s["span_id"]) for s in spans: attrs = s.get("attributes", {}) if "gen_ai.tool.name" not in attrs: continue parent = s.get("parent_span_id") if parent and parent in by_id: continue call_id = attrs.get("gen_ai.tool.call.id") if call_id and call_id in call_ids_by_agent: raise OrphanedToolSpanError( f"tool span {s['span_id']} (tool={attrs.get('gen_ai.tool.name')}, " f"call_id={call_id}) has no resolvable parent but matches agent span " f"{call_ids_by_agent[call_id]}; trace context was not propagated across " f"the tool-call boundary and token cost cannot be attributed by parentage" ) raise OrphanedToolSpanError( f"tool span {s['span_id']} (tool={attrs.get('gen_ai.tool.name')}) is a root " f"span with no matching agent call id; cost attribution is undefined" ) def attribute_cost(spans: list[dict]) -> dict: validate_agent_trace(spans) totals: dict[str, int] = {} for s in spans: attrs = s.get("attributes", {}) tokens = int(attrs.get("gen_ai.usage.input_tokens", 0)) + int( attrs.get("gen_ai.usage.output_tokens", 0) ) if tokens: totals[s["trace_id"]] = totals.get(s["trace_id"], 0) + tokens return totals ```