TypeScript support is easy to claim. Publish an npm package, add a few type declarations, and a tracing product can put “JavaScript and TypeScript” on its integration list.
Native support is a higher bar.
AI applications in the TypeScript ecosystem run inside concurrent Node.js servers, serverless functions, edge runtimes, background workers, test runners, and streaming web frameworks. They cross promise chains, callbacks, tool adapters, async iterators, and package boundaries. A useful tracing tool must fit those execution models without breaking types or producing disconnected spans.
The question is therefore not only, “Does this tool have a TypeScript SDK?” It is, “Does it preserve how a TypeScript agent actually runs?”
Consider a small agent:
async function supportAgent(question: string) {
const category = await classifyQuestion(question);
const documents = await retrieveDocuments(question, category);
return generateAnswer(question, documents);
}
The source looks sequential, but a production server may execute hundreds of these functions concurrently. A flat event stream cannot tell which retrieval or model call belongs to which request.
request A: classify -> retrieve -> generate
request B: classify -> retrieve -> generate
The tracer needs a request-level trace ID and a parent span for each nested operation. More importantly, those identifiers must remain available after every asynchronous handoff.
In Node.js, AsyncLocalStorage
is the usual foundation for request-scoped context. It propagates state through normal promise chains and many asynchronous resources, which is much safer than storing a current trace ID in a module-level variable.
import { AsyncLocalStorage } from 'node:async_hooks';
type TraceContext = {
traceId: string;
spanId: string | null;
};
const context = new AsyncLocalStorage<TraceContext>();
export function withTraceContext<T>(
value: TraceContext,
work: () => T,
): T {
return context.run(value, work);
}
export function currentTraceContext(): TraceContext {
const value = context.getStore();
if (!value) throw new Error('No active trace context');
return value;
}
await
by itself does not cause context loss when AsyncLocalStorage
is used correctly. Problems usually appear when instrumentation relies on global mutable state, registers work outside the active context, crosses an unsupported runtime boundary, or integrates with a library that manages its own scheduling.
A TypeScript tracing library should test at least these cases:
The acceptance criterion is simple: every span belongs to exactly one trace and has the expected parent.
Many AI routes return a stream before generation has finished. The HTTP handler may complete from the framework’s perspective while tokens, tool calls, and usage data are still in flight.
A model span should therefore not end merely because the route returned a Response
. It should end when the stream completes, fails, or is cancelled.
type StreamHooks<T> = {
onStart(): Promise<void> | void;
onChunk(chunk: T): Promise<void> | void;
onComplete(): Promise<void> | void;
onError(error: unknown): Promise<void> | void;
onCancel(): Promise<void> | void;
};
async function* traceStream<T>(
source: AsyncIterable<T>,
hooks: StreamHooks<T>,
): AsyncGenerator<T> {
await hooks.onStart();
let completed = false;
try {
for await (const chunk of source) {
await hooks.onChunk(chunk);
yield chunk;
}
completed = true;
await hooks.onComplete();
} catch (error) {
await hooks.onError(error);
throw error;
} finally {
if (!completed) await hooks.onCancel();
}
}
Real integrations also need to avoid double-finalizing a span when an error and cancellation happen close together. They should record time to first chunk, completion status, tool activity, and final token usage without storing every chunk by default.
Streaming support is not a cosmetic feature. Without it, latency is measured incorrectly, cancellations disappear, and partial responses look like successful completions.
“TypeScript runtime” can mean several different environments:
| Environment | Important tracing constraint |
|---|---|
| Long-running Node.js service | Async context, concurrency, graceful flush on shutdown |
| Serverless function | Cold starts, short lifetime, bounded flush time |
| Edge runtime | Web APIs, limited or absent Node built-ins |
| Background worker | Detached jobs, queue context propagation, retries |
| Browser | Bundle size, user privacy, no server credentials |
| Test runner | Isolation across files and parallel workers |
A library that imports node:async_hooks
or node:fs
from its main entry point may fail when bundled for an edge runtime even if those features are never called. Runtime-specific code should live behind explicit exports so bundlers can exclude it.
{
"exports": {
".": "./dist/core.js",
"./node": "./dist/node.js",
"./web": "./dist/web.js"
}
}
The core event model can be portable. Context propagation, persistence, and flush behavior may need runtime-specific implementations.
Framework integrations are valuable when they attach to stable lifecycle hooks rather than patching private internals. For an AI route, useful boundaries include:
Different frameworks expose these boundaries differently. A TypeScript-native tracer should make the manual API first-class, then add thin adapters for frameworks such as Vercel AI SDK, LangChain.js, or OpenAI Agents SDK. The adapter should translate framework events into one internal trace model instead of forcing the core to depend on every framework.
Instrumentation should not erase the function signature it wraps. A generic wrapper can preserve argument and return types:
type AsyncFunction<Args extends unknown[], Result> = (
...args: Args
) => Promise<Result>;
function withTracing<Args extends unknown[], Result>(
name: string,
fn: AsyncFunction<Args, Result>,
): AsyncFunction<Args, Result> {
return async (...args: Args): Promise<Result> => {
return traceStep(name, () => fn(...args));
};
}
type AgentResult = {
answer: string;
sources: string[];
};
const tracedAgent = withTracing(
'support_agent',
async (question: string): Promise<AgentResult> => {
return runAgent(question);
},
);
const result = await tracedAgent('Where is my order?');
result.answer; // string
result.sources; // string[]
Overloaded functions, methods that depend on this
, and streaming return types need more careful adapters. A library should document those boundaries instead of falling back to any
.
Strong types also improve trace quality. Tool names, span kinds, metadata fields, and completion states can be controlled unions, which catches instrumentation mistakes before runtime.
Modern TypeScript packages are consumed through ESM, CommonJS, transpilers, monorepo build systems, and framework bundlers. Tracing libraries are especially sensitive because they often initialize early and integrate across package boundaries.
A production-ready package should make these behaviors clear:
Automatic monkey-patching can be convenient, but explicit wrappers and adapters are easier to reason about across module systems. If automatic instrumentation is offered, it should be optional and observable.
TypeScript-native does not need to mean backend-specific. The instrumentation layer can emit a small internal event model, while sinks translate those events to local files, OpenTelemetry, or a hosted platform.
type TraceSink = {
write(event: TraceEvent): Promise<void>;
flush(options?: { timeoutMs?: number }): Promise<void>;
};
This separation lets teams use local traces for development, short-lived artifacts in CI, and centralized observability in production without rewriting every adapter.
It also creates one place to enforce privacy policy. Framework integrations should emit approved metadata into the core; destinations should not decide what sensitive payloads to collect.
Use representative tests rather than a single hello-world script.
| Test | What a passing result looks like |
|---|---|
| Two concurrent requests | Separate trace trees with no mixed spans |
| Nested tool call | Correct parent and timing under the model or agent step |
| Streaming response | First-chunk, completion, error, and cancellation are distinct |
| Serverless invocation | Events flush within a bounded time without delaying every request |
| Edge build | Node-only modules are absent from the bundle |
| Type check | Wrapped functions preserve arguments and results |
| Parallel tests | Trace state is isolated by test and worker |
| Privacy check | Raw prompts and tool payloads are absent by default |
Also inspect failure behavior. Observability should not crash the agent because a sink is unavailable, but silent data loss is not acceptable either. Libraries should expose dropped-event counts, flush failures, and backpressure policy.
A TypeScript-native tracing tool should be:
any
.An npm package is only the delivery mechanism. Native support is the accumulated quality of these runtime and developer-experience decisions.
TypeScript agents are asynchronous, streaming, and frequently deployed across more than one runtime. Their observability tools need to understand those constraints as first-class design inputs.
When evaluating a tracer, ask whether it preserves execution context, lifecycle, types, and privacy under the conditions your application actually uses. That is the difference between an SDK that compiles and instrumentation you can trust.
The next article will build the core mechanics directly: a TypeScript execution tree using AsyncLocalStorage
, parent-child spans, safe completion handling, and pluggable trace sinks.