An adapter layer becomes strategically useful when several teams need one observability contract but cannot, or should not, standardize on one agent framework.
AI SDK, LangChain.js, OpenAI Agents SDK, and direct model clients organize execution differently. One emphasizes generation and streaming, another exposes hierarchical callbacks, another has agent runs and handoffs, and a direct client exposes only provider requests unless the application adds its own spans.
Unified tracing should preserve those differences while translating the common lifecycle into one model. Done well, teams can share execution-tree tooling, CI quality gates, privacy policy, and telemetry export without coupling every consumer to every framework.
Unify Semantics, Not APIs
The frameworks do not need a shared callback interface. They need a shared answer to a smaller set of questions:
- What is the root operation?
- Which model, tool, retrieval, decision, and handoff spans occurred?
- What was each span’s parent?
- How did it end?
- Which usage and timing metrics are available?
- Which facts are unavailable from this integration?
The framework adapter converts its native lifecycle into those semantics. Consumers never call framework hooks directly.
AI SDK lifecycle ---------┐
LangChain callbacks ------+--> framework adapters --> trace core
OpenAI Agents tracing ----+
direct client wrappers ---┘
|
+------------+------------+
| | |
execution UI CI rules telemetry sinks
A Practical Mapping Matrix
The exact public APIs change over time, so keep the mapping conceptual and verify it against the supported framework version.
| Normalized concept | AI SDK-style integration | LangChain-style integration | OpenAI Agents-style integration | Direct client |
|---|---|---|---|---|
| Root run | Application request or generation | Chain, graph, or agent run | Agent trace or runner invocation | Manual application span |
| Model span | Generation or stream lifecycle | LLM/chat-model callback | Model generation item/span | Provider request wrapper |
| Tool span | Tool execution lifecycle | Tool callback/run | Function-tool execution | Application tool wrapper |
| Retrieval span | Tool or explicit application span | Retriever callback/run | Tool or custom span | Application wrapper |
| Handoff | Application-defined transition | Graph/chain transition | Native handoff lifecycle | Application span |
| Parent context | App context plus source IDs | Parent run identifiers | Trace/span context |
AsyncLocalStorage or explicit context |
| Token usage | Final generation usage when exposed | Model callback metadata when exposed | Run/model usage when exposed | Provider response usage |
This matrix is a design guide, not a promise that every version exposes every cell. The adapter’s capability declaration is the authoritative record.
Keep the Core Contract Framework-Neutral
The common model should represent lifecycle and capability without importing framework types.
type Capability =
| 'model_lifecycle'
| 'tool_lifecycle'
| 'retrieval_lifecycle'
| 'handoff_lifecycle'
| 'parent_relationships'
| 'streaming_completion'
| 'token_usage'
| 'cancellation';
type AdapterDescriptor = {
id: string;
adapterVersion: string;
framework: string;
supportedFrameworkRange: string;
capabilities: Partial<Record<Capability, boolean>>;
};
type NormalizedAttribute = string | number | boolean;
type NormalizedSpan = {
schemaVersion: 1;
traceId: string;
spanId: string;
parentSpanId: string | null;
name: string;
kind: 'run' | 'model' | 'tool' | 'retrieval' | 'decision' | 'handoff';
startedAt: string;
endedAt?: string;
status?: 'ok' | 'error' | 'cancelled';
attributes: Record<string, NormalizedAttribute>;
source: {
adapterId: string;
adapterVersion: string;
sourceId: string;
};
};
The source block is essential for support. When a span looks wrong, developers need to know which adapter and native event produced it.
Do Not Collapse Everything to the Lowest Common Denominator
A base schema should contain portable fields, but frameworks may expose valuable extra data. Use namespaced extension attributes rather than adding a new top-level field for every integration.
const span: NormalizedSpan = {
// portable fields omitted
attributes: {
'model.name': 'example-model',
'model.input_tokens': 840,
'model.output_tokens': 126,
'adapter.ai_sdk.finish_reason': 'stop',
},
};
Portable consumers read model.*. Framework-specific diagnostics may read adapter.ai_sdk.*. Extension values must still follow the same privacy and size policy as core attributes.
Document extension keys and treat changes as adapter-version changes. Otherwise a shared schema slowly becomes an undocumented collection of framework internals.
Choose One Authoritative Capture Path
Duplicate instrumentation is one of the easiest ways to corrupt a trace. For example, a framework may already emit a model span while a provider-client wrapper emits another span for the same request.
agent run
├─ generate_answer framework span
│ └─ provider_request client wrapper span
└─ generate_answer accidental duplicate
Nested spans may be intentional when they represent different layers. Duplicate peer spans are not.
Define precedence for each operation:
- Prefer a stable framework lifecycle when it exposes correct parentage and completion.
- Add a lower-level client span only when it represents a distinct network operation.
- Disable overlapping automatic instrumentation when the adapter owns the span.
- Attach a stable operation ID so duplicates can be detected in tests.
Do not deduplicate by span name and timestamp alone. Concurrent model calls may legitimately share both.
Register Adapters Explicitly
Automatic framework detection sounds convenient but can activate multiple adapters in a monorepo or after a transitive dependency is added. Explicit registration makes ownership visible.
type AdapterSession = {
descriptor: AdapterDescriptor;
stop(): Promise<void>;
};
type AdapterFactory<Options> = {
descriptor: AdapterDescriptor;
start(options: Options, core: TraceCore): Promise<AdapterSession>;
};
class AdapterRegistry {
private readonly factories = new Map<string, AdapterFactory<unknown>>();
register<Options>(factory: AdapterFactory<Options>): void {
if (this.factories.has(factory.descriptor.id)) {
throw new Error(`Adapter already registered: ${factory.descriptor.id}`);
}
this.factories.set(
factory.descriptor.id,
factory as AdapterFactory<unknown>,
);
}
get(id: string): AdapterFactory<unknown> {
const factory = this.factories.get(id);
if (!factory) throw new Error(`Adapter not registered: ${id}`);
return factory;
}
}
Application configuration can then select one or more adapters deliberately. The registry should reject conflicting ownership of the same capture surface unless the configuration explains the intended nesting.
Preserve Context Across Framework Boundaries
An application may call a LangChain workflow from an AI SDK tool, or hand work to another service that uses a direct client. Adapter-local IDs are not enough.
Inside one Node.js process, the trace core can use AsyncLocalStorage to provide the active normalized context. An adapter reads that context when the framework does not supply a parent.
Across services or queues, propagate a standard trace carrier in request headers or message metadata. The receiving service validates the carrier and starts its framework run as a child or linked trace according to policy.
type TraceCarrier = {
traceId: string;
parentSpanId: string;
sampled: boolean;
};
interface ContextBridge {
inject(carrier: TraceCarrier): Record<string, string>;
extract(headers: Record<string, string | undefined>): TraceCarrier | null;
}
Do not put prompts, user identifiers, or framework state in the carrier. It is correlation data, not a portable context dump.
Model Handoffs Explicitly
Handoffs deserve their own span kind because they change which agent owns the task. Treating a handoff as an ordinary tool call hides an important control-flow decision.
A normalized handoff span can record:
- Source and destination agent roles from controlled vocabularies
- Handoff reason category
- Whether the destination accepted or rejected the task
- Duration until the destination began work
- Link to the destination root span
Do not record the complete transferred conversation by default. The trace needs the relationship, not a duplicate of the handoff payload.
Frameworks without a native handoff concept can emit the same span from an application wrapper. That keeps cross-framework analysis consistent.
Make Missing Data Visible
Unified tracing fails when consumers interpret “unavailable” as zero. If an adapter cannot observe token usage, cancellation, or tool internals, emit a diagnostic and declare the capability false.
Quality gates can then express policy:
- Fail when a required capability is missing.
- Skip a rule with a visible reason.
- Warn when an optional metric is unavailable.
The choice belongs to the consumer’s policy, not the adapter. The adapter’s responsibility is honest data.
Maintain a Shared Conformance Suite
Every adapter should run the same behavioral fixtures:
| Fixture | Required invariant |
|---|---|
| Single model call | One root, one model child, one completion each |
| Parallel tools | Siblings share the expected parent |
| Tool retry | Attempts remain distinct and ordered |
| Model stream | Completion or cancellation closes the span once |
| Handoff | Source and destination traces are linked correctly |
| Framework error | Controlled status and category are preserved |
| Duplicate callback | Diagnostic emitted; no duplicate completion |
| Abrupt shutdown | Open spans and dropped events are reported |
Native callback fixtures test translation quickly. A small integration matrix should also run against the minimum and maximum supported framework versions.
Publish adapter compatibility separately from the core release. Framework updates should not require an unrelated trace-core version bump.
Treat Adapters as Maintained Products
An adapter has an ongoing compatibility cost. Give each integration:
- A clear owner
- A supported framework-version range
- A changelog for semantic mappings
- Capability and privacy documentation
- Fixture and integration tests
- A deprecation policy
- Diagnostics that identify the adapter version
Without ownership, adapters tend to keep compiling while silently losing events after framework lifecycle changes.
Migrate One Consumer at a Time
Adopting unified tracing does not require replacing existing observability immediately.
- Define the normalized schema and privacy policy.
- Add one adapter in shadow mode.
- Compare trace counts, parentage, status, and usage against the existing integration.
- Move local rendering or one CI rule to normalized events.
- Add the next framework adapter and run the conformance suite.
- Switch shared consumers after the data is trusted.
- Remove duplicate legacy instrumentation.
Shadow mode should avoid exporting sensitive data twice. Compare normalized metadata, not raw payloads.
When Unified Tracing Is the Wrong Choice
One small application with one stable framework may be better served by its built-in telemetry. An adapter platform is justified when several teams need shared rules, common privacy controls, migration flexibility, or one observability backend.
The pattern should remove repeated integration work. If it adds more maintenance than the framework differences it isolates, the boundary is premature.
Final Thought
Unified tracing across AI SDK, LangChain, OpenAI Agents, and direct clients is not achieved by forcing every framework event into an identical callback shape. It comes from a stable semantic contract, honest capability reporting, explicit context propagation, and adapters that own framework-specific volatility.
Choose one authoritative capture path, preserve handoffs and parentage, keep extensions namespaced, and operate each adapter with a conformance suite and compatibility policy. Teams remain free to choose the framework that fits their application while the organization gains one trustworthy language for execution, quality gates, and observability.
Top comments (1)
The adapter pattern is especially useful when the trace schema is treated as the product contract, not just a logging convenience. If every framework emits different names for the same step, debugging becomes translation work. A stable internal event model makes model and framework swaps much less scary.