Context propagation in async Node.js

2 min read

Short answer

Context is what tells a new span which trace and parent it belongs to. Inside one Node.js process, a context manager built on AsyncLocalStorage carries the active context across await and callbacks. Between processes, a propagator writes it into the request, by default as a W3C traceparent header, and the receiving service reads it back.

On this page

Run the same agent code twice. Once, send 8453 sits under its tool call. Once, it’s a trace of its own with no parent. The only difference is one line of setup.

Context propagation in Node.js: with a context manager the send span stays under its tool call, without one each span starts its own trace, and between processes the context travels in a W3C traceparent header.

Two kinds of propagation

Inside one process Between processes
Problem Which span is active after an await? Which trace does this request belong to?
Solved by A context manager A propagator
In Node.js AsyncLocalStorage traceparent header (W3C Trace Context)

The context propagation docs define propagation as moving context between services and processes. In Node.js, the in-process half matters just as much, because nearly every agent tool awaits something.

What breaks across await?

A tool that awaits a signer, then starts the send span. The send span takes whatever context is active at that moment:

import { context, propagation } from '@opentelemetry/api';
async function sendTransaction() {
await new Promise((r) => setTimeout(r, 10)); // e.g. signing
tracer.startSpan('send 8453').end(); // parent: whatever is active now
const headers: Record<string, string> = {};
propagation.inject(context.active(), headers); // what an RPC call would carry
return headers;
}
const headers = await tracer.startActiveSpan('execute_tool pay_vendor', async (tool) => {
const h = await sendTransaction();
tool.end();
return h;
});

With only a tracer provider set, there’s no context manager:

send 8453 7e12c070c16890b48f9cde6127b8a2d2 (root)
execute_tool pay_vendor a2cc05c5c8e58797af0ba58aaf725791 (root)
{}

With provider.register(), which installs an AsyncLocalStorage context manager and the W3C propagators for trace context and baggage:

send 8453 d84e74f116506e13d7ce44333b3f95f5 67347fa8c7938dd3
execute_tool pay_vendor d84e74f116506e13d7ce44333b3f95f5 (root)
{
traceparent: '00-d84e74f116506e13d7ce44333b3f95f5-67347fa8c7938dd3-01'
}

Without a context manager, context.active() always returns the root context (JS context docs). No error, no warning. Just two traces where you expected one, and no header for the next service.

What’s in traceparent?

00-<trace id>-<parent span id>-<flags>. The trace ID is the one the whole run shares. The span ID is the span that made the call. Flags 01 means sampled. The receiving service starts its spans as children of that span.

FAQ

How does OpenTelemetry keep context across async calls in Node.js?

With a context manager. NodeTracerProvider.register() installs one based on AsyncLocalStorage. Without it, context.active() always returns the root context, so every new span starts its own trace.

What does the traceparent header contain?

A version, the trace ID, the parent span ID and trace flags, separated by dashes, for example 00-a0892f3577b34da6a3ce929d0e0e4736-f03067aa0ba902b7-01.

What does context.with do?

It runs a callback with the given context as the active one, synchronously, and returns the callback's result. With a context manager installed, async work started inside the callback keeps that context.

Go further

Type to search the docs, Learn topics and the blog.