June 2026
Your async trace tree is lying about who called whom
In a concurrent model gateway the active span is the wrong parent, so I anchor every span to the server span captured once at request entry.
The trace looked fine until I read it from the bottom. A single request to the gateway I build had produced a tidy tree, and three levels down, nested under the model call, sat the guardrail check that was supposed to run before that call ever happened. The timestamps disagreed with the tree: the guardrail had closed milliseconds before the model span opened, yet there it sat, drawn as the model call’s child.
The gateway runs many requests on one event loop, and each span had been parented to whatever span was active when it opened. That is the default everyone reaches for: open a span, inherit the current context, close it. In a concurrent service the active span belongs to whichever coroutine last claimed the context on that task, which is rarely the request you think you are tracing.
Ambient parenting was wrong at two points in the same request. When the pre-call guardrail span opened, the active span was left over from setup work unrelated to it, so the guardrail attached to a stranger. When the model-call span opened a few milliseconds later, the guardrail was still the most recent thing to touch the context, so the model call attached to the guardrail. Two wrong parents in one tree, and the shape it drew implied a causal chain that never ran.
Nothing here is a propagation bug. The context was being carried and restored exactly as designed. The active span is an accurate record of what the runtime is executing at a given instant, and for a request that runs start to finish on one stack that record is also the call tree. A gateway is not that. Pre-call guardrails, parallel model calls, and post-call checks mean the span that is active when you open a child is an accident of scheduling. It says nothing about structure.
So I stopped parenting to the ambient span. At request entry the gateway captures the server span once and stashes it as an explicit anchor. A call I named set_request_root_span does the capture; resolve_request_span_context hands that anchor back to anything that needs a parent. Every span in the request parents to the anchor instead of to whatever happens to be active. The active span tells you what is running right now; it does not tell you which request the work belongs to.
This makes the guardrail and the model call siblings under the server span rather than one buried inside the other. The pre-call guardrail has to be a sibling, because it runs before the model call exists, and you cannot nest a span inside a parent that has not been created yet. The tree no longer describes the order coroutines woke up. What it shows now is the request: one server span, the guardrail and the model call hanging off it side by side, each with its own timing.
That is the real change. The model-call spans follow the lifecycle of the request. They no longer follow the nesting of the async calls that happen to produce them, which only records how the runtime scheduled work on the loop. A person reading the trace wants to know which checks ran, which model calls ran, in what order, under one root. The anchor gives them that.
The anchor encodes lifecycle cleanly as long as the request stays flat: a few guardrails, a few model calls, all of them children of one server span. A request that fans out into an orchestration loop breaks that assumption. When the model is called, reads a tool result, and is called again, the second model call has a real causal parent, the first call, and also a lifecycle parent, the request root. The anchor sends it to the root. On a multi-turn loop that flattens a chain of dependent calls into a row of siblings, and whether a nested model call should hang off the anchor or off the call that caused it has no single answer, because lifecycle and causality have stopped pointing at the same span.