How to add agent analytics to a Vercel AI SDK app

Record questions and answers from generateText and streamText, with token counts, cached input, cost, and latency in Amplitude. About 20 minutes, with complete JavaScript files.

  • Node.js 22+
  • AI SDK 7
  • OpenAI provider
  • 20 min

Chris Tam · Last updated 21 October 2026

Vercel AI SDK generates and streams model replies through a common interface. I use agent analytics to connect each answer to its token usage, cost, latency, and user.

In this tutorial, I record questions and answers, keep a streaming task open through its final usage, and flush its events before the script exits.

A question-answering script that sends one [Agent] User Message and one [Agent] AI Response per successful task, followed by [Agent] Session End. Each response carries the model, provider, input and output tokens, latency, and calculated cost when the model is priced. By the end, the events appear under your test user's profile and the task appears in agent analytics.

Prerequisites

  • A Node.js 22 or later project, using AI SDK 7 and @ai-sdk/openai 4. The complete .mjs files run as ES modules without changing your package's module setting. This command-line example uses the Chat Completions API and one model call per task; its functions can also sit behind existing Node server routes.
  • An OpenAI API key and access to gpt-4o-mini. Provider calls use your provider account.
  • A US-region Amplitude project with agent analytics enabled and the View Agent Analytics Objects permission. Find its API key in Settings > Projects > your project > General, then Manage next to API Key. For an EU project, supply an EU-configured analytics client instead of initializing with the key alone.

No Amplitude account yet? Every plan includes a free allotment of agent sessions. Sign up for free to get a project API key.

1. Install the SDKs

Keep the Vercel packages and Amplitude packages separate. @ai-sdk/openai supplies the model; @amplitude/ai records the analytics events. The Amplitude AI SDK installation reference covers the two Amplitude packages.

pnpm add ai@7 @ai-sdk/openai@4 @amplitude/ai @amplitude/analytics-node zod
.env
# .env
AMPLITUDE_AI_API_KEY=your-amplitude-project-api-key
OPENAI_API_KEY=your-openai-key

Node reads this file when the script runs with --env-file=.env. If you already load environment variables through your host, keep that setup.

2. Initialize one Amplitude client

Create amplitude.mjs at the project root. The agent name identifies this AI feature; the task ID will identify one job.

amplitude.mjs
// amplitude.mjs
import { AIConfig, AmplitudeAI } from '@amplitude/ai';

if (!process.env.AMPLITUDE_AI_API_KEY) {
  throw new Error('AMPLITUDE_AI_API_KEY is not set');
}

export const ai = new AmplitudeAI({
  apiKey: process.env.AMPLITUDE_AI_API_KEY,
  config: new AIConfig({
    contentMode: 'full',
    redactPii: true,
    debug: process.env.NODE_ENV !== 'production',
  }),
});

export const supportAgent = ai.agent('vercel-support');

export async function flushAgentEvents() {
  const pending = ai.flush();
  await (pending?.promise ?? pending);
}

Set contentMode: 'metadata_only' if message text must stay in your infrastructure. Cost, token counts, latency, and session grouping still work, but content-based quality signals have no text to evaluate. Redaction is pattern-based and does not remove every kind of personal data; review representative payloads before rollout.

Why. The upload helper awaits the underlying analytics return object's .promise. A completed upload attempt is not a guarantee of successful ingestion. Keep the helper for scripts and serverless work where the process can stop immediately after the task.

3. Record a completed generation

Create answer.mjs. Use the plain Vercel provider and record its result with the manual session methods. This file includes both the completed and streaming functions used below.

