DEV Community

Cover image for Building TypeScript-Native Observability: Async Context and Execution Flow
Raju Dandigam
Raju Dandigam

Posted on

Building TypeScript-Native Observability: Async Context and Execution Flow

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.

This article builds a small Node.js tracer to demonstrate the core mechanics: immutable async context, parent-child spans, reliable finalization, and a pluggable sink. It is intentionally smaller than a production observability library, but the design avoids several common mistakes found in minimal examples.

Completion Order Is Not Causality

Imagine three tools running in parallel:

80 ms   search_tickets completes
100 ms  load_account completes
120 ms  search_docs completes
Enter fullscreen mode Exit fullscreen mode

Those timestamps describe completion order. The execution tree describes why the operations existed:

research_agent
└─ parallel_retrieval
   ├─ search_docs
   ├─ search_tickets
   └─ load_account
Enter fullscreen mode Exit fullscreen mode

Both views are useful, but only the tree preserves the relationship between the agent decision and its child tools.

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 therefore needs an explicit logical context.

The Context We Need

Each asynchronous branch needs two values:

type TraceContext = {
  traceId: string;
  parentSpanId: string | null;
};
Enter fullscreen mode Exit fullscreen mode

When a new span starts, it reads the current context, records parentSpanId, creates its own spanId, and runs child work inside a new context whose parent is that span.

In Node.js, AsyncLocalStorage provides the propagation primitive. It carries a value through normal asynchronous resources without adding trace parameters to every application function.

Do not mutate one shared context object. Parallel siblings would race to replace the current span. Create a new context value for every nested span instead.

Define a Small Event Model

Use separate start and end events so a consumer can identify spans that never completed.

type SpanKind = 'run' | 'model' | 'tool' | 'retrieval' | 'decision';
type SpanStatus = 'ok' | 'error';

type SpanStarted = {
  version: 1;
  event: 'span_started';
  traceId: string;
  spanId: string;
  parentSpanId: string | null;
  name: string;
  kind: SpanKind;
  startedAt: string;
};

type SpanEnded = {
  version: 1;
  event: 'span_ended';
  traceId: string;
  spanId: string;
  endedAt: string;
  durationMs: number;
  status: SpanStatus;
  errorCategory?:
    | 'timeout'
    | 'validation'
    | 'authorization'
    | 'dependency'
    | 'unknown';
  metadata?: Record<string, string | number | boolean | null>;
};

type SpanEvent = SpanStarted | SpanEnded;
Enter fullscreen mode Exit fullscreen mode

The schema stores a controlled error category rather than an exception message or stack. Metadata should also be operation-specific in a production design; the flat record keeps this example readable.

Keep Persistence Behind a Sink

The tracer should not own a global event array. Long-running processes would retain every trace in memory, and tests could accidentally read events from unrelated runs.

Use a sink contract instead:

export interface TraceSink {
  enqueue(event: SpanEvent): boolean;
  flush(options?: { timeoutMs?: number }): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

enqueue() is deliberately synchronous and non-throwing. It places the event in a bounded buffer and returns false when the event cannot be accepted. The sink handles batching and persistence outside the application’s critical path.

A production sink should expose accepted, dropped, retried, and failed event counts. “Tracing must not crash the request” should not become “tracing may fail silently.”

Build the Tracer

The tracer below scopes context with AsyncLocalStorage, emits one end event from a finally block, and keeps sink failure separate from application failure.

import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

type ActiveContext = {
  traceId: string;
  parentSpanId: string | null;
};

type SpanOptions = {
  metadata?: () => SpanEnded['metadata'];
};

function errorCategory(
  error: unknown,
): SpanEnded['errorCategory'] {
  if (!(error instanceof Error)) return 'unknown';
  if (error.name === 'AbortError') return 'timeout';
  if (error.name === 'ValidationError') return 'validation';
  if (error.name === 'AuthorizationError') return 'authorization';
  return 'dependency';
}

export class Tracer {
  private readonly context = new AsyncLocalStorage<ActiveContext>();

  constructor(
    private readonly sink: TraceSink,
    private readonly onDroppedEvent: (event: SpanEvent) => void = () => {},
  ) {}

  private emit(event: SpanEvent): void {
    try {
      const accepted = this.sink.enqueue(event);
      if (!accepted) this.onDroppedEvent(event);
    } catch {
      this.onDroppedEvent(event);
    }
  }

  async run<T>(name: string, work: () => Promise<T>): Promise<T> {
    const traceId = randomUUID();

    return this.context.run(
      { traceId, parentSpanId: null },
      () => this.span(name, 'run', work),
    );
  }

