cd /news/developer-tools/trace-any-typescript-agent-framework… · home topics developer-tools article
[ARTICLE · art-89424] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Trace Any TypeScript Agent Framework With Adapters

A developer proposes an adapter-layer pattern to unify observability across TypeScript AI agent frameworks such as Vercel AI SDK, LangChain.js, and OpenAI Agents SDK. The approach translates framework-specific callbacks into a versioned trace model, with explicit capability flags to distinguish missing data from actual events. The design avoids leaking framework details into consumers and supports quality gates and telemetry export.

read7 min views2 publishedAug 9, 2026

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.

A tempting interface is runWithTrace(input) -> { result, events }

. It works in a demo but creates several problems:

A stronger boundary translates source events as they arrive and sends normalized events to the tracing core.

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.

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.

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.

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.

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.

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:

If the source cannot expose cancellation or final usage, set the relevant capability to false

. Do not fill missing values with zero.

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:

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.

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.

Once events are normalized, one set of consumers can:

Consumers may filter by adapter

and adapter version when investigating integration-specific gaps, but their primary logic should depend on the normalized schema.

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.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @vercel 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/trace-any-typescript…] indexed:0 read:7min 2026-08-09 ·