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; the rest of the system operates on normalized events.
framework callbacks or wrappers
|
v
framework adapter
|
v
versioned trace events
| | |
v v v
local UI CI gates telemetry export
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.
Define the Normalized Model First
Keep the shared model small, explicit, and versioned.
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' | 'cancelled';
durationMs?: number;
errorCategory?: string;
attributes: Record<string, string | number | boolean>;
}
| {
schemaVersion: 1;
event: 'adapter_diagnostic';
traceId: string;
adapter: string;
code: string;
message: string;
};
The core model intentionally avoids raw prompts, outputs, tool arguments, and tool results. A separate capture policy can approve selected payload fields, but adapters should not add them implicitly.
Use controlled attribute keys for model usage, tool outcomes, retry counts, and framework identity. A common schema is useful only when adapters agree on the meaning and units of those fields.
Declare Adapter Capabilities
Not every framework exposes the same information. Hiding those gaps produces misleading traces.
type AdapterCapabilities = {
modelLifecycle: boolean;
toolLifecycle: boolean;
parentRelationships: boolean;
streamingLifecycle: boolean;
tokenUsage: boolean;
cancellation: boolean;
};
interface TraceEmitter {
emit(event: TraceEvent): void;
}
interface FrameworkAdapter<SourceEvent> {
readonly id: string;
readonly version: number;
readonly capabilities: AdapterCapabilities;
accept(event: SourceEvent, emitter: TraceEmitter): void;
close(emitter: TraceEmitter): void;
}
Capabilities let consumers distinguish “zero tool calls occurred” from “this adapter cannot observe tool calls.” Quality gates can then fail, skip, or warn according to policy.
Normalize a Generic Source Event
The following source union represents the kinds of callbacks many frameworks expose. Actual framework adapters translate their native callbacks into this internal source shape first.
type SourceEvent =
| {
type: 'start';
sourceId: string;
parentSourceId?: string;
name: string;
kind: SpanKind;
timestampMs: number;
}
| {
type: 'end';
sourceId: string;
timestampMs: number;
status: 'ok' | 'error' | 'cancelled';
errorCategory?: string;
attributes?: Record<string, string | number | boolean>;
};
Framework-specific code is now responsible for only one translation: native callback to SourceEvent. IDs, lifecycle state, diagnostics, and the final trace schema can be shared.
Preserve Identity and Parentage
Source IDs cannot be assumed to match the trace system’s ID format. Maintain a stable mapping for the lifetime of one adapter session.
import { randomUUID } from 'node:crypto';
class NormalizingAdapter implements FrameworkAdapter<SourceEvent> {
readonly id = 'generic';
readonly version = 1;
readonly capabilities: AdapterCapabilities = {
modelLifecycle: true,
toolLifecycle: true,
parentRelationships: true,
streamingLifecycle: false,
tokenUsage: true,
cancellation: true,
};
private readonly traceId = randomUUID();
private readonly spanIds = new Map<string, string>();
private readonly startedAt = new Map<string, number>();
private readonly ended = new Set<string>();
private spanId(sourceId: string): string {
const existing = this.spanIds.get(sourceId);
if (existing) return existing;
const created = randomUUID();
this.spanIds.set(sourceId, created);
return created;
}
accept(event: SourceEvent, emitter: TraceEmitter): void {
if (event.type === 'start') {
this.handleStart(event, emitter);
return;
}
this.handleEnd(event, emitter);
}
private handleStart(
event: Extract<SourceEvent, { type: 'start' }>,
emitter: TraceEmitter,
): void {
if (this.startedAt.has(event.sourceId)) {
this.diagnostic(emitter, 'duplicate_start', event.sourceId);
return;
}
this.startedAt.set(event.sourceId, event.timestampMs);
emitter.emit({
schemaVersion: 1,
event: 'span_started',
traceId: this.traceId,
spanId: this.spanId(event.sourceId),
parentSpanId: event.parentSourceId
? this.spanId(event.parentSourceId)
: null,
name: event.name,
kind: event.kind,
timestamp: new Date(event.timestampMs).toISOString(),
attributes: { adapter: this.id },
});
}
private handleEnd(
event: Extract<SourceEvent, { type: 'end' }>,
emitter: TraceEmitter,
): void {
if (this.ended.has(event.sourceId)) {
this.diagnostic(emitter, 'duplicate_end', event.sourceId);
return;
}
const start = this.startedAt.get(event.sourceId);
if (start === undefined) {
this.diagnostic(emitter, 'end_without_start', event.sourceId);
return;
}
this.ended.add(event.sourceId);
emitter.emit({
schemaVersion: 1,
event: 'span_ended',
traceId: this.traceId,
spanId: this.spanId(event.sourceId),
timestamp: new Date(event.timestampMs).toISOString(),
status: event.status,
durationMs: Math.max(0, event.timestampMs - start),
errorCategory: event.errorCategory,
attributes: {
adapter: this.id,
...(event.attributes ?? {}),
},
});
}
private diagnostic(
emitter: TraceEmitter,
code: string,
sourceId: string,
): void {
emitter.emit({
schemaVersion: 1,
event: 'adapter_diagnostic',
traceId: this.traceId,
adapter: this.id,
code,
message: `${code} for source event ${sourceId}`,
});
}
close(emitter: TraceEmitter): void {
for (const sourceId of this.startedAt.keys()) {
if (!this.ended.has(sourceId)) {
this.diagnostic(emitter, 'span_left_open', sourceId);
}
}
}
}
This example chooses to report an end event with no start rather than inventing a start time. Another system may synthesize an incomplete span, but the policy should be explicit and consistent.
Adapt Framework Hooks, Do Not Leak Them
Each integration should be a thin layer:
function attachFrameworkHooks(
framework: FrameworkRuntime,
adapter: FrameworkAdapter<SourceEvent>,
emitter: TraceEmitter,
): () => void {
const unsubscribe = framework.subscribe((nativeEvent) => {
const sourceEvents = translateNativeEvent(nativeEvent);
for (const event of sourceEvents) adapter.accept(event, emitter);
});
return () => {
unsubscribe();
adapter.close(emitter);
};
}
The exact subscription and callback APIs differ across Vercel AI SDK, LangChain.js, OpenAI Agents SDK, and direct provider clients, and they may change between versions. That volatility belongs in translateNativeEvent(), not in the trace store or quality-gate code.
An adapter may emit zero, one, or several normalized events for one native callback. For example, a combined framework event might close a tool span and open the next model span.
Streaming Needs More Than Start and End
Frameworks often expose stream start, first chunk, tool activity, completion, usage, and cancellation through different callbacks or promises. The adapter should preserve the lifecycle facts the normalized model supports:
- Keep the model span open until completion, error, or cancellation.
- Record time to first chunk as a bounded numeric attribute.
- Attach token usage only when the framework reports final usage.
- Treat client cancellation separately from provider error.
- Ignore or count individual content chunks unless payload capture is explicitly enabled.
If the source cannot expose cancellation or final usage, set the relevant capability to false. Do not fill missing values with zero.
Normalize Semantics, Not Just Field Names
Two frameworks may use the word “tool” differently. One event may represent the model requesting a tool; another may represent the application executing it. Those are distinct operations.
A robust adapter design defines semantics for:
- Model request versus streamed response
- Tool selection versus tool execution
- Framework retry versus application retry
- Retrieval operation versus ordinary tool call
- Cancelled, timed out, rejected, and failed states
- Input, cached-input, and output token units
Document these mappings next to the adapter and include the adapter version in trace attributes. Field renaming without semantic alignment creates a common schema that cannot be compared safely.
Test Adapters With Event Fixtures
Adapter tests should not call real models. Feed representative native-event fixtures into the translation layer and assert normalized invariants.
test('normalizes parallel tool spans under one parent', () => {
const emitter = new RecordingEmitter();
const adapter = new NormalizingAdapter();
for (const event of parallelToolFixture) {
adapter.accept(event, emitter);
}
adapter.close(emitter);
expect(emitter.diagnostics()).toEqual([]);
expect(emitter.openSpanIds()).toEqual([]);
expect(emitter.childrenOf('parallel_retrieval')).toHaveLength(3);
});
Maintain fixtures for success, error, retry, streaming completion, cancellation, duplicate callbacks, missing parents, missing usage, and abrupt shutdown. Run the same conformance suite against every adapter.
Use a small integration smoke test for each supported framework version to detect callback-surface changes. Keep that separate from the fast fixture suite.
Keep Analysis Framework-Independent
Once events are normalized, one set of consumers can:
- Render execution trees
- Enforce CI quality gates
- Calculate model and tool usage
- Detect retries and fallbacks
- Export spans to OpenTelemetry
- Produce local trace artifacts
Consumers may filter by adapter and adapter version when investigating integration-specific gaps, but their primary logic should depend on the normalized schema.
When an Adapter Layer Is Worth It
Adapters add code and a compatibility commitment. They are justified when a team has multiple frameworks, is migrating between frameworks, maintains shared quality gates, or wants a stable trace history across implementation changes.
For one small project with adequate built-in tracing, direct instrumentation may be simpler. Introduce the abstraction when framework variation creates repeated work or inconsistent data, not merely because an adapter pattern is available.
Final Thought
A useful tracing adapter does more than rename callback fields. It preserves identity, parentage, lifecycle, status, usage semantics, and capability gaps while keeping framework volatility at the edge of the system.
Design the normalized event contract first, make adapter limitations visible, validate every lifecycle transition, and test translations with fixtures. Then teams can choose the agent framework that fits their application while sharing one execution model for debugging, CI, and observability.
The final article in this sequence will compare the concrete responsibilities of adapters for AI SDK, LangChain, and OpenAI Agents-style integrations and show how to keep the shared core stable as those ecosystems evolve.
Top comments (1)
The adapter limitation bit is the part I would want surfaced in CI, not hidden in docs. Normalized traces are useful only if missing lifecycle events fail visibly. Otherwise every framework integration slowly invents its own blind spots. Are you planning to version the event schema separately from each adapter?