answer.mjs
// answer.mjs
import { generateText, streamText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { supportAgent } from './amplitude.mjs';

const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
const modelName = 'gpt-4o-mini';
const model = openai.chat(modelName);

function trackAnswer(session, text, usage, started) {
  session.trackAiMessage(text, modelName, 'openai', performance.now() - started, {
    inputTokens: usage.inputTokens,
    outputTokens: usage.outputTokens,
    cacheReadTokens: usage.inputTokenDetails?.cacheReadTokens,
  });
}

export async function answerQuestion({ taskId, message, userId, deviceId }) {
  return supportAgent.session({ sessionId: taskId, userId, deviceId }).run(async session => {
    session.trackUserMessage(message);
    const started = performance.now();
    const result = await generateText({ model, prompt: message });
    trackAnswer(session, result.text, result.usage, started);
    return result.text;
  });
}

export async function streamAnswer({ taskId, message, userId, deviceId }, write) {
  return supportAgent.session({ sessionId: taskId, userId, deviceId }).run(async session => {
    session.trackUserMessage(message);
    const started = performance.now();
    const result = streamText({ model, prompt: message });
    let text = '';
    for await (const chunk of result.textStream) {
      text += chunk;
      await write(chunk);
    }
    const usage = await result.usage;
    trackAnswer(session, text, usage, started);
    return text;
  });
}

answerQuestion records the incoming question, waits for generateText, then sends one response with the normalized usage. The timer starts before generation and ends when the answer is ready. Use a canonical provider model ID so the price catalog can calculate [Agent] Cost USD.

For this OpenAI path, usage.inputTokens already includes cached input tokens. Pass the cache-read count separately for differential pricing; do not add it to the total again. The example has no tools or multiple generation steps, so each successful task produces one response event.

MCP. Your coding agent can use Amplitude's MCP server to inspect existing event names and IDs before connecting this feature to product analytics.

4. Keep the streaming session open

streamAnswer in the same file consumes result.textStream inside session.run(). It forwards chunks to your output function, then awaits result.usage and records the completed answer before the callback returns.

index.mjs
// index.mjs (streaming branch; the complete entrypoint is in step 5)
await streamAnswer(task, chunk => process.stdout.write(chunk));

Keep the stream consumption inside the callback. Returning a stream immediately closes the analytics scope before the answer finishes. If the model or output adapter fails, this example does not record a successful completed answer.

Use this server-side flow with a Node runtime. A Vercel deployment does not make the Amplitude Node SDK an Edge Runtime library. For a serverless response, make sure your host keeps the task running through consumption, session close, and the upload wait.

5. Flush after the task closes

Create index.mjs. The fixed user ID is a local demonstration value: replace it with your authenticated user's stable product analytics ID before connecting a shared app. For a signed-out visitor, omit userId and forward the browser's stable deviceId.

index.mjs
// index.mjs
import { randomUUID } from 'node:crypto';
import { flushAgentEvents } from './amplitude.mjs';
import { answerQuestion, streamAnswer } from './answer.mjs';

const task = {
  taskId: randomUUID(),
  message: process.argv[2] ?? 'Explain how to export a report in one sentence.',
  userId: 'user-42', // Replace with the stable ID from your authentication layer.
};

try {
  if (process.argv.includes('--stream')) {
    await streamAnswer(task, chunk => process.stdout.write(chunk));
    process.stdout.write('\n');
  } else {
    console.log(await answerQuestion(task));
  }
} finally {
  await flushAgentEvents();
}

Generate a new taskId for a new job and keep it stable within that job. Never share it across users. run() closes the session after its callback completes. If your application continues the conversation after the first close, later events are stored but do not update that session's first enrichment result.

The finally block waits for uploads even if generation fails. On a long-running server, use the same upload helper after your server stops accepting new requests and drains active work, rather than exiting during a model call.

Verify it worked

Run each mode with one short question:

node --env-file=.env index.mjs "How do I export a report?"
node --env-file=.env index.mjs "How do I export a report?" --stream
  1. Terminal. Outside production, the Amplitude client prints each tracked event to stderr. A successful invocation should show one User Message, one AI Response, and one Session End. The answer prints to stdout; streamed mode prints chunks as they arrive.
  2. Ingestion Debugger. In Amplitude, open Data > Sources > Ingestion Debugger. Check successful upload counts. This screen shows aggregates; it is not the individual-event viewer.
  3. User profile. Open Users > User Profiles and search for user-42, or the stable ID you substituted. Events normally appear within ten seconds to a minute. On AI Response, check [Agent] Model Name, [Agent] Provider, [Agent] Input Tokens, [Agent] Output Tokens, [Agent] Cost USD, [Agent] Latency Ms, and [Agent] Session ID.
  4. Agent analytics. Find the task by its session ID. Session Record enrichment typically follows 15 to 20 minutes after the explicit close and can take longer during a backlog. A scope closes per script invocation in this example.

Troubleshooting

The answer appears but no AI Response event does

Make sure your call goes through answerQuestion or streamAnswer, not a separate raw generateText call. This tutorial manually records Vercel results; installing Amplitude alone does not wire those calls into the session.

Every answer appears twice

There are two recording paths. Use the plain Vercel provider with the manual calls shown here, and remove an overlapping callback or other instrumentation that records the same response.

Streamed answers have no tokens or cost

Keep consumption and await result.usage inside the session callback. AI SDK 7 exposes normalized inputTokens, outputTokens, and inputTokenDetails; raw OpenAI names like prompt_tokens are not the shape consumed by trackAnswer.

Cached input makes the cost too high

Do not add cacheReadTokens to inputTokens. OpenAI's normalized input total already includes the cache reads. The separate cache field selects the discounted rate.

No events arrive before a script stops

Keep await flushAgentEvents() in finally, after the session finishes. Check the project key and destination region if events print locally but never appear remotely.

A new model has no Cost USD

The bundled price catalog may not recognize it. Use the canonical model ID and follow the cost and pricing guidance before relying on totals.

FAQ

Do I need the separate openai package?

This path uses @ai-sdk/openai and explicit Amplitude tracking. The code does not construct a client from the separate openai package.

Can I use the same code for a tool loop?

This page covers one model call per task. A loop needs per-step response and tool instrumentation; recording one aggregate answer hides those calls. Keep that as a separate integration task.

Does this connect the task to session replay?

A user ID joins agent and product analytics identity. A browser replay link additionally needs the browser session ID, device ID, and an active browser recording. This command-line example does not create a browser recording.

Full code reference

All three files together. Keep the .env file from step 1 and the installed packages.

Full code reference
// -- amplitude.mjs --
import { AIConfig, AmplitudeAI } from '@amplitude/ai';

if (!process.env.AMPLITUDE_AI_API_KEY) {
  throw new Error('AMPLITUDE_AI_API_KEY is not set');
}

export const ai = new AmplitudeAI({
  apiKey: process.env.AMPLITUDE_AI_API_KEY,
  config: new AIConfig({
    contentMode: 'full',
    redactPii: true,
    debug: process.env.NODE_ENV !== 'production',
  }),
});

export const supportAgent = ai.agent('vercel-support');

export async function flushAgentEvents() {
  const pending = ai.flush();
  await (pending?.promise ?? pending);
}

// -- answer.mjs --
import { generateText, streamText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { supportAgent } from './amplitude.mjs';

const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
const modelName = 'gpt-4o-mini';
const model = openai.chat(modelName);

function trackAnswer(session, text, usage, started) {
  session.trackAiMessage(text, modelName, 'openai', performance.now() - started, {
    inputTokens: usage.inputTokens,
    outputTokens: usage.outputTokens,
    cacheReadTokens: usage.inputTokenDetails?.cacheReadTokens,
  });
}

export async function answerQuestion({ taskId, message, userId, deviceId }) {
  return supportAgent.session({ sessionId: taskId, userId, deviceId }).run(async session => {
    session.trackUserMessage(message);
    const started = performance.now();
    const result = await generateText({ model, prompt: message });
    trackAnswer(session, result.text, result.usage, started);
    return result.text;
  });
}

export async function streamAnswer({ taskId, message, userId, deviceId }, write) {
  return supportAgent.session({ sessionId: taskId, userId, deviceId }).run(async session => {
    session.trackUserMessage(message);
    const started = performance.now();
    const result = streamText({ model, prompt: message });
    let text = '';
    for await (const chunk of result.textStream) {
      text += chunk;
      await write(chunk);
    }
    const usage = await result.usage;
    trackAnswer(session, text, usage, started);
    return text;
  });
}

// -- index.mjs --
import { randomUUID } from 'node:crypto';
import { flushAgentEvents } from './amplitude.mjs';
import { answerQuestion, streamAnswer } from './answer.mjs';

const task = {
  taskId: randomUUID(),
  message: process.argv[2] ?? 'Explain how to export a report in one sentence.',
  userId: 'user-42', // Replace with the stable ID from your authentication layer.
};

try {
  if (process.argv.includes('--stream')) {
    await streamAnswer(task, chunk => process.stdout.write(chunk));
    process.stdout.write('\n');
  } else {
    console.log(await answerQuestion(task));
  }
} finally {
  await flushAgentEvents();
}

Hand it to your agent

Paste this prompt into your coding agent:

Using Cursor or Claude Code? Paste this prompt
Add Amplitude agent analytics to the server-side Vercel AI SDK 7 calls
in this project, following /build/vercel-ai-sdk-agent-analytics.
Use @ai-sdk/openai 4 for the model and the manual Amplitude session APIs.
Create one shared AmplitudeAI client and one named agent at module scope.
For one model call per task, record one trackUserMessage and one
trackAiMessage with normalized inputTokens, outputTokens and cacheReadTokens.
Do not add cached input to the inclusive input total again. Keep stream
consumption and the final usage read inside session.run(). Use the auth
layer's stable user ID, omit it when signed out, and forward a stable
device ID for anonymous activity. Use a unique stable task ID per job.
Await the upload helper after the session closes. Do not layer a second
response-recording path over this one. Keep all secrets server-side and
merge with existing instrumentation. Report changed files and tests.

What this unlocks

  • Compare [Agent] Cost USD and [Agent] Latency Ms for successful AI responses.
  • Connect agent and product events through the same stable user ID.
  • Read content-based session quality signals after a task closes, when your privacy mode includes message text.

Next steps

Read the cost of each answer

Send a task through your app, check its events, and use those events to build the reports you need.