# Which invariants prevent trace reconstruction across A2A hops when OTel GenAI spans are correlated by trace_id alone **Date of Record:** 2026 November 12 **Scope:** Applies to multi-agent systems where an orchestrator invokes downstream agents via the Agent-to-Agent (A2A) protocol and each agent emits OpenTelemetry GenAI semantic-convention spans. It does not apply to single-process tool-calling loops, nor to systems where a single collector-side processor rewrites all spans into one resource before export. ## Core Distinction The distinction that matters is between **trace identity** and **causal reachability**. A `trace_id` is a 128-bit label propagated as an opaque string; it asserts only that two spans belong to the same logical transaction, not that one span's execution causally produced the other's. Across an A2A hop, the orchestrator's `gen_ai.chat` span and the downstream agent's `gen_ai.chat` span will share a `trace_id` if the A2A client injects `traceparent` — but the *parent-child edge* between them is carried separately, in the W3C `parent-id` (the `span_id` of the caller's span) and in A2A's own `task_id`/`context_id` metadata. Correlation by `trace_id` alone reconstructs a *set* of spans, not a *tree*. The invariant that fails silently is: **every remote GenAI span's `parent_span_id` must resolve to a span that was exported with the same `trace_id`, or the reconstruction is a forest, not a trace.** ## What Was Observed In a three-hop pipeline (orchestrator → retrieval agent → synthesis agent), the orchestrator emitted a `gen_ai.chat` span with `gen_ai.operation.name=chat`, `gen_ai.request.model`, and a `send` message event. The A2A client serialized the outbound request with a `traceparent` header whose `parent-id` was the orchestrator span's `span_id`. The retrieval agent, however, ran under a framework whose A2A server handler created a **new root span** because its propagator was configured with `OTEL_PROPAGATORS=baggage` only — `tracecontext` was omitted. The retrieval agent's GenAI spans therefore carried a *fresh* `trace_id` (all-zero parent, new 128-bit trace), and the A2A request's `traceparent` was treated as an unknown header. Downstream, the synthesis agent *did* honor `tracecontext` and produced spans whose `parent_span_id` pointed at the retrieval agent's root span — but that root span lived in a different trace. The collector exported all three traces correctly; the *backend* joined them by `trace_id` and produced three disjoint traces. The orchestrator's trace contained only its own span plus a dangling `send` event; the retrieval trace was a standalone root; the synthesis trace was a two-node tree whose root had no parent. End-to-end reconstruction failed not because any span was lost, but because the parent edge crossed a trace boundary that the propagator had severed. A second, subtler failure appeared in the same run: the A2A `context_id` (a logical conversation identifier, distinct from `trace_id`) was consistent across all three agents, while `trace_id` was not. Consumers that keyed on `context_id` recovered partial reconstruction; consumers that keyed on `trace_id` did not. The two identifiers are orthogonal, and conflating them is the root cause of most "missing span" reports in A2A deployments. ## Condition of Validity Correlation by `trace_id` is valid only when **all** of the following hold: 1. Every hop's A2A client injects `traceparent` (W3C Trace Context) and every A2A server extracts it into the active context *before* creating its first span. 2. The `tracecontext` propagator is present in `OTEL_PROPAGATORS` on every process — not merely `baggage`, not merely `b3`. 3. The remote agent's first span is a **child** of the extracted context, not a new root. Frameworks that call `tracer.start_as_current_span(..., context=Context())` or that reset context in middleware violate this. 4. Sampling decisions are propagated via the `traceparent` `trace-flags` byte, or the sampler is `parentbased_*`. A remote agent with an independent `AlwaysOn` sampler will emit spans the orchestrator's `parentbased_traceidratio` sampler dropped, producing a trace that appears complete locally but is half-missing end-to-end. 5. The backend does not re-key on `context_id` or `task_id` when `trace_id` collisions or splits occur. Condition 4 is the one most often missed: even with perfect propagation, a *sampling* divergence creates the same forest topology as a propagation failure, and the two are indistinguishable from the exported spans alone. ## Decision Boundary When reconstructing a multi-agent trace, the operator must decide between two strategies: - **Strict tree reconstruction** (join on `trace_id`, then validate parent edges): correct, but fails closed. Any hop that violated conditions 1–4 yields a forest, and the reconstruction must report the break rather than silently stitching. - **Context-keyed reconstruction** (join on A2A `context_id`, order by timestamp): tolerant of propagation gaps, but cannot distinguish concurrent sub-tasks within one context, and cannot compute true critical-path latency because causal edges are inferred from wall-clock ordering. The boundary is: **use strict tree reconstruction when the causal path is the object of analysis (latency attribution, failure root-cause, span-level SLOs); use context-keyed reconstruction only for conversation-level grouping, and never report its inferred edges as causal.** A trace that is a forest must be *labeled* a forest, not repaired by assumption. ## Operational Effect The operational consequence is that "trace completeness" is not a property of the collector or the backend but of the *propagator configuration across every hop's runtime*. A single agent deployed with a default `OTEL_PROPAGATORS` (which historically defaulted to `baggage` in some SDK distributions) silently converts every downstream trace into a new root. Because the spans are all exported and all carry valid GenAI attributes, dashboards that count `gen_ai.chat` spans by `trace_id` show *more* traces, not missing spans — the failure looks like fan-out, not loss. The corrective invariant to enforce in CI is a synthetic A2A round-trip assertion: a canary orchestrator invokes a canary agent, and the test asserts that the agent's root span's `parent_span_id` equals the orchestrator's `span_id` and that both `trace_id`s are equal. This catches propagator misconfiguration, context-reset middleware, and sampler divergence in one check. ```python import os from dataclasses import dataclass from typing import Optional class TraceReconstructionError(RuntimeError): """Raised when an A2A hop severs the causal edge required for end-to-end reconstruction.""" pass @dataclass(frozen=True) class Span: trace_id: str span_id: str parent_span_id: Optional[str] operation: str context_id: Optional[str] = None def assert_causal_edge(caller: Span, callee: Span) -> None: """Validate that the callee's first span is a child of the caller across an A2A hop.""" if caller.trace_id != callee.trace_id: raise TraceReconstructionError( f"trace_id split across A2A hop: caller={caller.trace_id!r} " f"callee={callee.trace_id!r}; propagator likely missing 'tracecontext' " f"(check OTEL_PROPAGATORS on the callee process)" ) if callee.parent_span_id != caller.span_id: raise TraceReconstructionError( f"parent edge broken: callee.parent_span_id={callee.parent_span_id!r} " f"!= caller.span_id={caller.span_id!r}; callee may have started a new root" ) def reconstruct(spans: list[Span]) -> dict[str, list[Span]]: """Group spans by trace_id and verify every non-root parent resolves within its trace.""" by_trace: dict[str, list[Span]] = {} for s in spans: by_trace.setdefault(s.trace_id, []).append(s) for trace_id, group in by_trace.items(): ids = {s.span_id for s in group} for s in group: if s.parent_span_id is not None and s.parent_span_id not in ids: raise TraceReconstructionError( f"dangling parent in trace {trace_id!r}: span {s.span_id!r} " f"references parent {s.parent_span_id!r} not present in trace; " f"reconstruction is a forest, not a tree (do not stitch by context_id)" ) return by_trace def check_propagator_env(env: dict[str, str]) -> None: propagators = env.get("OTEL_PROPAGATORS", "") if "tracecontext" not in [p.strip() for p in propagators.split(",")]: raise TraceReconstructionError( f"OTEL_PROPAGATORS={propagators!r} lacks 'tracecontext'; " f"every A2A hop will start a new root span and break end-to-end correlation" ) ```