How to add agent analytics to a Node.js backend

Install the Amplitude AI SDK in an Express server, wrap a chat route in an agent session, record a tool call, stream replies over server-sent events, and flush on shutdown. About 20 minutes, all copy-paste.

  • Node.js 20.6+
  • Express
  • Amplitude AI SDK
  • 20 min

Chris Tam · Last updated 19 October 2026

An Express process can stop with agent events still buffered, especially while a model call is active. I drain requests before waiting for agent analytics uploads in the shutdown handler.

I’ll instrument an Express chat route, a retrieval lookup, and a server-sent event stream. This extends product analytics for a Node.js backend and reuses its identity middleware.

A POST /chat route that records [Agent] User Message, [Agent] Tool Call for a search_help_docs lookup, and [Agent] AI Response for each signed-in turn under one [Agent] Session ID per conversation, with [Agent] Model Name, [Agent] Input Tokens, [Agent] Output Tokens, [Agent] Cost USD, and [Agent] Latency Ms filled in by the OpenAI wrapper, plus a streaming POST /chat/stream route. Both use the same user identity as your product events. AI responses also carry the forwarded browser device and session linkage; the tool helper does not attach those fields to its Tool Call event. By the end you'll see those events in your user's event stream and the conversation in agent analytics.

Prerequisites

  • A Node.js 20.6 or later Express app that runs as ES modules ("type": "module") and calls a model. The examples use OpenAI; the SDK also wraps Anthropic, Azure OpenAI, Gemini, Google Gen AI, Bedrock, and Mistral.
  • The amplitudeIdentity middleware from How to add product analytics to a Node.js backend, which sets req.amplitudeIds, and the browser apiFetch helper from How to stitch client and server events to one user, which sends the device and session ID headers.
  • An Amplitude project with agent analytics enabled, and its project API key. In Amplitude, go to Settings > Projects > your project > General, then Manage next to API Key. Agent data can live in the same project as your product analytics, which is the simplest setup.
  • The View Agent Analytics Objects permission on your role, which you need to open agent analytics at all.

No Amplitude account yet? A free account is enough for this tutorial; every plan includes a free allotment of agent sessions. Sign up for free to get your project API key.

These examples use a US-region Amplitude project. For an EU project, supply an EU-configured analytics client instead of initializing with the API key alone.

1. Install the SDK

The Node path needs @amplitude/ai and the Node analytics client it sends through, which you already have if you followed the product analytics page. Add openai if it isn't already a direct dependency, because the wrapper instruments the openai package itself. For every option, see the agent analytics SDK reference.

pnpm add @amplitude/ai @amplitude/analytics-node "openai@>=4 <7"

The docs name the variable AMPLITUDE_AI_API_KEY. It holds the same project API key as the AMPLITUDE_API_KEY your product analytics already reads.

.env
# .env
AMPLITUDE_API_KEY=your-project-api-key
AMPLITUDE_AI_API_KEY=your-project-api-key
OPENAI_API_KEY=your-openai-key

Prefer to let a coding agent do the instrumentation? Install the packages, then tell your agent to follow node_modules/@amplitude/ai/amplitude-ai.md. The steps below are what it should produce on Express.

2. Initialize once in a server module

Create one module that constructs the client, the wrapped OpenAI client, and the agent at module scope. Routes import from it and never construct their own.

lib/amplitude-ai.js
// lib/amplitude-ai.js
import { AmplitudeAI, AIConfig, OpenAI } from '@amplitude/ai';

const apiKey = process.env.AMPLITUDE_AI_API_KEY;

if (!apiKey) {
  throw new Error('AMPLITUDE_AI_API_KEY is not set');
}

export const ai = new AmplitudeAI({
  apiKey,
  config: new AIConfig({
    contentMode: 'full', // prompt and response text, with PII redaction
    redactPii: true,
    debug: process.env.NODE_ENV !== 'production',
  }),
});

// Use this instead of `new OpenAI()` from 'openai'. It forwards every call
// and records model, tokens, latency, and cost on [Agent] AI Response.
export const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  amplitude: ai,
});

export const supportAgent = ai.agent('support-chat', {
  description: 'In-app support assistant',
});

