TypeScript teams rarely standardize on one AI framework forever. One service may use Vercel AI SDK, another LangChain.js, another OpenAI Agents SDK, and a mature system may call provider clients directly. Those implementations expose different callback, telemetry, and streaming surfaces. Observability becomes expensive when every dashboard, test rule, and CI report understands each framework independently.
An adapter layer isolates that variation. Framework-specific code captures source events, the adapter translates them into one versioned trace model, and the rest of the system operates on normalized events. The goal is not to pretend every framework is identical. The goal is to preserve a common set of observable facts without leaking framework details into every consumer.
Put the Boundary in the Right Place
A tempting interface is runWithTrace(input) -> { result, events }. It works in a demo but creates several problems:
- Streaming runs may not have one finite completion point
- Buffering every event in memory does not scale
- Callback-driven frameworks already own the run lifecycle
- A framework may emit activity after the initial method returns
- Returning events couples capture, storage, and application results
A stronger boundary translates source events as they arrive and sends normalized events to the tracing core. The adapter does not control execution. It observes execution and emits standardized spans.
Define the Normalized Model First
Keep the shared model small, explicit, and versioned. You need enough structure to reconstruct agent behavior without encoding framework-specific semantics.
type SpanKind = 'run' | 'model' | 'tool' | 'retrieval' | 'decision';
type TraceEvent =
| {
schemaVersion: 1;
event: 'span_started';
traceId: string;
spanId: string;
parentSpanId: string | null;
name: string;
kind: SpanKind;
timestamp: string;
attributes: Record<string, string | number | boolean>;
}
| {
schemaVersion: 1;
event: 'span_ended';
traceId: string;
spanId: string;
timestamp: string;
status: 'ok' | 'error';
error?: { message: string; stack?: string };
}
| {
schemaVersion: 1;
event: 'span_annotated';
traceId: string;
spanId: string;
timestamp: string;
key: string;
value: string | number | boolean | object;
};
This model supports:
- Hierarchical spans with parent-child relationships
- Typed span kinds for filtering and aggregation
- Arbitrary attributes without schema explosion
- Schema versioning for backward compatibility
- Streaming-friendly event emission (no buffering required)
Adapter Interface
Each framework adapter implements a single method:
interface FrameworkAdapter {
instrument(
config: unknown,
emitter: (event: TraceEvent) => void
): void;
}
The adapter receives framework-specific configuration and an event emitter. It registers hooks, callbacks, or wrappers that translate framework events into TraceEvent instances. The emitter is synchronous. If you need async processing, buffer or queue inside the tracing core, not inside the adapter.
Vercel AI SDK Adapter
Vercel AI SDK exposes onStepFinish and onFinish callbacks. The adapter wraps streamText or generateText and emits spans for the overall run and each tool call.
class VercelAIAdapter implements FrameworkAdapter {
instrument(
config: { model: LanguageModel },
emitter: (event: TraceEvent) => void
): void {
const originalStreamText = streamText;
globalThis.streamText = async (options) => {
const traceId = randomUUID();
const runSpanId = randomUUID();
emitter({
schemaVersion: 1,
event: 'span_started',
traceId,
spanId: runSpanId,
parentSpanId: null,
name: 'vercel_ai_run',
kind: 'run',
timestamp: new Date().toISOString(),
attributes: { prompt: options.prompt },
});
const result = await originalStreamText({
...options,
onStepFinish: (step) => {
if (step.toolCalls) {
step.toolCalls.forEach((call) => {
const toolSpanId = randomUUID();
emitter({
schemaVersion: 1,
event: 'span_started',
traceId,
spanId: toolSpanId,
parentSpanId: runSpanId,
name: call.toolName,
kind: 'tool',
timestamp: new Date().toISOString(),
attributes: { args: JSON.stringify(call.args) },
});
emitter({
schemaVersion: 1,
event: 'span_ended',
traceId,
spanId: toolSpanId,
timestamp: new Date().toISOString(),
status: 'ok',
});
});
}
},
onFinish: () => {
emitter({
schemaVersion: 1,
event: 'span_ended',
traceId,
spanId: runSpanId,
timestamp: new Date().toISOString(),
status: 'ok',
});
},
});
return result;
};
}
}
This approach monkey-patches the global streamText function. In production, you may prefer dependency injection or a factory pattern. The key is that the adapter owns the translation logic, not the application code.
LangChain Adapter
LangChain exposes callbacks on every runnable. The adapter creates a custom callback handler that emits normalized events.
class LangChainAdapter implements FrameworkAdapter {
instrument(
config: unknown,
emitter: (event: TraceEvent) => void
): void {
const handler = {
handleChainStart: (chain, inputs, runId, parentRunId) => {
emitter({
schemaVersion: 1,
event: 'span_started',
traceId: parentRunId || runId,
spanId: runId,
parentSpanId: parentRunId || null,
name: chain.name || 'chain',
kind: 'run',
timestamp: new Date().toISOString(),
attributes: { inputs: JSON.stringify(inputs) },
});
},
handleToolStart: (tool, input, runId, parentRunId) => {
emitter({
schemaVersion: 1,
event: 'span_started',
traceId: parentRunId || runId,
spanId: runId,
parentSpanId: parentRunId || null,
name: tool.name,
kind: 'tool',
timestamp: new Date().toISOString(),
attributes: { input: JSON.stringify(input) },
});
},
handleChainEnd: (outputs, runId) => {
emitter({
schemaVersion: 1,
event: 'span_ended',
traceId: runId,
spanId: runId,
timestamp: new Date().toISOString(),
status: 'ok',
});
},
handleChainError: (error, runId) => {
emitter({
schemaVersion: 1,
event: 'span_ended',
traceId: runId,
spanId: runId,
timestamp: new Date().toISOString(),
status: 'error',
error: { message: error.message, stack: error.stack },
});
},
};
// Register handler globally or inject into specific chains
globalThis.langchainCallbacks = [handler];
}
}
LangChain’s callback system is more granular than Vercel AI SDK. You get separate events for chain start, tool start, LLM start, and retrieval. The adapter maps those to the normalized SpanKind taxonomy.
Context Propagation Across Async Boundaries
When an agent calls a tool that spawns a new framework instance, you need to propagate traceId and parentSpanId. AsyncLocalStorage is the standard Node.js mechanism.
import { AsyncLocalStorage } from 'async_hooks';
const traceContext = new AsyncLocalStorage<{
traceId: string;
spanId: string;
}>();
function withTraceContext<T>(
context: { traceId: string; spanId: string },
fn: () => T
): T {
return traceContext.run(context, fn);
}
function getTraceContext() {
return traceContext.getStore();
}
Inside each adapter, wrap framework execution in withTraceContext. When a tool calls another agent, read the context with getTraceContext and set parentSpanId accordingly.
This works for single-process agents. If tools invoke remote services, propagate context via HTTP headers (W3C Trace Context) or message metadata.
Tracing Core
The tracing core receives events from all adapters and routes them to storage, dashboards, and CI gates.
class TracingCore {
private handlers: Array<(event: TraceEvent) => void> = [];
addHandler(handler: (event: TraceEvent) => void): void {
this.handlers.push(handler);
}
emit(event: TraceEvent): void {
this.handlers.forEach((h) => h(event));
}
}
const core = new TracingCore();
// Local development: log to console
core.addHandler((event) => console.log(JSON.stringify(event)));
// CI: assert no errors
const errors: TraceEvent[] = [];
core.addHandler((event) => {
if (event.event === 'span_ended' && event.status === 'error') {
errors.push(event);
}
});
// Production: export to OpenTelemetry collector
core.addHandler((event) => {
// Convert TraceEvent to OTLP span and send via HTTP
});
Handlers are synchronous. If you need async I/O, use a queue. The adapter should never block on telemetry export.
Adapter Trade-Offs
| Aspect | Adapter Pattern | Framework-Native Telemetry | Auto-Instrumentation |
|---|---|---|---|
| Vendor lock-in | None. You own the schema. | High. Each framework has proprietary formats. | Medium. Depends on instrumentation library. |
| Maintenance burden | Medium. One adapter per framework. | Low per framework, high across stack. | Low initially, high when frameworks change. |
| Streaming support | Native. Events emit as they occur. | Framework-dependent. | Often buffered. |
| Custom attributes | Full control via attributes field. | Limited to framework schema. | Limited to auto-detected fields. |
| Context propagation | Manual via AsyncLocalStorage. | Framework-dependent. | Often automatic but opaque. |
The adapter pattern trades initial setup cost for long-term flexibility. You write more code up front but avoid rewriting consumers when you swap frameworks.
Failure Modes
Adapter falls behind framework updates. If Vercel AI SDK adds a new callback, your adapter will not capture those events until you update it. Mitigate with integration tests that assert expected span counts and kinds.
Context loss across async boundaries. If a tool spawns a worker thread or child process, AsyncLocalStorage does not propagate. Use explicit context passing or distributed tracing headers.
Event flood. A chatbot that streams 1,000 tokens will emit 1,000+ events if you trace every token. Filter or sample at the adapter level, not in the core.
Schema drift. If two adapters emit conflicting attributes for the same SpanKind, downstream consumers break. Enforce schema validation in the core and version the model aggressively.
Technical Verdict
Use adapter-based tracing when:
- You run multiple TypeScript agent frameworks in production
- You need unified observability without vendor lock-in
- You want to swap frameworks without rewriting dashboards or CI gates
- You control the agent execution environment (not a third-party SaaS)
Avoid it when:
- You standardize on one framework and trust its native telemetry
- You need zero-code observability (use auto-instrumentation instead)
- Your agents run in environments where you cannot inject adapters (serverless edge functions with strict cold start budgets)
- You lack the engineering capacity to maintain adapters as frameworks evolve
The adapter pattern is infrastructure. It pays off when you have heterogeneous stacks and long-term observability requirements. For single-framework prototypes, native telemetry is faster.