docs

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

Terminal
npm install @unhandled/instrumentation-vercel-ai

Register with the AdonisJS agent

ts
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

ts
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

OptionWhereDefaultEffect
recordInputsintegration, instrumentation, processorfalsePrompt, system instructions and tool arguments.
recordOutputsintegration, instrumentation, processorfalseResponse text, tool calls, generated objects and tool results.
enabledintegrationtruefalse 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:

ts
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

SpanNameExample
Pipeline{operation} {functionId}invoke_agent support-chat
Model{operation} {modelId}generate_text gpt-4o-mini
Toolexecute_tool {toolName}execute_tool lookupOrder
SDK functionPipeline operationModel operation
generateText, streamTextinvoke_agentgenerate_text, stream_text
generateObjectgenerate_objectgenerate_object
streamObjectstream_objectstream_object
embed, embedManyembeddingsembeddings
rerankrerankrerank

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_id and gen_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_tokens and gen_ai.usage.reasoning.output_tokens. Pipeline spans keep the SDK's summed usage under vercel.ai.usage.* so it is not counted twice.
  • With recordInputs: gen_ai.system_instructions, gen_ai.input.messages and gen_ai.tool.call.arguments.
  • With recordOutputs: gen_ai.output.messages (text, tool calls and generated objects as one assistant message) and gen_ai.tool.call.result.
  • Every other ai.* attribute is kept under vercel.ai.*, for example vercel.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.