cd /news/developer-tools/the-adapter-pattern-unified-tracing-… · home topics developer-tools article
[ARTICLE · art-94531] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer detailed an adapter pattern for unified tracing across AI SDK, LangChain.js, OpenAI Agents SDK, and direct model clients. The approach normalizes framework-specific lifecycles into a common span model, enabling shared observability tooling without coupling to any single framework. The design includes capability declarations and source attribution to aid debugging.

read7 min views1 publishedAug 13, 2026

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.

The frameworks do not need a shared callback interface. They need a shared answer to a smaller set of questions:

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

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.

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.

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.

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:

Do not deduplicate by span name and timestamp alone. Concurrent model calls may legitimately share both.

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.

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.

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:

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.

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:

The choice belongs to the consumer’s policy, not the adapter. The adapter’s responsibility is honest data.

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.

An adapter has an ongoing compatibility cost. Give each integration:

Without ownership, adapters tend to keep compiling while silently losing events after framework lifecycle changes.

Adopting unified tracing does not require replacing existing observability immediately.

Shadow mode should avoid exporting sensitive data twice. Compare normalized metadata, not raw payloads.

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.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @ai sdk 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-adapter-pattern-…] indexed:0 read:7min 2026-08-13 ·