Skip to content
ai-effect
Esc
↑↓navigate↵open⌘Jpreview

OpenTelemetry

Export ai-effect spans to OpenTelemetry and nest the AI SDK's own spans under them.

Tracing covers the spans ai-effect records. The AI SDK has its own telemetry package, @ai-sdk/otel, that records the finer detail: one span per step and per model call. Set both up on the same OpenTelemetry provider and the SDK’s spans nest under the ai-effect span of the call that made them.

Setup

pnpm add @ai-sdk/otel @effect/opentelemetry @opentelemetry/api @opentelemetry/context-async-hooks @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-http

Register the provider, a context manager and the SDK’s telemetry once at startup, then add OtelTracer.layerGlobal next to Ai.layer:

import { OpenTelemetry } from '@ai-sdk/otel';
import * as OtelTracer from '@effect/opentelemetry/OtelTracer';
import { context, trace } from '@opentelemetry/api';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import {
	BasicTracerProvider,
	BatchSpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import { isStepCount, registerTelemetry } from 'ai';
import { Ai, generateText, tool } from 'ai-effect';
import { Effect, Layer, Schema } from 'effect';

const provider = new BasicTracerProvider({
	spanProcessors: [
		new BatchSpanProcessor(
			new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' }),
		),
	],
});
trace.setGlobalTracerProvider(provider);
context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable());
registerTelemetry(new OpenTelemetry());

const program = Effect.gen(function* () {
	const weather = yield* tool({
		description: 'Get the current temperature for a city',
		inputSchema: Schema.Struct({ city: Schema.String }),
		execute: ({ city }) => Effect.succeed({ city, temperature: 21 }),
	});

	return yield* generateText({
		model: 'openai/gpt-5',
		prompt: 'What is the temperature in Paris?',
		tools: { weather },
		stopWhen: isStepCount(2),
	});
});

program.pipe(
	Effect.provide(Layer.merge(Ai.layer, OtelTracer.layerGlobal)),
	Effect.runPromise,
);

OtelTracer.layerGlobal makes Effect’s spans, including the ai-effect ones, go to the global provider. registerTelemetry turns on the SDK’s spans for every call. A batching processor holds spans until it flushes, so call provider.shutdown() before a short-lived process exits.

The span tree

With a tool call, one generateText produces:

ai-effect.generateText                 ai-effect
├─ execute_tool weather                ai-effect
└─ invoke_agent {modelId}              @ai-sdk/otel
   ├─ step 1
   │  ├─ chat {modelId}
   │  └─ execute_tool weather
   └─ step 2
      └─ chat {modelId}

A tool run through ai-effect appears twice: once as the ai-effect span next to invoke_agent, and once as the SDK’s span under its step. The two spans differ by instrumentation scope, which is effect for ai-effect and gen_ai for @ai-sdk/otel. Streaming calls nest the same way, even when the stream is consumed after streamText has returned.

Telemetry options you pass on a call, such as experimental_telemetry, reach the SDK untouched.

Was this page helpful?