// Wait for the analytics upload attempt before a process exits.
export async function flushAgentEvents() {
  const pending = ai.flush();
  await (pending?.promise ?? pending);
}

Why. Keep the named agent at module scope so routes reuse its configuration. The file isn't lib/amplitude.js because that's the product analytics client from the sibling page, and the two stay separate.

contentMode: 'full' is the default and sends prompt and response text after scrubbing emails, phone numbers, SSNs, card numbers, and IP addresses. If message text must stay in your infrastructure, use 'metadata_only': you still get tokens, latency, model, cost, and session grouping, with no text. debug prints each tracked event to stderr outside production.

Redaction matches patterns; it does not remove every kind of personal data. Review representative payloads before production capture. In metadata_only, built-in quality signals that need message text are unavailable.

3. Wrap the chat route in a session

Open one session per request, keyed on the conversation's ID, and make the LLM call inside run(). The wrapper sees the call happen inside the session and records the user's latest message from messages and the AI response against it, so the route doesn't call trackUserMessage() as well. The user ID and device ID come from req.amplitudeIds, so agent events land on the same user as your product events.

routes/chat.js
// routes/chat.js
import { Router } from 'express';
import { openai, supportAgent } from '../lib/amplitude-ai.js';

export const chat = Router();

chat.post('/', async (req, res) => {
  const { conversationId, messages } = req.body;

  const reply = await supportAgent
    .session({
      // Same IDs as your product events. Omitted when signed out; never
      // send a placeholder like "anonymous".
      userId: req.amplitudeIds.user_id,
      deviceId: req.amplitudeIds.device_id,
      // Sent by apiFetch in the browser, from the Browser SDK.
      browserSessionId: req.get('x-amplitude-session-id'),
      sessionId: conversationId,
    })
    .run(async () => {
      const completion = await openai.chat.completions.create({
        model: 'gpt-4o-mini',
        messages,
      });

      return completion.choices[0].message.content ?? '';
    });

  res.json({ reply });
});

Mount it in server.js after amplitudeIdentity, as shown in step 6. The SDK also ships createAmplitudeAIMiddleware for Express, which opens a session for every request; the explicit session here is the version where you choose the session ID.

Reuse conversationId across every turn of the same conversation, and never across users. Every turn then carries the same [Agent] Session ID, so don't generate a fresh one per request. When the same user starts a new conversation, the browser sends a new ID.

run() closes the session when its callback returns, and Amplitude enriches a session once, at its first close. Later turns with the same ID are stored, but they don't reach that session's [Agent] Session Record.

Use the canonical model ID, gpt-4o-mini, not a gateway alias. Cost comes from token counts multiplied against a public model-price catalog, and a name the catalog can't price gets no cost.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing taxonomy before it adds anything, so the Plan Upgraded event your billing route already sends and the [Agent] events from this page end up in charts together instead of next to a duplicate planUpgraded. Set up the MCP server.

4. Record the tool calls your agent makes

When the model's answer depends on something your server looks up, wrap that function with tool(). Each call inside a session emits [Agent] Tool Call with its duration, success, input, and output, so a bad answer can be traced to a bad lookup.

lib/help-docs.js
// lib/help-docs.js
import { tool } from '@amplitude/ai';

async function searchHelpDocsImpl(query) {
  // ... your search: a vector store, a database, or an internal API
  return [{ title: 'Export a report', body: 'Open Reports, then Export.' }];
}

export const searchHelpDocs = tool(searchHelpDocsImpl, { name: 'search_help_docs' });

Call it inside run(), before the model call:

routes/chat.js
// routes/chat.js (inside chat.post('/'), replacing the run callback)
import { searchHelpDocs } from '../lib/help-docs.js';

// ...
    .run(async () => {
      const question = messages[messages.length - 1].content;
      const docs = await searchHelpDocs(question);

      const completion = await openai.chat.completions.create({
        model: 'gpt-4o-mini',
        messages: [
          { role: 'system', content: `Answer from these docs:\n${JSON.stringify(docs)}` },
          ...messages,
        ],
      });

      return completion.choices[0].message.content ?? '';
    });

