Vercel AI SDK
The Vercel AI SDK can export a span for every generateText, streamText and tool call through OpenTelemetry. Point that export at Spanlight’s OTLP endpoint and each call becomes a trace with its prompt, answer, tokens, cost and time to first token. No Spanlight package is installed in your application.
Before you start
- A running Spanlight instance that your server can reach
- A project API key with the ingest:write scope (the default). Creating one needs the member, admin or owner role
- Node.js 24 (the version this page was tested with), in a Node service or the Next.js server runtime
Install
Section titled “Install”npm install ai@7.0.142 @ai-sdk/otel@1.0.142 @ai-sdk/openai@4.0.96 @opentelemetry/sdk-trace-node@2.12.0 @opentelemetry/exporter-trace-otlp-http@0.223.0 @opentelemetry/resources@2.12.0Give the exporter your Spanlight address and project API key:
export SPANLIGHT_HOST=http://localhost:8080export SPANLIGHT_API_KEY=spl_live_...Set up tracing (AI SDK 7)
Section titled “Set up tracing (AI SDK 7)”AI SDK 7 sends telemetry once you register it; you do not change your calls. This is the file the GenAI fixture is recorded through, unchanged. Save it as instrumentation.ts in a Next.js app, or import it first in a Node service:
// instrumentation.ts (Next.js) or the first import of a Node serviceimport { NodeTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';import { resourceFromAttributes } from '@opentelemetry/resources';import { registerTelemetry } from 'ai';import { OpenTelemetry } from '@ai-sdk/otel';
const provider = new NodeTracerProvider({ resource: resourceFromAttributes({ 'service.name': 'support-bot', 'service.version': process.env.RELEASE, 'deployment.environment.name': process.env.NODE_ENV, }), spanProcessors: [ new BatchSpanProcessor( new OTLPTraceExporter({ url: `${process.env.SPANLIGHT_HOST}/v1/otlp/traces`, headers: { Authorization: `Bearer ${process.env.SPANLIGHT_API_KEY}` }, }), ), ],});provider.register();registerTelemetry(new OpenTelemetry({ tracer: provider.getTracer('spanlight') }));service.version becomes the trace’s release and deployment.environment.name its environment; see Trace fields. The URL must end in /v1/otlp/traces: /v1/traces is Spanlight’s native endpoint and rejects OTLP.
OpenTelemetry writes the OpenTelemetry GenAI attributes (gen_ai.*), the mode the AI SDK recommends. LegacyOpenTelemetry, from the same package, keeps the ai.* attributes of AI SDK 5 and 6 instead. Both are read. Use the legacy mode when you need trace metadata, because the GenAI mode does not send it (see What is not recorded):
import { LegacyOpenTelemetry } from '@ai-sdk/otel';
registerTelemetry(new LegacyOpenTelemetry({ tracer: provider.getTracer('spanlight') }));In legacy mode, pass the metadata on the call as runtime context. Each key you include becomes a trace metadata key, and functionId names the trace:
await generateText({ model: openai.chat('gpt-4.1-mini'), prompt: 'Say hello in five words.', telemetry: { functionId: 'answer', includeRuntimeContext: { tenant: true } }, runtimeContext: { tenant: 'acme' },});With @vercel/otel
Section titled “With @vercel/otel”A Next.js app that already uses @vercel/otel can hand it the same exporter. This form was not run for this page:
import { registerOTel } from '@vercel/otel';import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';import { trace } from '@opentelemetry/api';import { registerTelemetry } from 'ai';import { OpenTelemetry } from '@ai-sdk/otel';
export function register() { registerOTel({ serviceName: 'support-bot', traceExporter: new OTLPTraceExporter({ url: `${process.env.SPANLIGHT_HOST}/v1/otlp/traces`, headers: { Authorization: `Bearer ${process.env.SPANLIGHT_API_KEY}` }, }), }); registerTelemetry(new OpenTelemetry({ tracer: trace.getTracer('spanlight') }));}AI SDK 5 and 6
Section titled “AI SDK 5 and 6”AI SDK 5 and 6 have no registerTelemetry. Register the same tracer provider (the provider block above, without the last line), then turn telemetry on in each call with experimental_telemetry:
await generateText({ model: openai.chat('gpt-4.1-mini'), prompt: 'Say hello in five words.', experimental_telemetry: { isEnabled: true, functionId: 'answer', metadata: { tenant: 'acme' } },});functionId names the trace, and each metadata key becomes a trace metadata key.
| AI SDK | How telemetry is turned on | Attributes | Recorded with |
|---|---|---|---|
| 7 | registerTelemetry(new OpenTelemetry(...)) |
gen_ai.* |
ai 7.0.142, @ai-sdk/otel 1.0.142, @ai-sdk/openai 4.0.96 |
| 7 | registerTelemetry(new LegacyOpenTelemetry(...)) |
ai.* |
ai 7.0.142, @ai-sdk/otel 1.0.142, @ai-sdk/openai 4.0.96 |
| 6 | experimental_telemetry on each call |
ai.* |
ai 6.0.0, @ai-sdk/openai 3.0.130 |
| 5 | experimental_telemetry on each call |
ai.* |
ai 5.0.0, @ai-sdk/openai 2.0.135 |
Every row was recorded with @opentelemetry/sdk-trace-node 2.12.0, @opentelemetry/resources 2.12.0 and @opentelemetry/exporter-trace-otlp-http 0.223.0. OTLP compatibility shows, scenario by scenario, which fields each version fills.
Check it worked
Section titled “Check it worked”Make one generateText call, then open Traces in the dashboard and pick the project that owns your key.
- In GenAI mode the trace is named
invoke_agent gpt-4.1-mini. It holds theinvoke_agentspan, astep 1span and thechat gpt-4.1-minimodel call. - In legacy mode, and on AI SDK 5 and 6, the trace is named after
functionIdwhen you set one, elseai.generateText. It holds theai.generateTextstep and theai.generateText.doGeneratemodel call.
Open the model call. It shows the prompt, the answer, the token counts and the cost.
What is recorded
Section titled “What is recorded”Each generateText, streamText or generateObject call is one trace.
| Span | Kind | What Spanlight stores |
|---|---|---|
The provider call (chat <model>, or ai.generateText.doGenerate and the other do… spans) |
llm |
Provider, the billed model snapshot (gpt-4.1-mini-2025-04-14), input, output and cached tokens, prompt, answer, finish reason, cost |
The outer call: invoke_agent <model> (GenAI mode) or ai.generateText (legacy mode) |
agent in GenAI mode, chain in legacy mode |
The same usage again, marked as a usage rollup so the call is counted once |
Each tool call (execute_tool <name>, or ai.toolCall) |
tool |
The tool’s span, with no usage |
step 1 (GenAI mode only) |
other |
Timing only |
- Time to first token on
streamText, fromgen_ai.client.operation.time_to_first_chunk(GenAI mode) orai.response.msToFirstChunk(legacy mode). - Errors. A failed call is
erroron every span of the trace. A rate limit gets the error classrate_limitfrom the status or the message the SDK records. - Cost is computed by Spanlight from the model and the token counts; see How cost is calculated.
- Trace metadata from
experimental_telemetry.metadata(AI SDK 5 and 6) or the runtime context (AI SDK 7 in legacy mode).
What is not recorded
Section titled “What is not recorded”- Trace metadata and the trace name in GenAI mode. AI SDK 7’s
OpenTelemetryintegration does not send the runtime context. It sendsfunctionIdasgen_ai.agent.name, which Spanlight does not use as the trace name, so the trace is named after its root span. UseLegacyOpenTelemetryfor both. - Cached tokens of a non-streamed call on AI SDK 5 and 6. Only
streamTextwritesai.usage.cachedInputTokensthere. AI SDK 7 sends them on every call. - The lifetime of a cache write. AI SDK 5 and 6 send no cache-write count. When AI SDK 7 sends one (
ai.usage.inputTokenDetails.cacheWriteTokens), no attribute says whether it was a 5-minute or a 1-hour write, so every cache write is priced at the 5-minute rate. The recorded calls went to an OpenAI model older than GPT-5.6, which reports no cache writes. - Time to first token of a call that does not stream. There is no first chunk.
- Usage and output of a failed call. The span keeps the error and the prompt.
- Calls outside the AI SDK, browser calls, and edge runtimes without an OpenTelemetry SDK. Only what the AI SDK exports on a Node.js server is sent.
- A cost the exporter computes. Spanlight never reads one; it prices every span itself.
Troubleshooting
Section titled “Troubleshooting”- No trace arrives. Check that
instrumentation.tsruns before your first AI SDK call and thatSPANLIGHT_HOSTis set in the server’s environment. A short script must callawait provider.forceFlush()before it exits, or the batch is lost. Then work through No trace arrives. - The exporter gets
422 VALIDATION_ERROR. The URL ends in/v1/traces. Use/v1/otlp/traces. 401 UNAUTHORIZED. TheAuthorizationheader is missing or the key is not a project API key. See TheAuthorizationheader does not reach Spanlight.- Some calls have no trace (AI SDK 5 or 6). Telemetry is off for those calls. Add
experimental_telemetry: { isEnabled: true }to each one. - The cost of a call looks doubled. The outer span and the provider call are in different traces, so the rollup cannot be found. Keep both in one process and one tracer provider. See My agent’s cost is doubled.
- The trace has no metadata. You are in GenAI mode. Switch to
LegacyOpenTelemetryand passruntimeContext.