---
title: "How to add agent analytics to a Node.js backend"
description: "Instrument an Express route that calls an LLM with the Amplitude AI SDK, track tool calls and streamed replies, and flush before the process exits."
canonical: "https://amplitude.com/en-us/build/nodejs-agent-analytics"
---

# How to add agent analytics to a Node.js backend

Instrument an Express route that calls an LLM with the Amplitude AI SDK, track tool calls and streamed replies, and flush before the process exits.

Source: https://amplitude.com/en-us/build/nodejs-agent-analytics

---

# 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](https://amplitude.com/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](https://amplitude.com/build/nodejs-product-analytics) 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](https://amplitude.com/build/nodejs-product-analytics), which sets `req.amplitudeIds`, and the browser `apiFetch` helper from [How to stitch client and server events to one user](https://amplitude.com/build/stitch-client-server-events), 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](https://app.amplitude.com/signup) to get your project API key.

These examples use a US-region Amplitude project. For an EU project, supply an [EU-configured analytics client](https://amplitude.com/docs/amplitude-ai/agent-analytics/privacy-modes#data-residency) 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](https://amplitude.com/docs/sdks/agent-analytics/sdk).

```sh
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

```sh
# .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

```javascript
// 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

```javascript
// 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](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## 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

```javascript
// 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

```javascript
// 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

```javascript
// 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

```javascript
// 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

```javascript
// -- .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

```text
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

[Web frameworksHow to add agent analytics to a Next.js appRoute handlers, the Node runtime, and streaming on Vercel.](https://amplitude.com/build/nextjs-agent-analytics)[Web frameworksHow to add agent analytics to a FastAPI backendThe same setup in Python, with async sessions and a lifespan flush.]()[Use caseHow to stitch client and server events to one userThe identity middleware and browser helper this page builds on.](https://amplitude.com/build/stitch-client-server-events)

Reference docs

[Agent analytics quickstart →](https://amplitude.com/docs/amplitude-ai/agent-analytics/quickstart)[Amplitude AI SDK reference →](https://amplitude.com/docs/sdks/agent-analytics/sdk)[Instrument your agent with the AI SDK →](https://amplitude.com/docs/amplitude-ai/agent-analytics/instrument-ai-sdk)[Agent analytics sessions and user identity →](https://amplitude.com/docs/amplitude-ai/agent-analytics/sessions)[Verify your agent analytics instrumentation →](https://amplitude.com/docs/amplitude-ai/agent-analytics/verify)[Amplitude MCP server →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

For agents

[View raw .md](/build/nodejs-agent-analytics.md)[llms.txt](/llms.txt)

## 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.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