Why. The wrapped function records only when a session is active, and it captures input and output under the session's privacy mode. Outside an active session, this example produces no Tool Call event. In SDK 0.22.1, it also needs a user ID on the session, so calls from signed-out visitors, who have only a device ID, run without a Tool Call event. For tool calls you can't wrap, call s.trackToolCall(name, latencyMs, success, { input, output }); the option names are input and output, and other spellings are ignored without an error.

5. Stream replies inside the session

Express writes a stream to the response as it arrives, so the session stays open for as long as run() is still looping. Keep the loop inside the callback and await it before you end the response.

routes/chat.js
// routes/chat.js (add to the file from step 3)
chat.post('/stream', async (req, res) => {
  const { conversationId, messages } = req.body;

  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.flushHeaders();

  await supportAgent
    .session({
      userId: req.amplitudeIds.user_id,
      deviceId: req.amplitudeIds.device_id,
      browserSessionId: req.get('x-amplitude-session-id'),
      sessionId: conversationId,
    })
    .run(async () => {
      const stream = await openai.chat.completions.create({
        model: 'gpt-4o-mini',
        messages,
        stream: true,
        // Streams only report token usage when asked; tokens and cost need it.
        stream_options: { include_usage: true },
      });

      for await (const chunk of stream) {
        const text = chunk.choices[0]?.delta?.content;
        if (text) res.write(`data: ${JSON.stringify(text)}\n\n`);
      }
    });

  res.write('data: [DONE]\n\n');
  res.end();
});

Why. The docs warn that a session closed before its stream is consumed drops the [Agent] AI Response event. Returning the stream from run() and piping it afterwards does exactly that. Consuming it inside the callback keeps the session open until the last chunk. The final chunk carries usage and no choices, which the ?. guard skips.

6. Flush before the process exits

An Express server is a long-running process, so the SDK skips the per-session flush and lets the analytics client batch events normally. Drain active requests, then flush the agent client in the same shutdown handler that already flushes your product analytics client.

server.js
// server.js
import express from 'express';
import { amplitude } from './lib/amplitude.js';
import { ai, flushAgentEvents } from './lib/amplitude-ai.js';
import { amplitudeIdentity } from './middleware/amplitude-identity.js';
import { auth } from './routes/auth.js';
import { billing } from './routes/billing.js';
import { chat } from './routes/chat.js';

const app = express();
app.use(express.json());
// Mount your session or auth middleware here, before amplitudeIdentity,
// so req.user is populated.
app.use(amplitudeIdentity);
app.use('/auth', auth);
app.use('/billing', billing);
app.use('/chat', chat);

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

async function shutdown(signal) {
  console.log(`${signal} received, flushing Amplitude`);
  await new Promise((resolve, reject) => {
    server.close(error => error ? reject(error) : resolve());
  });
  await flushAgentEvents();
  await amplitude.flush().promise;
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Why. Wait for active requests to finish before flushing, so a deploy does not cut off a model response or leave its events behind. The helper awaits the analytics client's upload attempt. Keep an explicit flush-and-wait step before returning from a serverless invocation, too.

Verify it worked

Run pnpm start, send two messages in the same conversation from your chat UI, then check in this order:

  1. Terminal. With debug on outside production, every tracked event prints to stderr as a one-line summary, for example [amplitude-ai] [Agent] AI Response | user=user-42 session=... agent=support-chat model=gpt-4o-mini .... For a signed-in user, expect a User Message, a Tool Call, and an AI Response per non-streaming turn. Device-only and streaming examples omit the tool lookup event. If nothing prints, check instrumentation, including an LLM call outside run(). You can also run pnpm exec amplitude-ai doctor.
  2. Network tab. Filter for /chat. The request headers should include x-amplitude-session-id and x-amplitude-device-id. The server's own upload to Amplitude doesn't show here, since it leaves from Node.
  3. Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your server's uploads. It shows counts, not individual events.
  4. User lookup. Go to Users > User Profiles and search your user ID or device ID. Within ten seconds to a minute the event stream shows [Agent] User Message, [Agent] Tool Call, and [Agent] AI Response for each signed-in non-streaming turn, next to your product events. Device-only and streaming requests omit Tool Call in this example. Open an AI Response and confirm [Agent] Model Name, [Agent] Provider, [Agent] Input Tokens, [Agent] Output Tokens, [Agent] Cost USD, [Agent] Latency Ms, [Agent] Session ID (your conversationId), and [Agent] Agent ID (support-chat) are all set.
  5. Agent analytics. Open agent analytics in the left nav and find the session by your conversationId. Quality signals typically arrive on [Agent] Session Record 15 to 20 minutes after the first close, and cover the events received before it. An enrichment backlog can delay them.

Troubleshooting

Nothing prints in the terminal and no AI Response arrives

The model call used an unwrapped client or ran outside a session. Import openai from lib/amplitude-ai.js, not from 'openai', and make the call inside run().

Tool Call events are missing, or have no input and output

searchHelpDocs ran outside run(), the visitor was signed out so the session had no user ID, or a manual trackToolCall passed toolInput and toolOutput. Call the wrapped function inside the session, and use input and output for manual calls.

Every turn has two User Message events

The route calls s.trackUserMessage() for a message it also sends through the wrapped client, which records it already. Remove the manual call.

Agent events arrive under a different user than product events

The route isn't reading req.amplitudeIds, or amplitudeIdentity is mounted after the chat router. Mount it before app.use('/chat', chat), and pass req.amplitudeIds.user_id instead of building a new ID.

The last few events before a deploy never arrive

The process exited with events still buffered. Confirm the shutdown handler waits for active requests and then awaits flushAgentEvents(), and that your host sends SIGTERM and waits before killing the process.

Turns of one conversation have different session IDs

The browser sends a new conversationId per message, or different agent names are used across requests. Keep the agent in lib/amplitude-ai.js and hold the ID for the life of the conversation.

Streamed replies have a User Message but no AI Response

The session closed before the stream finished. Loop over the stream inside the run() callback, as in step 5, and await run() before calling res.end().

[Agent] Cost USD is empty

The model name isn't one the price catalog knows, such as a gateway alias or a fine-tuned ft: model. Pass the canonical provider model ID.

FAQ

Can I keep conversation text out of Amplitude?

Yes. Set the privacy mode to metadata_only. Tokens, cost, latency, models, and session grouping remain available; message text and tool payloads stay out. Built-in quality signals that need message text will not run.

Should every message get a new agent session ID?

Use one stable ID for the same conversation or task, and a new ID for a new job. Never reuse an ID across users. These scope helpers close at the end of each request or task; later events with the same ID are stored but do not update the first Session Record.

Why are events visible before quality signals?

Events normally appear within about a minute. The Session Record typically follows 15 to 20 minutes after an explicit close, and can take longer during an enrichment backlog.

Full code reference

Every file this tutorial adds or changes, in one block, the version to hand to an agent. lib/amplitude.js, middleware/amplitude-identity.js, and the auth and billing routes are unchanged from the product analytics page.

Full code reference
// -- .env --
// AMPLITUDE_API_KEY=your-project-api-key
// AMPLITUDE_AI_API_KEY=your-project-api-key
// OPENAI_API_KEY=your-openai-key

// -- lib/amplitude-ai.js --
import { AmplitudeAI, AIConfig, OpenAI } from '@amplitude/ai';

const apiKey = process.env.AMPLITUDE_AI_API_KEY;

if (!apiKey) {
  throw new Error('AMPLITUDE_AI_API_KEY is not set');
}

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

export const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  amplitude: ai,
});

export const supportAgent = ai.agent('support-chat', {
  description: 'In-app support assistant',
});

// Wait for the analytics upload attempt before a process exits.
export async function flushAgentEvents() {
  const pending = ai.flush();
  await (pending?.promise ?? pending);
}

// -- lib/help-docs.js --
import { tool } from '@amplitude/ai';

async function searchHelpDocsImpl(query) {
  // ... your search: a vector store, a database, or an internal API
  return [{ title: 'Export a report', body: 'Open Reports, then Export.' }];
}

export const searchHelpDocs = tool(searchHelpDocsImpl, { name: 'search_help_docs' });

// -- routes/chat.js --
import { Router } from 'express';
import { openai, supportAgent } from '../lib/amplitude-ai.js';
import { searchHelpDocs } from '../lib/help-docs.js';

export const chat = Router();

function agentSession(req, conversationId) {
  return supportAgent.session({
    userId: req.amplitudeIds.user_id,
    deviceId: req.amplitudeIds.device_id,
    browserSessionId: req.get('x-amplitude-session-id'),
    sessionId: conversationId,
  });
}

chat.post('/', async (req, res) => {
  const { conversationId, messages } = req.body;

  const reply = await agentSession(req, conversationId).run(async () => {
    const question = messages[messages.length - 1].content;
    const docs = await searchHelpDocs(question);

    const completion = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [
        { role: 'system', content: `Answer from these docs:\n${JSON.stringify(docs)}` },
        ...messages,
      ],
    });

    return completion.choices[0].message.content ?? '';
  });

  res.json({ reply });
});

chat.post('/stream', async (req, res) => {
  const { conversationId, messages } = req.body;

  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.flushHeaders();

  await agentSession(req, conversationId).run(async () => {
    const stream = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages,
      stream: true,
      stream_options: { include_usage: true },
    });

    for await (const chunk of stream) {
      const text = chunk.choices[0]?.delta?.content;
      if (text) res.write(`data: ${JSON.stringify(text)}\n\n`);
    }
  });

  res.write('data: [DONE]\n\n');
  res.end();
});

// -- server.js --
import express from 'express';
import { amplitude } from './lib/amplitude.js';
import { ai, flushAgentEvents } from './lib/amplitude-ai.js';
import { amplitudeIdentity } from './middleware/amplitude-identity.js';
import { auth } from './routes/auth.js';
import { billing } from './routes/billing.js';
import { chat } from './routes/chat.js';

const app = express();
app.use(express.json());
app.use(amplitudeIdentity);
app.use('/auth', auth);
app.use('/billing', billing);
app.use('/chat', chat);

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

async function shutdown(signal) {
  console.log(`${signal} received, flushing Amplitude`);
  await new Promise((resolve, reject) => {
    server.close(error => error ? reject(error) : resolve());
  });
  await flushAgentEvents();
  await amplitude.flush().promise;
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Hand it to your agent

Prefer to have Cursor, Claude Code, or Codex CLI do the wiring? This prompt points the agent at this page and pins the decisions that matter.

Using Cursor or Claude Code? Paste this prompt
Add Amplitude agent analytics to this Express backend by following
https://amplitude.com/build/nodejs-agent-analytics exactly. Install
@amplitude/ai, @amplitude/analytics-node, and openai in the supported
>=4 <7 range. Create
lib/amplitude-ai.js that constructs AmplitudeAI from
process.env.AMPLITUDE_AI_API_KEY, the wrapped OpenAI client from
@amplitude/ai, and the agent, all at module scope; never call ai.agent()
inside a route. In each LLM route, open agent.session({ userId, deviceId,
browserSessionId, sessionId }) with userId and deviceId from
req.amplitudeIds, browserSessionId from the x-amplitude-session-id
header, and the conversation ID as sessionId, and make the model call
inside run(). Don't call trackUserMessage() for a message you pass to
the wrapped client; the wrapper records it. Wrap lookup functions the
model depends on with tool() and call them inside run(). For streaming, pass stream_options: { include_usage: true },
loop over the stream inside run(), and await it before ending the
response. In the SIGTERM and SIGINT
handler, await active-request draining, then flushAgentEvents(), then the product analytics flush before exiting. Never pass a placeholder
userId. Don't hardcode keys. Report every file you changed.

What this unlocks

  • Chart [Agent] Cost USD and [Agent] Latency Ms by model on AI Response events.
  • Connect agent and product events in funnels when they use the same stable user ID.
  • Review content-based quality signals on the Session Record after the session closes, when your privacy mode sends message text.

When browser session replay is already recording, forwarding both browser IDs adds a replay link to the agent session. Verify that recording separately from the server events.

Next steps

Ship the instrumentation, then read the data

Use the verification steps above to check your agent events, then configure the reports and replay links you need.