  async span<T>(
    name: string,
    kind: SpanKind,
    work: () => Promise<T>,
    options: SpanOptions = {},
  ): Promise<T> {
    const parent = this.context.getStore();
    if (!parent) throw new Error('span() must run inside run()');

    const spanId = randomUUID();
    const startedAt = Date.now();

    this.emit({
      version: 1,
      event: 'span_started',
      traceId: parent.traceId,
      spanId,
      parentSpanId: parent.parentSpanId,
      name,
      kind,
      startedAt: new Date(startedAt).toISOString(),
    });

    let status: SpanStatus = 'ok';
    let failureCategory: SpanEnded['errorCategory'];

    try {
      return await this.context.run(
        { traceId: parent.traceId, parentSpanId: spanId },
        work,
      );
    } catch (error) {
      status = 'error';
      failureCategory = errorCategory(error);
      throw error;
    } finally {
      this.emit({
        version: 1,
        event: 'span_ended',
        traceId: parent.traceId,
        spanId,
        endedAt: new Date().toISOString(),
        durationMs: Date.now() - startedAt,
        status,
        errorCategory: failureCategory,
        metadata: options.metadata?.(),
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The application exception is rethrown unchanged. A sink problem never enters the try block that classifies application work, so an observability failure cannot turn a successful model call into a failed model span.

The metadata callback runs when the span ends, which is useful when token usage or result counts are not known at start time. Keep the callback deterministic and payload-free.

Trace Parallel Work

All three operations below inherit the parallel_retrieval span as their parent even though they complete at different times.

await tracer.run('research_agent', async () => {
  return tracer.span('parallel_retrieval', 'decision', async () => {
    const [documents, tickets, account] = await Promise.all([
      tracer.span('search_docs', 'retrieval', () => searchDocs()),
      tracer.span('search_tickets', 'tool', () => searchTickets()),
      tracer.span('load_account', 'tool', () => loadAccount()),
    ]);

    return { documents, tickets, account };
  });
});
Enter fullscreen mode Exit fullscreen mode

Each call to span() creates a new immutable context for its own promise chain. Sibling branches never mutate a shared current-span value.

Make Retries Visible

A retry should be a child span, not an overwritten attempt count. That preserves the duration and error category of every attempt.

await tracer.span('load_pricing', 'tool', async () => {
  try {
    return await tracer.span('attempt_1', 'tool', () => {
      return callPricingApi({ timeoutMs: 1_000 });
    });
  } catch {
    try {
      return await tracer.span('attempt_2', 'tool', () => {
        return callPricingApi({ timeoutMs: 3_000 });
      });
    } catch {
      return tracer.span('fallback_to_cache', 'tool', loadCachedPricing);
    }
  }
});
Enter fullscreen mode Exit fullscreen mode
load_pricing
├─ attempt_1          error: timeout
├─ attempt_2          error: dependency
└─ fallback_to_cache  ok
Enter fullscreen mode Exit fullscreen mode

In real application code, catch only the errors that should trigger a retry or fallback. Authentication, validation, and cancellation errors usually need different handling.

Preserve Function Types

A wrapper should retain argument and result types:

type AsyncFn<Args extends unknown[], Result> = (
  ...args: Args
) => Promise<Result>;

function traced<Args extends unknown[], Result>(
  tracer: Tracer,
  name: string,
  kind: SpanKind,
  fn: AsyncFn<Args, Result>,
): AsyncFn<Args, Result> {
  return (...args: Args) => {
    return tracer.span(name, kind, () => fn(...args));
  };
}

const tracedSearch = traced(
  tracer,
  'search_database',
  'tool',
  async (query: string): Promise<string[]> => searchDatabase(query),
);

const results = await tracedSearch('pricing'); // string[]
Enter fullscreen mode Exit fullscreen mode

Methods that depend on this, overloaded functions, and streams need specialized wrappers. Keep those adapters explicit instead of erasing their signatures with any.

Handle Detached and Streaming Work Explicitly

Async context answers “which trace does this work belong to?” It does not decide how long a trace should remain open.

Detached work such as an unawaited background task may continue after the root span ends. Streaming work may continue after a route handler returns a response. Both need an explicit lifecycle policy:

  • Await work that is part of the request’s success criteria.
  • Start a new linked trace for a durable background job.
  • End a streaming span on completion, error, or cancellation.
  • Flush at controlled lifecycle boundaries, not after every event.
  • Do not assume serverless runtimes will keep executing after the response is sent.

Context propagation and lifecycle management are related, but they are not the same problem.

Control Overhead and Backpressure

Trace meaningful boundaries rather than every helper function. Agent runs, retrieval, model calls, tools, policy decisions, retries, and fallbacks usually provide enough structure.

Use a bounded queue. When the sink is slower than the event producer, choose and document a policy: drop newest, drop oldest, apply limited backpressure, or disable tracing for the run. Never allow an unbounded buffer to consume the process.

Sampling should normally happen at the trace level. Independently sampling child spans creates broken trees. Keep complete traces for selected runs, and always consider retaining errors or high-latency outliers through a documented policy.

Test the Execution Model

A tracer is correct only if it survives concurrency and failure tests:

  1. Run two traces concurrently and assert that their span IDs never mix.
  2. Start three Promise.all() siblings and assert that they share the expected parent.
  3. Throw from application work and verify the original error reaches the caller.
  4. Force the sink to reject or throw and verify the application result is unchanged.
  5. Cancel a stream and verify the span ends once with a cancellation status.
  6. Flush the sink and assert that every started span has exactly one end event.
  7. Run tests in parallel workers and verify isolation.

Also test the emitted data policy. Execution trees are valuable even when prompts, outputs, tool arguments, and retrieved content are absent.

Relationship to OpenTelemetry

The concepts map naturally to distributed tracing: a run is a trace, an operation is a span, metadata becomes attributes, and context propagation preserves parentage. A production implementation can translate this event model into OpenTelemetry or another backend.

Building the small version first is still useful. It makes the invariants visible: immutable context, one parent per span, exactly one finalization, bounded persistence, and application behavior independent of sink health.

Final Thought

The difficult part of TypeScript agent tracing is not generating IDs. It is preserving causal structure while asynchronous work branches, completes out of order, retries, streams, and occasionally outlives its original request.

AsyncLocalStorage provides a strong Node.js foundation, but reliable observability also needs lifecycle rules, sink isolation, backpressure, and concurrency tests. With those pieces in place, scattered events become an execution tree that developers can trust.

Top comments (0)