Dev.to · 9 min read

The Adapter Pattern: Unified Tracing Across AI SDK, LangChain, and OpenAI Agents

The Adapter Pattern: Unified Tracing Across AI SDK, LangChain, and OpenAI Agents

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; }; 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; 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; }; type AdapterFactory = { descriptor: AdapterDescriptor; start(options: Options, core: TraceCore): Promise; }; class AdapterRegistry { private readonly factories = new Map(); register(factory: AdapterFactory): 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, ); } get(id: string): AdapterFactory { 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; extract(headers: Record): 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.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News