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

> Source: <https://dev.to/raju_dandigam/the-adapter-pattern-unified-tracing-across-ai-sdk-langchain-and-openai-agents-4d3b>
> Published: 2026-08-13 00:05:01+00:00

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.

``` php
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.

``` js
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.
