Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

Traces, steps, and context

How the SDK carries identity through your call stack, what survives an async boundary, and where it stops.

ExplanationLast updated

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 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.

Four context layers resolving into one x-tt-* header mapFour stacked layers feed one resolution step. From the bottom: the init layer set once at startup, the trace scope, the step scope, and per-call arguments at the top. Each layer can set the same field, and the highest layer that sets a field wins, so a user set on the call overrides one set on the trace. Tags merge key by key rather than replacing wholesale. Identity — the trace id, step id and parent id — comes only from the scopes themselves and cannot be set from the init layer or a per-call argument. Resolution happens when headers() is called, over an immutable snapshot, so changing a scope afterwards cannot alter a request already in flight.Context layershighest setting layer winscall argumentsheaders(user=…, tier=…)higheststep scopestep(agent=…, step_class=…)trace scopetrace(agent=…, user=…)init layerinit(agent=…) / envlowestsnapshotheaders()resolve + cap+ charset guardx-tt-agentx-tt-userx-tt-tag-…one mapIdentity is structuraltrace id, step id and parent id come from thescopes only — no layer can set them as a valuetags merge per key; scalars replace

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:

  1. A scalar is replaced by the highest layer that sets it. agent on a step beats agent on the trace, which beats agent on init().
  2. A field no layer sets stays unset, rather than falling back to a default.
  3. Tags merge per key, not wholesale. A tag set on the trace survives a step that sets a different tag.
  4. Per-call arguments are ephemeral. They apply to that one call and are not written back to any scope.
  5. 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.
  6. 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"))
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 ids

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.