# Traces, steps, and context

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

Source: https://tokentriage.com/docs/sdks/context

---
import Callout from '../../../components/Callout.astro';
import NextSteps from '../../../components/NextSteps.astro';
import Details from '../../../components/Details.astro';
import CodeTabs from '../../../components/CodeTabs.astro';

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](/docs/sdks/emission).

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

<CodeTabs>
  <Fragment slot="python">
  ```python
  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
  ```
  </Fragment>
  <Fragment slot="typescript">
  ```ts
  await 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
    });
  });
  ```
  </Fragment>
</CodeTabs>

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.

<Callout type="note">
Nesting a `trace` inside a `trace` mints a brand-new trace and warns with `TT-SDK-007`. Trace
ids are not inherited across trace scopes, so the inner work becomes a separate unit rather
than a subtree. If you meant a subtree, you wanted a step.
</Callout>

## Four layers, and which one wins

Labels can be set in four places. They stack, and the highest layer that sets a field wins.

import SdkPrecedenceDiagram from '../../../components/SdkPrecedenceDiagram.astro';

<SdkPrecedenceDiagram />

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:

```python
tt.init(agent="init-agent", user="init-user", tags={"env": "init", "a": "init"})
```

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

<Callout type="warning">
`tier` and `cache` are the two fields that can **only** be set per call. They are not accepted
on a scope, because a routing-tier override that quietly applies to every request for the rest
of a trace is a mistake you find in a bill rather than in a diff.
</Callout>

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

<CodeTabs>
  <Fragment slot="python">
  ```python
  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"))
  ```
  </Fragment>
  <Fragment slot="typescript">
  ```ts
  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")]),
  );
  ```
  </Fragment>
</CodeTabs>

```text
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:

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

<Details summary="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.

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

</Details>

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

<CodeTabs>
  <Fragment slot="python">
  ```python
  from tokentriage.adapters import otel
  dispose = otel.bridge()   # scopes now adopt the active span's ids
  ```
  </Fragment>
  <Fragment slot="typescript">
  ```ts
  import { bridge } from "tokentriage/adapters/otel";
  const dispose = bridge();
  ```
  </Fragment>
</CodeTabs>

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.

<NextSteps items={[
  { title: 'From your call to the wire', href: '/docs/sdks/emission', description: 'Value rules, byte caps, the closed header set, and traceparent.' },
  { title: 'Adapters', href: '/docs/sdks/adapters', description: 'What injects the headers for you, and how to instrument a client we do not ship for.' },
  { title: 'Reading the receipt', href: '/docs/sdks/receipts', description: 'What comes back, and which scope holds it.' },
  { title: 'What identity is on the wire', href: '/docs/build/observability-identity', description: 'The server-side view of the same identity, and what it groups.' }
]} />
