How to Trace Vercel AI SDK Calls with OpenTelemetry
Vercel AI SDK tracing from zero: register an OpenTelemetry provider, export OTLP/HTTP to telemetry.dev, and read model, tool, token and error spans.

Your chat route works fine on your laptop. In production, one user in twenty waits eight seconds for a reply, and another gets a blank response with a 200 status. Your logs say the handler ran. They don't say which of the three model calls and two tool steps inside it was the slow one, or which one threw.
This guide takes you from nothing to a working trace of a Vercel AI SDK call: the model call, a tool step, token counts, latency and errors, all sent over standard OTLP/HTTP. You'll register a tracer in a Next.js app, point an exporter at telemetry.dev, switch on the SDK's telemetry flag per call, and decide what prompt data gets recorded.
Prerequisites: a TypeScript project already using the ai package (Next.js or any Node runtime), a provider key you already call with, and a free telemetry.dev workspace. Every step below uses open OpenTelemetry pieces, so nothing here locks you in.
Step 1: Check your AI SDK version and what telemetry it emits
Start by finding out what you're running. Open your lockfile or run npm ls ai. Vercel AI SDK tracing has changed shape across major versions, and most "my spans are missing" reports trace back to a version mismatch, not a bug in your setup.
The constant across versions: the SDK doesn't trace anything by default. You can enable OpenTelemetry spans using the experimental_telemetry option. The name has the word experimental in it for a reason. Treat the span names and attribute names as something that can shift between releases, and pin your ai version when you rely on them in dashboards or alerts.
Once it's on, expect to see a span for the top-level call (for example ai.generateText), a child span for each provider round trip, and a span for each tool execution. Token usage and finish reason ride along as attributes on the model-call spans.
Two things differ by version, so check your release notes before you go further. First, how the SDK is wired to the tracer: older releases lean on the global OpenTelemetry tracer provider, while newer ones let you pass a tracer explicitly. Second, the exact attribute keys for usage. Don't copy an attribute name from a blog post (including this one) without confirming it against the changelog for your installed version.
Write down your version now. If you're two majors behind, upgrade first, because every later step assumes an SDK that actually emits spans.
Step 2: Register an OpenTelemetry provider at app startup (instrumentation.ts)
A span with no provider registered goes nowhere. The SDK calls the OpenTelemetry API, and the API is a no-op until something at startup installs a real tracer provider and an exporter. That something has to run before your first request.
In Next.js, the hook for this is instrumentation.ts at the project root (or in src/). Next.js calls its exported register() function once when the server boots. Put your setup there and guard it so it only runs on the Node.js runtime, because the Node SDK doesn't run on the Edge runtime.
Install the standard packages: @opentelemetry/api, @opentelemetry/sdk-node (or @opentelemetry/sdk-trace-node), @opentelemetry/exporter-trace-otlp-http, and @opentelemetry/resources. Then create the file:
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./instrumentation.node');
}
}Keep the real setup in instrumentation.node.ts so the Node-only imports never get bundled for Edge. Inside it, build a NodeSDK with a resource that sets service.name to something you'll recognize later, such as my-app-chat, and start it.
The service name matters more than people expect. It's the first filter you'll use when you open the trace view, and if three apps all report as unknown_service, you won't enjoy debugging at 2am.
If you're not on Next.js, the idea is identical: initialize the Node SDK in a file you load before the rest of your app, for example with node --import ./otel.js server.js. Registration first, application code second. Always.
Step 3: Point the OTLP/HTTP exporter at telemetry.dev
Now give those spans somewhere to go. telemetry.dev ingests standard OTLP/HTTP, which means the exporter you install is the stock OpenTelemetry one. You aren't adding a vendor SDK to your bundle to get traces out. If you ever move backends, you change a URL and a header.
In your Node setup file, construct an OTLPTraceExporter and hand it to the SDK:
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
const sdk = new NodeSDK({
resource: resourceFromAttributes({ 'service.name': 'my-app-chat' }),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
headers: { Authorization: `Bearer ${process.env.TELEMETRY_DEV_API_KEY}` },
}),
});
sdk.start();Two placeholders to fill from your workspace: the OTLP endpoint and the API key. Copy the exact endpoint URL and the exact auth header format from the telemetry.dev OTLP reference and the quickstart instead of trusting my snippet. Header names and paths are the kind of detail you verify against the source, not against memory.
Put both values in environment variables. Never commit the key. On Vercel, add them in project settings for the environments you want traced.
Prefer an OpenTelemetry Collector in the middle? That works too, since telemetry.dev accepts any OTLP/HTTP exporter or Collector. A Collector is worth it once you have several services and want batching, retries or sampling in one place. For a single Next.js app, the direct exporter is simpler and enough. Start simple.
Step 4: Turn on experimental_telemetry for each call, with functionId and metadata
This is the step people forget, and it's why traces come up empty. The provider from Step 2 is plumbing. The SDK still emits nothing until each call asks for it.
Add the option to every generateText, streamText, generateObject or streamObject call you want to see:
import { generateText } from 'ai';
const result = await generateText({
model: yourModel,
prompt,
experimental_telemetry: {
isEnabled: true,
functionId: 'support-chat-reply',
metadata: {
route: '/api/chat',
plan: 'pro',
},
},
});isEnabled is the switch. functionId is the field I'd fight for in code review. It names the logical operation, so when a summarizer, a router and a reply generator all call the same model, you can tell them apart in the trace list without reading prompts. Use stable, descriptive, kebab-case ids and never put a user id in one.
metadata is for the dimensions you'll want to filter by: route, feature flag, plan tier, environment. Keep values small and low-cardinality. A request id in metadata is useful for jumping from your own logs to a trace. An entire user profile is not.
Don't enable it on a hunch for one route and call it done. A partial rollout produces traces with holes, and holes look like bugs. Grep for your AI SDK call sites, add the option to each, and consider a small wrapper so new calls inherit it by default.
Streaming note: with streamText, the span closes when the stream finishes, so a client that disconnects early can leave you with a shorter or odd-looking span. Expect that, and read durations with it in mind.
Step 5: Decide what to record: recordInputs, recordOutputs and sensitive data
The SDK can record prompts and completions as span attributes. That's great for debugging and a liability for privacy. Decide deliberately, per environment, before you ship traces from production.
The SDK gives you two flags inside experimental_telemetry:
experimental_telemetry: {
isEnabled: true,
functionId: 'support-chat-reply',
recordInputs: false,
recordOutputs: false,
}Set recordInputs: false to keep prompts out of spans and recordOutputs: false to keep completions out. Timing, token counts, finish reasons and errors still come through, which is usually what you need to find a slow or failed step. You lose the ability to read the exact prompt that caused a bad answer, so many teams record in development and staging and turn it off in production. Drive the flags from an environment variable so you aren't editing call sites to flip them.
A reasonable default: capture on locally, capture off for anything touching real user text until you've decided otherwise.
Be precise about what each control does. telemetry.dev can also turn off prompt capture per environment before storage, and it applies redaction patterns. Redaction patterns are pattern matching, and they can't catch all sensitive data. A customer who pastes an account number in a format your patterns don't expect will get through. Don't treat redaction as a guarantee.
Likewise, turning capture off isn't the same as nothing leaving your app. Spans, durations, token counts, your functionId and any metadata you attach still get exported. If a metadata value is sensitive, it's sensitive in the trace too. Audit what you put there. The cheapest privacy control is not collecting the field in the first place.
Step 6: Run a call with a tool step and read the trace (find the slow step, inspect the failed call)
Time to generate a trace worth reading. Define a tool and let the model call it. A weather lookup is the classic, but any function that does real I/O works better, because you want a step with measurable latency:
import { generateText, tool } from 'ai';
import { z } from 'zod';
const result = await generateText({
model: yourModel,
maxSteps: 3,
tools: {
lookupOrder: tool({
description: 'Fetch an order by id',
parameters: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => fetchOrder(orderId),
}),
},
prompt: 'Where is order 4821?',
experimental_telemetry: { isEnabled: true, functionId: 'order-status' },
});The multi-step option name differs between SDK versions (maxSteps in some, a stop condition in newer ones), which is one more reason Step 1 came first. Use whatever your version documents.
Hit the route once, then open your workspace in telemetry.dev and filter by your service name and functionId. You should see one trace with a parent span for the call, a child span for the first model round trip, a span for the lookupOrder tool execution, and a second model span that consumes the tool result.
Now do the two jobs this setup exists for. To find the slow step, compare span durations side by side. If the tool span dominates, your database or upstream API is the problem and no prompt tweak will fix it. If a model span dominates, look at token counts on it, since long prompts and long outputs cost time. To inspect the failed call, force an error: throw inside execute, or use a bad API key for one request. The failing span should carry the error status and exception details, so you can see exactly which step broke without reproducing it.
That's the whole loop: find the slow step, inspect the failed call, with spans you exported yourself.
Troubleshooting and what to do next: missing spans, missing tokens or cost, free-plan units
If nothing shows up, work down this list in order.
No spans at all. Check that isEnabled: true is on the call, that register() actually ran (add a one-line log), and that you're on the Node.js runtime and not Edge. Confirm the endpoint and auth header against the OTLP reference. Short-lived serverless functions can exit before the exporter flushes, so make sure your setup flushes or uses a span processor suited to your runtime.
Spans but no tool step. Your tool probably isn't being called, or the multi-step setting from Step 6 is missing, so the run stops after the first model response.
Missing tokens or cost. Token counts appear only when the provider reports usage and your SDK version records it. If usage isn't reported, expect tokens or cost to be blank. Don't read computed cost as billing reconciliation. Check your provider invoice for that.
Free-plan units. Keep in mind how usage is counted against your allowance. A single call with a tool step is several spans, so 30,000 free units goes faster than 30,000 requests. The pricing page lists the free allowance.
Next, add functionId everywhere, set the capture flags per environment, and watch a week of real traffic.
Conclusion
A Vercel AI SDK app without tracing is a black box with a good error message. You now have the pieces to open it: a provider registered in instrumentation.ts, a standard OTLP/HTTP exporter, experimental_telemetry on every call, and a deliberate choice about prompt capture.
Do the next part today. Create a free telemetry.dev workspace (no credit card, 30k units, 14-day retention), point the exporter from Step 3 at it, and trace the one route your users complain about most. Look at the longest span first. If you find the slow step in your first session, keep tracing the rest of your AI routes. If the trace shows nothing surprising, you've at least ruled out the model, and that's worth knowing too.
Share this article: