Traces, steps, and context
How the SDK carries identity through your call stack, what survives an async boundary, and where it stops.
On this page
The reason to instrument an agent is that “we spent $2,400 on GPT-4o last week” is not an actionable sentence. “The retry loop in the summariser spent $1,900 of it” is. Getting from one to the other means every request has to arrive labelled with where in your program it came from.
You could thread a trace id through every function that might make an LLM call. Most people
try this once. The SDK’s alternative is two scopes and an ambient context that follows your
call stack, including across await.
This page is about how that propagation works and where it stops. For the exact header names and value rules, see from your call to the wire.
Two scopes, and what each one means
A trace is one unit of work: a user turn, a job, an incoming HTTP request. A step is one stage inside it, such as a planning call, a tool loop, or a summarisation pass.
Opening either is a block. Every request made inside the block carries its labels, however deep in your call stack the request is actually made.
with tt.trace(agent="support-bot", user="u-42"):
with tt.step(agent="planner", step_class="user_turn"):
plan = call_the_model() # carries both scopes' labels
with tt.step(agent="writer"):
draft = call_the_model() # a sibling step, same traceawait tt.trace({ agent: "support-bot", user: "u-42" }, async () => {
await tt.step({ agent: "planner", stepClass: "user_turn" }, async () => {
const plan = await callTheModel(); // carries both scopes' labels
});
await tt.step({ agent: "writer" }, async () => {
const draft = await callTheModel(); // a sibling step, same trace
});
});Python’s scopes are context managers and also work as decorators; observe(...) is decorator
sugar over step. TypeScript’s are callback-scoped, which is the only form that can hold an
async context frame. That difference is idiomatic rather than accidental, and it is the one
shape difference you will feel between the bindings.
Nesting steps builds a parent edge. An inner step’s parent_id defaults to the enclosing
step’s id at the moment it opens, which is what lets the server reconstruct a tree.
Four layers, and which one wins
Labels can be set in four places. They stack, and the highest layer that sets a field wins.
Read the stack bottom to top. The init layer is your process-wide default; the trace and step
scopes are ambient; per-call arguments sit on top. Everything is resolved when headers() is
called, over a snapshot, and the arrow out to the right is the single header map that results.
The rules are worth stating one at a time, because precedence is what people get wrong:
- A scalar is replaced by the highest layer that sets it.
agenton a step beatsagenton the trace, which beatsagentoninit(). - A field no layer sets stays unset, rather than falling back to a default.
- Tags merge per key, not wholesale. A tag set on the trace survives a step that sets a different tag.
- Per-call arguments are ephemeral. They apply to that one call and are not written back to any scope.
- Identity is structural. Trace, step and parent ids come from the scopes themselves. No
layer can set them as an ordinary value, and
step(parent=…)at scope-open is the only override. - The snapshot is taken at
headers()time. Mutating a scope afterwards cannot change a request already in flight.
Here is all six in one run. The init layer sets an agent, a user and two tags; each scope then adds or overrides:
tt.init(agent="init-agent", user="init-user", tags={"env": "init", "a": "init"})
1) init only {'x-tt-agent': 'init-agent', 'x-tt-user': 'init-user',
'x-tt-tag-a': 'init', 'x-tt-tag-env': 'init'}
2) + trace {'x-tt-trace-id': '18ce3f…', 'x-tt-agent': 'trace-agent',
'x-tt-user': 'init-user', 'x-tt-tag-a': 'init',
'x-tt-tag-b': 'trace', 'x-tt-tag-env': 'trace'}
3) + step {…, 'x-tt-step-id': '3a75fd…', 'x-tt-agent': 'step-agent',
'x-tt-tag-c': 'step', 'x-tt-tag-env': 'trace'}
4) + call override {…, 'x-tt-agent': 'call-agent', 'x-tt-tag-env': 'call'}
5) call was ephemeral {…, 'x-tt-agent': 'step-agent', 'x-tt-tag-env': 'trace'}
Trace ids are elided for width; everything else is verbatim. Watch three things. x-tt-user
stays init-user the whole way down, because no higher layer ever set it. The env tag climbs
init → trace → call while a, b and c accumulate beside it, which is the per-key merge.
And line 5 is the same call as line 3. The override in line 4 left nothing behind.
Crossing an async boundary
Ambient context is stored in the runtime’s own async-context mechanism: contextvars in
Python, AsyncLocalStorage in Node. Both follow a task across await, so concurrent work
under one trace keeps its own step identity without any coordination from you.
async def worker(name):
with tt.step(agent=name):
await asyncio.sleep(0.01)
return tt.headers()
with tt.trace(agent="fanout", user="u-1"):
results = await asyncio.gather(worker("a"), worker("b"), worker("c"))const worker = (name) =>
tt.step({ agent: name }, async () => {
await new Promise((r) => setTimeout(r, 10));
return tt.headers();
});
await tt.trace({ agent: "fanout", user: "u-1" }, async () =>
Promise.all([worker("a"), worker("b"), worker("c")]),
);a 8d2f658a2b8b471f trace= f0d0b75a
b 881ead37c1f12ec4 trace= f0d0b75a
c a00fba8fe1fde750 trace= f0d0b75a
Three distinct step ids under one trace id, from three coroutines that ran concurrently. Nothing is shared through a process global, which matters: a global “current step” slot under fan-out hands one task’s identity to another, and the resulting rows are wrong in a way that looks plausible.
current_step() returns the innermost open explicit step, or nothing outside any step. It
does not return the implicit step an adapter opens per request; that one is not yours to nest
under.
Where propagation stops
Two boundaries are worth knowing before you meet them.
A new thread or process does not inherit the context. contextvars and AsyncLocalStorage
both follow async tasks, not OS threads you start yourself. If you hand work to a thread pool
or a subprocess, open a scope on the other side, passing the trace id explicitly if you want the
work to join the same trace.
A JavaScript runtime without AsyncLocalStorage cannot propagate at all. The TypeScript
package ships two builds. The node export condition gets the AsyncLocalStorage carrier;
every other runtime, including edge workers, browsers, and bundles resolved through the
default condition, gets a carrier that runs your callback and stores nothing.
It says so, once:
TT-SDK-018: AsyncLocalStorage is unavailable on this runtime - trace()/step() run but do not
propagate ambient context; rows are standalone. Pass identity explicitly (headers()) or use
the node build.
What still works on a runtime with no async context
The scopes still run your callback, and the init and call layers still resolve. What is lost is the middle: the trace and step layers, and identity.
headers() inside a trace scope { 'x-tt-agent': 'svc' } # from init(), not the trace
headers({ user: 'u-1', tier: 'fast' })
{ 'x-tt-agent': 'svc', 'x-tt-tier': 'fast',
'x-tt-user': 'u-1' }The agent: "planner" set on the trace scope in that run is absent, and there are no
x-tt-trace-id or x-tt-step-id headers at all. Rows still arrive and are still costed; they
are standalone rather than grouped.
The diagnostic’s advice to “pass identity explicitly” is looser than it sounds. Business
dimensions can be passed per call, as above. Trace and step identity cannot. It is
structural, so there is no call-layer route to it, and the scope objects are not reachable on a
runtime where the carrier stores nothing. If you need grouped traces on an edge runtime, the
answer is the node build, via a bundler condition or a runtime compatibility flag.
Adopting ids from OpenTelemetry
If you already run OpenTelemetry, you probably do not want a second, unrelated trace id. An opt-in bridge makes a scope adopt the ids of the OTel span active when it opens, so both systems name the same work.
from tokentriage.adapters import otel
dispose = otel.bridge() # scopes now adopt the active span's idsimport { bridge } from "tokentriage/adapters/otel";
const dispose = bridge();You install it; it is not inferred. Parent edges are never adopted. Those come only from your own scope nesting, because W3C context carries no explicit parent semantics. An inactive or all-zero span falls through to normal minting.
One consequence catches people: adoption reads the active span, so several scopes opened under one span all share that span’s id. That is the truthful rendering, since they did happen inside one span, and it does not affect cost attribution: the server keys every receipt by its own request id rather than by step id. If you want distinct ids per step, give each step its own OTel span.