Vercel AI SDK
Turn Vercel AI SDK telemetry into GenAI spans with @unhandled/instrumentation-vercel-ai, with recording options and conversation IDs.
@unhandled/instrumentation-vercel-ai turns the Vercel AI SDK's telemetry into GenAI-convention spans for the AI agents views. The instrumentation switches experimental_telemetry on for every call; the span processor renames the SDK's spans and maps their attributes. Neither creates spans of its own.
It covers generateText, streamText, generateObject, streamObject, embed, embedMany and rerank (exported as WRAPPED_FUNCTIONS). Peer: ai 3.0.0 or later, below 7.
Install
npm install @unhandled/instrumentation-vercel-aiRegister with the AdonisJS agent
import env from '#start/env'
import { defineConfig } from '@unhandled/agent-adonisjs'
import { vercelAi } from '@unhandled/instrumentation-vercel-ai/integration'
export default defineConfig({
dsn: env.get('UNHANDLED_DSN'),
integrations: [vercelAi({ recordInputs: true, recordOutputs: false })],
})The integration registers both the instrumentation and the span processor with the same recording options.
Register standalone
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
import {
VercelAiInstrumentation,
VercelAiSpanProcessor,
} from '@unhandled/instrumentation-vercel-ai'
const recording = { recordInputs: true, recordOutputs: false }
const provider = new NodeTracerProvider({
spanProcessors: [
new VercelAiSpanProcessor(recording),
new BatchSpanProcessor(new OTLPTraceExporter()),
],
})
provider.register()
new VercelAiInstrumentation(recording).enable()Register before ai is imported, and put the processor before the exporting processor because it rewrites spans when they end. Nothing is traced until the instrumentation is enabled.
Options
| Option | Where | Default | Effect |
|---|---|---|---|
recordInputs | integration, instrumentation, processor | false | Prompt, system instructions and tool arguments. |
recordOutputs | integration, instrumentation, processor | false | Response text, tool calls, generated objects and tool results. |
enabled | integration | true | false skips the integration. |
Prompts and responses are not recorded unless you opt in, as in OpenTelemetry's GenAI conventions, because they often hold personal data. Opt in for the whole app with the options above, or per call with experimental_telemetry: { recordInputs: true, recordOutputs: true }. A value set on the instrumentation beats the per-call value. A call with experimental_telemetry: { isEnabled: false } stays untraced. The processor on its own defaults both options to true and keeps whatever the SDK recorded. Pass it the same options so content is also dropped from spans the SDK has already recorded. Whatever you record is still scrubbed of emails, keys and card numbers when Unhandled ingests it.
Conversation IDs
Group the calls of one chat into a conversation:
import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { withConversationId } from '@unhandled/instrumentation-vercel-ai'
export const answer = (chatId: string, question: string) =>
withConversationId(chatId, () =>
generateText({
model: openai('gpt-4o-mini'),
prompt: question,
experimental_telemetry: { functionId: 'support-chat' },
})
)Every span started inside the callback gets gen_ai.conversation.id. getConversationId() returns the current ID inside the callback.
Span names
| Span | Name | Example |
|---|---|---|
| Pipeline | {operation} {functionId} | invoke_agent support-chat |
| Model | {operation} {modelId} | generate_text gpt-4o-mini |
| Tool | execute_tool {toolName} | execute_tool lookupOrder |
| SDK function | Pipeline operation | Model operation |
|---|---|---|
generateText, streamText | invoke_agent | generate_text, stream_text |
generateObject | generate_object | generate_object |
streamObject | stream_object | stream_object |
embed, embedMany | embeddings | embeddings |
rerank | rerank | rerank |
Tool calls get execute_tool. Without a functionId, a pipeline span is named after its operation alone, so set functionId in experimental_telemetry to tell your features apart.
Attributes
- Always:
gen_ai.operation.name,gen_ai.request.model,gen_ai.response.model,gen_ai.provider.name,gen_ai.function_idandgen_ai.tool.name, where the SDK provides them. - On model spans:
gen_ai.usage.input_tokens,gen_ai.usage.output_tokens,gen_ai.usage.cache_read.input_tokens,gen_ai.usage.cache_write.input_tokensandgen_ai.usage.reasoning.output_tokens. Pipeline spans keep the SDK's summed usage undervercel.ai.usage.*so it is not counted twice. - With
recordInputs:gen_ai.system_instructions,gen_ai.input.messagesandgen_ai.tool.call.arguments. - With
recordOutputs:gen_ai.output.messages(text, tool calls and generated objects as one assistant message) andgen_ai.tool.call.result. - Every other
ai.*attribute is kept undervercel.ai.*, for examplevercel.ai.response.msToFirstChunk.
With recordOutputs on, a tool result that embeds an error (tool-error, error-text, error-json or isError) adds an exception event and ERROR status to the tool span. When the SDK reports ai.usage.cost, the model span carries it as gen_ai.usage.cost and Unhandled uses it. Otherwise Unhandled computes cost when it reads the spans, from the model and token counts and the model's price on that day.