A useful agent trace is not a list of timestamps. It is a causal tree.
When a TypeScript agent retrieves documents in parallel, calls a model, retries a tool, and falls back to cached data, each operation needs a trace ID, its own span ID, and the correct parent span. Without those relationships, completion order is easily mistaken for execution structure. Three tools finishing at 80 ms, 100 ms, and 120 ms tell you nothing about why they ran or which agent decision spawned them.
The normal JavaScript call stack cannot serve as that tree. Async work may resume later, execute concurrently, or outlive the function that scheduled it. Tracing needs an explicit logical context that survives Promise chains, setTimeout, event emitters, and process boundaries.
Node.js ships AsyncLocalStorage for exactly this purpose. It has been production-ready since v13.10.0, but most agent observability examples still pass context manually or lose parent-child relationships at async boundaries.
The Context Propagation Problem
Agent workflows interleave async operations. A typical sequence:
- Agent decides to retrieve documents.
- Three parallel tool calls fire:
search_docs,search_tickets,load_account. - Each tool streams results.
- Agent synthesizes a response.
If you log timestamps, you see:
80 ms search_tickets completed
100 ms load_account completed
120 ms search_docs completed
The execution tree you need:
research_agent
└─ parallel_retrieval
├─ search_docs
├─ search_tickets
└─ load_account
Both views are useful. Only the tree preserves the relationship between the agent decision and its child tools.
AsyncLocalStorage Mechanics
AsyncLocalStorage carries a value through asynchronous resources without manual passing. Each async branch inherits the context from its parent.
The core type:
type TraceContext = {
traceId: string;
parentSpanId: string | null;
};
When a new span starts:
- Read the current context.
- Record
parentSpanId. - Create its own
spanId. - Run child work inside a new context whose parent is that span.
A minimal tracer:
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
const asyncLocalStorage = new AsyncLocalStorage<TraceContext>();
export function startTrace<T>(fn: () => T): T {
const traceId = randomUUID();
return asyncLocalStorage.run({ traceId, parentSpanId: null }, fn);
}
export function startSpan<T>(name: string, fn: () => T): T {
const context = asyncLocalStorage.getStore();
if (!context) throw new Error('No trace context');
const spanId = randomUUID();
const span = {
traceId: context.traceId,
spanId,
parentSpanId: context.parentSpanId,
name,
startTime: Date.now(),
};
try {
const result = asyncLocalStorage.run(
{ traceId: context.traceId, parentSpanId: spanId },
fn
);
emitSpan({ ...span, endTime: Date.now(), status: 'ok' });
return result;
} catch (error) {
emitSpan({ ...span, endTime: Date.now(), status: 'error', error });
throw error;
}
}
function emitSpan(span: any) {
// Send to collector, write to file, or buffer in memory
console.log(JSON.stringify(span));
}
This propagates context automatically. No need to thread a context parameter through every function signature.
Handling Async Boundaries
AsyncLocalStorage works across:
- Promise chains: Context flows through
.then()andawait. setTimeoutandsetImmediate: Timers inherit the context active when scheduled.- Event emitters: Listeners inherit the context from the emitter registration site.
- Worker threads: Context does not cross thread boundaries. You must serialize and reconstruct it.
For streaming LLM responses, wrap the stream consumer:
export async function streamSpan<T>(
name: string,
stream: AsyncIterable<T>,
handler: (chunk: T) => void
): Promise<void> {
return startSpan(name, async () => {
for await (const chunk of stream) {
handler(chunk);
}
});
}
Each chunk callback runs inside the span context.
Performance Overhead
AsyncLocalStorage adds a small cost to every async operation. Benchmarks from the Node.js team show 1-3% overhead in high-throughput scenarios. For agent workloads dominated by network I/O and LLM latency, this is negligible.
Critical paths:
- Tool call loops: If you spawn 50 parallel tool calls per agent turn, context propagation adds microseconds per call.
- LLM streaming: Token-by-token callbacks inherit context. The overhead is smaller than JSON parsing.
- Parallel tasks:
Promise.all()andPromise.race()preserve context for each branch.
If you measure a bottleneck, profile first. Most agent traces spend more time serializing spans than propagating context.
Serialization and Storage
A causal tree is queryable only if you can reconstruct parent-child relationships. Each span needs:
| Field | Purpose |
|---|---|
traceId | Groups all spans in a single agent execution |
spanId | Unique identifier for this operation |
parentSpanId | Links to the parent span (null for root) |
name | Human-readable operation label |
startTime | Nanosecond or millisecond timestamp |
endTime | Completion timestamp |
status | ok, error, or timeout |
attributes | Tool name, model ID, token count, etc. |
Store spans in a time-series database (ClickHouse, TimescaleDB) or a document store (MongoDB, DynamoDB). Index on traceId and parentSpanId to reconstruct trees efficiently.
For cross-process traces, serialize the context and pass it in HTTP headers or message metadata:
export function injectContext(headers: Record<string, string>): void {
const context = asyncLocalStorage.getStore();
if (context) {
headers['x-trace-id'] = context.traceId;
headers['x-parent-span-id'] = context.parentSpanId || '';
}
}
export function extractContext(headers: Record<string, string>): TraceContext | null {
const traceId = headers['x-trace-id'];
const parentSpanId = headers['x-parent-span-id'] || null;
return traceId ? { traceId, parentSpanId } : null;
}
Start a new span in the downstream service using the extracted context.
Finalization and Error Handling
Spans must close even if the operation throws or times out. Use try-finally or a wrapper that guarantees finalization:
export async function spanAsync<T>(
name: string,
fn: () => Promise<T>
): Promise<T> {
const context = asyncLocalStorage.getStore();
if (!context) throw new Error('No trace context');
const spanId = randomUUID();
const span = {
traceId: context.traceId,
spanId,
parentSpanId: context.parentSpanId,
name,
startTime: Date.now(),
};
try {
const result = await asyncLocalStorage.run(
{ traceId: context.traceId, parentSpanId: spanId },
fn
);
emitSpan({ ...span, endTime: Date.now(), status: 'ok' });
return result;
} catch (error) {
emitSpan({ ...span, endTime: Date.now(), status: 'error', error });
throw error;
}
}
For long-running spans, emit periodic heartbeat events to detect stalls.
Pluggable Sinks
The emitSpan function is the integration point. Replace console.log with:
- OpenTelemetry exporter: Send spans to Jaeger, Honeycomb, or Datadog.
- File buffer: Write newline-delimited JSON to disk for batch upload.
- In-memory ring buffer: Keep the last N spans for debugging.
- HTTP POST: Stream spans to a custom collector.
Example OpenTelemetry integration:
import { trace } from '@opentelemetry/api';
function emitSpan(span: any) {
const tracer = trace.getTracer('agent-tracer');
const otelSpan = tracer.startSpan(span.name, {
startTime: span.startTime,
});
otelSpan.setAttributes(span.attributes || {});
otelSpan.end(span.endTime);
}
Failure Modes
Common pitfalls:
- Lost context at process boundaries: Worker threads and child processes do not inherit
AsyncLocalStorage. Serialize and reconstruct context explicitly. - Span leaks: If you forget to close a span, it never emits. Use
finallyblocks or a span registry with timeouts. - Clock skew: If spans cross machines, use a monotonic clock or synchronized timestamps (NTP, PTP).
- Circular references: If you log the entire span object (including error stack traces), JSON serialization may fail. Sanitize before emitting.
Technical Verdict
Use TypeScript async context tracking when:
- You need causal relationships between agent decisions and tool calls.
- Your agent workflow spans multiple async operations (parallel retrieval, streaming, retries).
- You want observability without threading a context parameter through every function.
- You control the Node.js runtime and can use
AsyncLocalStorage.
Avoid it when:
- Your agent runs in a browser (no
AsyncLocalStorageequivalent in standard Web APIs). - You need zero-overhead tracing (manual context passing is faster).
- Your workflow is purely synchronous (call stack is sufficient).
- You already have a mature OpenTelemetry setup and just need to instrument a few functions.
The core insight: async context propagation turns flat logs into queryable trees. The overhead is small. The debugging value is high.