Vercel AI SDK integration
createSpanlensTracker opens a span when you create it and closes it when the AI SDK call is done, recording token usage, model name, and multi-step tool topology to /traces without touching the rest of the call. Streaming calls (streamText, streamObject) close the span through their onFinish and onError callbacks. Awaited calls (generateText, generateObject) have no onFinish callback in AI SDK 4.x and 5.x, so you pass the result to tracker.end() instead. Works with AI SDK 4.x and 5.x via a duck-typed payload check, no peer dependency on the ai package.
Install
pnpm add @spanlens/sdk
# the integration is exposed as a sub-path import, no extra peer depMinimal setup
generateText and generateObject
import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { SpanlensClient } from '@spanlens/sdk'
import { createSpanlensTracker } from '@spanlens/sdk/vercel-ai'
const client = new SpanlensClient({ apiKey: process.env.SPANLENS_API_KEY! })
const tracker = createSpanlensTracker({ client, modelName: 'gpt-4o' })
const result = await generateText({
model: openai('gpt-4o'),
messages: [{ role: 'user', content: 'Summarise the latest release notes.' }],
onStepFinish: tracker.onStepFinish, // optional, counts tool steps and sums their usage
}).catch(async (err) => {
await tracker.onError(err) // closes the span as an error
throw err
})
await tracker.end(result) // required, closes the span with the run's totalsstreamText and streamObject
import { streamText } from 'ai'
const tracker = createSpanlensTracker({ client, modelName: 'gpt-4o' })
const result = streamText({
model: openai('gpt-4o'),
messages: [{ role: 'user', content: 'Summarise the latest release notes.' }],
onStepFinish: tracker.onStepFinish, // optional, counts tool steps and sums their usage
onFinish: tracker.onFinish, // required, closes the span once the stream ends
onError: tracker.onError, // recommended, closes the span if the stream fails
})A fresh trace is opened the moment createSpanlensTracker is called, so latency is measured from the user's perspective (not just from when the model started emitting). The span closes when tracker.end() or onFinish runs, or when onError reports a failure.
Closing the span never waits on Spanlens. end(), onFinish, and onError record the end time on the span and on the trace the tracker opened the moment they run, send the updates in the background, and return right away. A slow or unreachable Spanlens server adds nothing to your response time, and an ingest error never lands in your catch. In a serverless route, call await client.flush() before returning (or hand it to waitUntil) so the updates are delivered before the function freezes. If you would rather wait for delivery inline, pass awaitIngest: true to createSpanlensTracker.
What gets captured
| AI SDK call | Captured | Notes |
|---|---|---|
generateText | single span, prompt + completion tokens, model, latency, finish reason | closed by tracker.end(result); token totals read from the result |
streamText | same shape. onFinish fires after the stream closes with the totals | partial tokens during the stream are not split into sub-spans (kept flat) |
generateObject / streamObject | span output carries the structured object as text via JSON.stringify(...) | close generateObject with tracker.end(result), streamObject with onFinish |
Multi-step tool calls (maxSteps > 1) | steps count in span metadata; token totals cover every step | individual tool calls are not split out (keeps the trace tree small) |
Trace tree shape
A two-step tool-using call (model picks a tool, runs it, then writes the final answer) produces one span by default. The multi-step structure is recorded as metadata.steps on the same span so the trace tree stays readable in the common case:
Trace: ai.generate (3.2s)
└── llm.gpt-4o (3.2s, steps=2, gpt-4o, 480/220 tokens, $0.0034)To break tool calls out into their own spans, pass a parent trace and wrap your tool execution in trace.span(...) explicitly. See Attaching to a long-lived trace below.
Attaching to a long-lived trace
By default the tracker opens a fresh trace on each AI call and closes it together with the span. To group multiple generateText turns under a single trace (chat sessions, agent loops, RAG pipelines), pass an existing trace at construction, and the tracker leaves its lifecycle entirely to the caller:
const trace = client.startTrace({
name: 'chat-session',
metadata: { user_id: user.id, session_id: sessionId },
})
for (const userMessage of conversation) {
const tracker = createSpanlensTracker({ client, trace, modelName: 'gpt-4o' })
const result = await generateText({
model: openai('gpt-4o'),
messages: history.concat({ role: 'user', content: userMessage }),
})
await tracker.end(result)
}
await trace.end({ status: 'completed' })
await client.flush() // before a serverless handler returnsEach turn lands as a child llm.gpt-4o span under the parent trace, so the chat appears as a single waterfall on /traces.
Pairing with the proxy for accurate cost
The tracker records the token totals the AI SDK reports for the call. From AI SDK 5.0 on, usage in the finish payload covers only the final step and totalUsage covers the whole run, so the tracker uses totalUsage when it is present, then the sum of the steps it saw through onStepFinish, then usage (already the combined total on AI SDK 4.x). These are the counts the SDK saw, not the provider's bill. For authoritative billing-grade cost numbers, route the underlying provider through the Spanlens proxy and the matching Request row will always carry the canonical figure:
import { createOpenAI } from '@ai-sdk/openai'
const openai = createOpenAI({
apiKey: process.env.SPANLENS_API_KEY!,
baseURL: 'https://api.spanlens.io/proxy/openai/v1',
})
const result = await generateText({
model: openai('gpt-4o'),
// ... onStepFinish: tracker.onStepFinish, etc.
})
await tracker.end(result)With the proxy in place, every model call lands as a Request in /requests with the authoritative cost, and the matching llm.gpt-4o span links to it via request_id automatically.
Linking spans to prompt versions
Use the proxy approach above and attach a default header so the call is tagged with a Spanlens Prompts version. The matching Request row carries prompt_version_id, so the A/B view can compare versions on real traffic:
const openai = createOpenAI({
apiKey: process.env.SPANLENS_API_KEY!,
baseURL: 'https://api.spanlens.io/proxy/openai/v1',
headers: { 'x-spanlens-prompt-version': 'chatbot-system@3' },
})Verifying the integration
- Make one
generateTextcall with the tracker wired up. - Open /traces. A new trace appears with name
ai.generate(or your customtraceName). - Click into the trace. One
llm.<modelName>span sits underneath with token counts and computed cost on the right panel. - If you wired the proxy as well, the span links to the matching Request in /requests via
request_id. Open it to see the raw request and response bodies.
Troubleshooting
Span shows zero tokens
AI SDK 4.x reports promptTokens / completionTokens; AI SDK 5.x renamed the fields to inputTokens / outputTokens. The tracker accepts both shapes via a fallback chain, so a zero count usually means the underlying provider didn't emit usage at all (some streaming providers, certain Bedrock backends). Routing through the Spanlens proxy (see above) recovers the authoritative number from the raw stream.
Span stays running after generateText
generateText and generateObject accept no onFinish callback in AI SDK 4.x and 5.x, so passing tracker.onFinish to them is silently ignored and the span never closes. Await the call, pass the result to tracker.end(result), and call tracker.onError(err) when the call throws.
Span stays running in a serverless function
The tracker sends its updates in the background, and a serverless runtime can freeze the instance as soon as your handler returns. Call await client.flush() before returning, or pass it to waitUntil. It waits for the updates your request already scheduled, not for traffic that arrives afterwards.
Trace closes before tool calls finish
The auto-managed trace closes together with the span, which is at the end of the AI SDK call, not the end of your application logic. If you kick off background work (DB writes, downstream API calls) after the LLM returns, pass an existing trace in the trace option and call trace.end() yourself when all work is done. See Attaching to a long-lived trace.
Multi-step trace looks flat
By design, the tracker keeps multi-step tool calls collapsed into one span with a steps count in metadata. Most users want a readable trace, not a fan-out of tool noise. To break each tool call into its own span, drop the tracker for that portion and use trace.span({name: 'tool.search', spanType: 'tool'}) inside your tool implementation.
TypeScript complains about callback signatures
The tracker is duck-typed against the AI SDK 4.x and 5.x payloads, so the parameter types are intentionally loose (any-equivalent on the framework side). If your TS config rejects implicit unknowns, cast the callbacks explicitly: onFinish: tracker.onFinish as Parameters<typeof streamText>[0]['onFinish'].
Next: LangGraph integration for multi-agent workflows, or data model for what ends up in the database.