Set up Agent Analytics
This page describes how to customize your Agent Analytics set up. To complete the in-depth set up, you need to modify your environment with the Agent Analytics SDK. The Agent Analytics SDK is the full developer reference, covering install, initialization, instrumenting sessions and tools, provider notes, edge runtimes, in-process OTel span ingestion, cost handling, and the full API. If you'd rather send OpenTelemetry traces from a Collector than instrument with the SDK, refer to Send OpenTelemetry traces directly. If you want to quickly get started with basic Agent Analytics, go to the Agent Analytics Quickstart Guide. The quickstart guide doesn't allow customizations. You can always complete the quickstart guide to get insights as fast as possible and then come back to this set up guide to customize your Agent Analytics insights.
The timeline below shows what your instrumentation produces. Click any event to inspect its shape.
| [Agent] Session ID | 4ddcc6b2-1041-432a-aa8c-ebe3eccac40b |
| [Agent] Agent ID | support-chatbot |
| [Agent] Trace ID | b4f63d43-d752-4b1f-8489-d234ddf586b2 |
| $llm_message.text | I can help. Your subscription renews on Aug 15… |
| [Agent] Model Name | gpt-4o-mini |
| [Agent] Provider | openai |
| [Agent] Input Tokens | 1245 |
| [Agent] Output Tokens | 87 |
| [Agent] Latency Ms | 3420 |
| [Agent] Cost USD | 0.0012 |
s.trackAiMessage(...) or a provider wrapper.How setup works
You must have previously completed the following prerequisites before data shows up in Amplitude:
- You've selected which Amplitude project the data will flow into and you have access to the corresponding project API Key.
- You've instrumented your code. The Amplitude AI SDK (Node and Python) covers most stacks. Runtimes the SDK doesn't bundle into can send events directly to the HTTP API. These runtimes include Cloudflare Workers, other edges, and unsupported languages such as Java, Go, and Ruby.
- You've decided what leaves your app. Privacy mode controls whether prompt and response text reaches Amplitude.
Choose an instrumentation path
| Path | Effort | Use when |
|---|---|---|
| Agentic SDK (recommended) | Minutes | Any codebase you can modify |
| Manual SDK | Varies | You want full control over how the SDK is wired in |
| HTTP API | Varies | AI SDK doesn't fit your runtime (edge, browser, Java, Go, Ruby) |
| OTLP endpoint | Minutes | You already run an OpenTelemetry Collector |
Whatever the path, the goal is the same: agents plus sessions plus wrapped providers. That combination is what unlocks per-user analytics, enrichment, and quality measurement.
Define your sessions
An agent session is one unit of work with a start, a set of turns, and an outcome, such as a ticket resolved, a task completed, or a conversation ended. It groups the events that produced that outcome (User Message, AI Response, Tool Call, Score, Span) under one [Agent] Session ID. A session opens when your code first attaches events to that ID, stays open while activity keeps arriving, and closes either explicitly or by idle timeout. Enrichment (Session Record, signals, evaluator results) runs once per session, after it closes.
Every session belongs to a user; pass the same user ID you use for product analytics. Refer to Setting user IDs for identity rules.
Choose a session ID
Pass the ID your application already has for "this unit of work."
| Agent type | Session ID to use |
|---|---|
| Chatbot or copilot | Thread or conversation ID |
| Support agent | Ticket ID |
| Coding agent | Task or ticket ID |
| Voice agent | Call ID |
| Background or autonomous agent | Run or job ID |
If nothing fits, generate a stable UUID at session-start and persist it wherever your app already keeps unit-of-work state (ticket row, thread record, request context). Threading the same ID through every request handler is what keeps turns in one session; don't generate a fresh ID per request.
New goal, new session. Reusing a session ID across unrelated users conflates their data.
How sessions close
This is the most important lifecycle concept in Agent Analytics, because enrichment only runs on closed sessions. If your [Agent] Session Record events aren't appearing, start here.
A session closes one of two ways.
Explicit close (recommended): Call
trackSessionEnd()(Node) ortrack_session_end()(Python) when the unit of work finishes: ticket resolved, call ended, run completed. Sessions opened with a scope helper close automatically when that scope exits:run()in Node, thesession()context manager in Python. The close is written when the next ingestion batch processes the Session End event, so in the typical case expect the[Agent] Session Recordevent within about 15 to 20 minutes; it can take longer while an org has an enrichment backlog.Closing without the AI SDK: The server closes a session on the
[Agent] Session Endevent itself, so you can send that event directly from any Amplitude SDK or the HTTP API. Set the event type to[Agent] Session End, include an[Agent] Session IDproperty matching the session you're closing, and send it to the project where you've enabled Agent Analytics.json{ "event_type": "[Agent] Session End", "user_id": "user-123", "event_properties": { "[Agent] Session ID": "session-abc" } }This closes the session exactly as an SDK-emitted close does. Amplitude stores any additional properties you include with the event, but they don't affect closing.
Idle timeout (automatic fallback): If you never close explicitly, the server closes the session after 30 minutes of inactivity by default, measured from the last agent event received for the session. Any of these six
[Agent]events resets the clock: User Message, AI Response, Tool Call, Session End, Score, and Span. Server-generated enrichment events, including Session Record and Evaluator Result, do not. Ingestion runs on a cycle, so the close lands within roughly 15 minutes after the idle window elapses.
What closing does and doesn't do. Closing marks completion; it doesn't lock the session. Amplitude still accepts and stores later events, but enrichment runs once, so late turns never reach the session's signals, rollups, or Session Record. Only the first close counts: Amplitude ignores duplicates, and also ignores a [Agent] Session End event that arrives after the idle timeout already closed the session. The recommendation is to close when the job is genuinely done; if real work continues, start a new session.
Choosing a timeout
On top of the idle window, sessions without a per-session override also close after 24 hours regardless of activity. Setting any override (a positive value or -1) exempts the session from that cap.
| Value | Behavior |
|---|---|
30 | This is also the default setting. Session closes after 30 minutes of inactivity |
240, etc. | Raise the timeout for jobs that wait on humans, such as support tickets, coding agents. |
-1 | Idle window becomes 90 days and the 24-hour cap is waived, so an explicit Session End is effectively the only close. |
Set the timeout too low and a long pause closes the session early: follow-up events still attach and Amplitude stores them, but they land after enrichment has run, so they never reach the Session Record. Reach for -1 when quiet stretches are normal and unbounded, such as a ticket idle over a weekend, a long-running background job, and any fixed timeout would eventually split a real job. The tradeoff is that a session your app forgets to close stays open and unenriched until the 90-day backstop closes it.
Two caveats on -1:
- Agents with enrichment disabled close on the org default schedule
- The 90-day limit counts from when the session started, so a session that stays active for more than 90 days straight never auto-closes at all.
Where to set the override
Set idleTimeoutMinutes (Node) or idle_timeout_minutes (Python) when you open the session, and also include an idle_timeout_minutes key in the agent's context. Today the context route is what reliably reaches the server for sessions that never close explicitly; setting both guarantees the override takes effect and keeps working after the server-side fix ships.
// Node
const agent = ai.agent('support-bot', {
context: { idle_timeout_minutes: 240 }, // reliable today
});
const session = agent.session({
userId,
sessionId: ticketId,
idleTimeoutMinutes: 240, // the intended parameter
});
Checking which path ran
Every Session Record carries [Agent] Close Reason, either explicit_close or timeout, so you can monitor how often the fallback is doing the work.
Long-running sessions
The SDK batches events in memory and ships them on an interval. For short sessions (under a minute), batching is transparent. For long-running sessions such as tickets worked over hours, coding tasks that span a day, or background jobs, the close event you emit at the end can sit in the buffer, and a process restart before it ships means the session stays open until the idle timeout catches it.
Call ai.flush() right after trackSessionEnd() (Node) or track_session_end() (Python) to guarantee the close lands before the process exits or restarts:
agent.trackSessionEnd({ sessionId: ticketId });
await ai.flush();
Serverless handlers auto-flush on session.run() completion, so you don't need an explicit call there. For long-running servers, also call ai.flush() in your SIGTERM handler so buffered events across every open session ship before the process dies.
Choose a privacy mode
Three content modes control what leaves your infrastructure. Set the mode once in SDK config.
| full (default) | metadata_only | customer_enriched | |
|---|---|---|---|
Message content ($llm_message.text) | Sent | Not sent | Not sent |
| System prompt | Sent | Not sent | Not sent |
| Tool inputs and outputs | Sent | Not sent | Not sent |
| Score comments | Sent | Not sent | Not sent |
| Tokens, cost, latency, models, session grouping | Yes | Yes | Yes |
| Amplitude enrichment (signals, evaluators) | Runs | Limited | Runs |
Your own enrichment through trackSessionEnrichment() | Available | Available | Available |
Amplitude-generated enrichments (signals, topics, custom evaluators) run in every mode. What differs is what those enrichments see: in modes without content, expect content-dependent signals such as task completion and response quality to carry lower information, while structural signals (errors, friction patterns from behavior, cost, latency) remain useful.
Regulated environments (healthcare, finance) typically run metadata_only or customer_enriched. Decide the mode before wide rollout; it determines what your legal review needs to cover.
full
Message content, system prompts, tool inputs and outputs, and score comments all reach Amplitude. Amplitude's built-in enrichment runs and produces the fullest signal set.
- System prompt is an optional field; you can omit it.
- PII redaction defaults to on and scrubs emails, phone numbers, SSNs, credit cards, and IP addresses before events leave your process. Built-in phone and SSN detection targets US formats; add custom regex patterns or plug in your own redaction (for example, Presidio) for international locales or domain-specific identifiers.
metadata_only
No message content, system prompts, tool payloads, or score comments leave your process. You still get tokens, cost, latency, model names, and session grouping, so analyses that don't require content still work. Content-dependent signals carry lower information; structural signals remain useful.
customer_enriched
No content leaves your process, but Amplitude enrichment runs on the labels you provide via trackSessionEnrichment(). Your enrichment is the only session-level quality data, because Amplitude's signals have no content to work with.
customer_enrichedisn't exclusive. You can send Session Enrichment events in any mode; this mode is simply the one where those events are the sole source of session-level quality data.
Setting user IDs
Amplitude counts unique users by combining three identifiers (device ID, user ID, and Amplitude ID) into a single profile across anonymous sessions, sign-ins, and multiple devices. Send a stable user ID as soon as a person authenticates so anonymous events on the same device merge into one profile and your active-user counts stay accurate.
Every [Agent] event carries your stable user ID and unlocks cross-domain funnels, cohorts, and retention chart analyses.
If Amplitude encounters a known device ID that is already tied to a user ID in a different project, Amplitude assumes the device ID is tied to that user ID in all projects, even if you don't have the Portfolio add-on. For more information, refer to Portfolios.
Two rules keep a single user from splitting into two:
- Never pass a placeholder user ID such as "anonymous", an empty string, or a temporary ID. A user ID can't be changed once set, so a placeholder creates a permanent separate user that won't merge later. Omit the userId instead; anonymous activity merges into the known identity through standard identity resolution once the user identifies.
- Reuse the same device ID across a pre-account session. If your backend generates a new device ID per request, the user identity merge breaks. If possible, read the device ID from the Browser SDK and forward it.
Decide your ID strategy on day one of instrumentation.
Agentic SDK
Available for Node and Python only.
Instrument this app with Amplitude Agent Analytics using the Node SDK.
Install the SDK:
npm install @amplitude/ai @amplitude/analytics-node
Then follow `node_modules/@amplitude/ai/amplitude-ai.md`.
Paste this prompt into your AI coding agent (Cursor, Claude Code, Windsurf, GitHub Copilot, or Codex). The agent reads the linked instructions file, scans your codebase, finds every LLM call site and the session lifecycle, and instruments them.
Manual SDK
Available for Node and Python only.
Wrap your LLM client, name an agent, and run each conversation inside a session. That pattern produces every event type the product expects.
import { AmplitudeAI, OpenAI } from '@amplitude/ai';
const ai = new AmplitudeAI({ apiKey: process.env.AMPLITUDE_AI_API_KEY });
const openai = new OpenAI({ amplitude: ai, apiKey: process.env.OPENAI_API_KEY });
const agent = ai.agent('my-agent');
const session = agent.session({ userId, sessionId });
await session.run(async (s) => {
s.trackUserMessage(message);
return openai.chat.completions.create({ model, messages });
});
await ai.flush();
The wrapped provider (new OpenAI({ amplitude: ai }) in Node, patch(amplitude_ai=ai) in Python) auto-captures [Agent] AI Response, including the chat text, from the completion response. Without a wrapped provider, Amplitude doesn't track that event or its message content. If you're using a provider the SDK doesn't wrap, or you're calling an unwrapped client directly, call trackAiMessage() (Node) or track_ai_message() (Python) after the completion returns. For details and all options, refer to Manual instrumentation in the SDK reference.
Send OpenTelemetry traces directly
Limited availability
Amplitude enables the OTLP endpoint per project. Contact your Amplitude account team to enable it. Until then, the endpoint returns 403.
If your stack already emits OpenTelemetry GenAI spans, point your exporter at Amplitude. No Amplitude SDK, no re-instrumentation. Amplitude translates gen_ai.* spans into the same [Agent] events the SDK produces, so sessions, enrichment, and every chart work identically.
This is a different path from the in-process OTel exporters, which run inside a Node or Python app that already uses the AI SDK.
Configure your exporter
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://api.amplitude.com/otlp/v1/traces # US
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://api.eu.amplitude.com/otlp/v1/traces # EU
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <project API key>"
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # or http/json
- OTLP over HTTP only.
http/protobuf(the OpenTelemetry SDK and Collector default) andhttp/jsonare both accepted, and gzip is supported. OTLP/gRPC isn't supported: bridge through a local Collector with an OTLP/HTTP exporter. - Amplitude accepts the project API key as a
Bearertoken or as anapi_keyquery parameter. - Instrumentation Amplitude understands: OpenTelemetry GenAI semantic conventions, OpenInference (
llm.*), OpenLLMetry and Traceloop (traceloop.*), OpenLIT, and LiteLLM. Amplitude ignores non-GenAI spans in the same pipeline, so a mixed Collector is safe to point at Amplitude.
Set session and user attributes
This is the one thing worth changing in your instrumentation. Everything else maps automatically.
An Amplitude agent session is a whole conversation, not one trace. Set a conversation attribute, or every trace becomes its own single-turn session: task completion and abandonment then read as noise, and, since billing is per agent session, one conversation bills as several.
| Signal | Attributes Amplitude reads, in order |
|---|---|
| Session | gen_ai.conversation.id, session.id, traceloop.association.properties.session (or chat, thread, conversation), gen_ai.session.id, then resource session.id and gen_ai.conversation.id. With none of these, Amplitude falls back to the trace ID and marks the session degraded. |
| User | enduser.id, gen_ai.request.user, gen_ai.user, user.id, traceloop.association.properties.user, resource enduser.id and user.id, then LiteLLM's metadata.user_api_key_end_user_id. With none of these, the user resolves to unknown. |
| Agent | gen_ai.agent.id, else resource service.name. |
| Environment | deployment.environment, on the span or the resource. |
What maps to what
| OpenTelemetry source | [Agent] result |
|---|---|
gen_ai.operation.name of chat, text_completion, or generate_content | [Agent] AI Response, plus an [Agent] User Message synthesized from the new input messages |
gen_ai.operation.name of execute_tool | [Agent] Tool Call, with gen_ai.tool.name, gen_ai.tool.call.id, arguments, and result |
Any other operation, such as invoke_agent, retrieval, plan, or the memory operations | [Agent] Span |
gen_ai.request.model, gen_ai.response.model | [Agent] Model Name |
gen_ai.provider.name, or the deprecated gen_ai.system | [Agent] Provider |
gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, and the reasoning and cache token attributes | Token properties |
gen_ai.usage.cost | [Agent] Cost USD. This is an Amplitude extension: OpenTelemetry doesn't standardize cost, so most instrumentation omits it. |
gen_ai.response.finish_reasons | [Agent] Finish Reason |
gen_ai.input.messages, gen_ai.output.messages | $llm_message |
gen_ai.system_instructions, or a system-role message | [Agent] System Prompt on the AI Response |
Span status of ERROR | [Agent] Is Error, [Agent] Error Message |
trace_id, span_id, parent_span_id, start time, duration | [Agent] Trace ID, [Agent] Span ID, [Agent] Parent Span ID, event timestamp, [Agent] Latency Ms |
To report costs for directly exported spans, set gen_ai.usage.cost. Amplitude doesn't calculate a cost when this attribute is absent. Refer to How cost is calculated for details.
Because a chat call re-sends the whole prompt history, only the user messages that follow the last assistant message become a new [Agent] User Message. Amplitude doesn't re-emit replayed history, and a tool-loop continuation whose new input is a tool result produces no spurious user turn.
Content and privacy
Most OpenTelemetry GenAI instrumentation treats message content as opt-in. Leave content capture off and you get the metadata_only shape: cost, tokens, latency, model, and sessions, with no transcripts. Turn it on and gen_ai.input.messages, gen_ai.output.messages, and gen_ai.system_instructions flow into $llm_message and [Agent] System Prompt, where Amplitude's redaction and enrichment apply. Redaction that has to happen before data leaves your network belongs in a Collector processor.
Delivery, limits, and responses
Delivery is at least once. Run a Collector with a persistent sending queue, since an in-memory queue loses buffered spans on restart, and keep retries enabled.
| Limit | Behavior |
|---|---|
| 4 MB decompressed request body | 413 |
| 2,000 translated events per export | 413. Lower your Collector batch size. |
| Response | Meaning |
|---|---|
200 | Accepted. A partial_success body reports any events that ingestion rejected. Watch for it, because a 200 tells your Collector to delete the batch. |
400 | Malformed request body or OTLP payload. Not retryable. |
401 | Missing or invalid API key. |
403 | Project not enabled for OTLP ingestion. |
415 | A content type other than application/x-protobuf or application/json. |
503 | Temporary. Your Collector should retry from its queue; nothing is dropped. |
Verify
Send one traced conversation, then confirm the events arrive with a real [Agent] Session ID rather than a trace ID, a populated [Agent] Agent ID, and your own user IDs rather than unknown. Those three are what the attribute chains above buy you.
Send agent events without the AI SDK
The AI SDK is the lowest-friction path, but it isn't required, and it's Node.js and Python only. If your stack can't run it, emit the [Agent] taxonomy directly with a standard Amplitude SDK or the HTTP API. Common cases: browsers and client-heavy SPAs, edge runtimes (Cloudflare Workers, Deno), and AI app builders such as Lovable, Superblocks, v0, or Bolt, where the generated app is browser-first and importing @amplitude/ai fails on Node-only modules.
This section is the canonical wire contract for that path. Without the SDK, three responsibilities shift to you: session lifecycle (you emit Session End), privacy (you redact before tracking), and ID discipline (you generate and thread the IDs below).
The event contract
Emit these events with track() from any standard Amplitude SDK, or using the HTTP API. The wire format is a POST to Amplitude's HTTP endpoint carrying the [Agent] event contract as JSON:
curl -X POST https://api2.amplitude.com/2/httpapi \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_API_KEY",
"events": [{
"event_type": "[Agent] User Message",
"user_id": "user-123",
"event_properties": {
"[Agent] Session ID": "ticket-42",
"[Agent] Turn ID": 1,
"[Agent] Trace ID": "trace-abc",
"[Agent] Agent ID": "support-bot",
"[Agent] Message ID": "msg-1",
"$llm_message": { "text": "I need a refund" }
}
}]
}'
For EU data residency, replace the endpoint with https://api.eu.amplitude.com/2/httpapi. Everything below applies to both the standard-SDK track() route and this raw HTTP route.
Minimum viable:
[Agent] User Message[Agent] AI Response[Agent] Session End- Optional: add
[Agent] Tool Callwhen your agent uses tools.
Every event carries the shared event properties: [Agent] Session ID, [Agent] Turn ID, [Agent] Trace ID, [Agent] Agent ID, plus [Agent] Env, [Agent] Runtime (for example browser), and optionally [Agent] Context. Send the same user ID you use for product analytics. Always set [Agent] Agent ID. Without it, the Agent Analytics consumer still accepts the event but can't correlate it into a session, so it lands misgrouped and stays out of Agent Analytics views. The HTTP API still returns 200 because it acknowledges receipt before the consumer runs, so don't rely on that response to confirm the event landed correctly. Stamp a stable agent ID on every event.
| Event | Required beyond the envelope | Notes |
|---|---|---|
[Agent] User Message | [Agent] Message ID, $llm_message: { text } when your privacy posture sends content | Emit at request start. |
[Agent] Tool Call | [Agent] Invocation ID, [Agent] Tool Name, [Agent] Tool Success, [Agent] Latency Ms, [Agent] Parent Message ID (the triggering user message) | One event per invocation, emitted in execution order, before the AI Response. Tool payloads go in [Agent] Tool Input / [Agent] Tool Output, redacted. No token properties here. |
[Agent] AI Response | [Agent] Message ID, [Agent] Model Name, [Agent] Provider, [Agent] Latency Ms, token counts, [Agent] Cost USD if you compute it, $llm_message: { text } when sending content | System prompt is a property ([Agent] System Prompt) on this event, not a separate event. Token properties belong only here. |
[Agent] Session End | Optional: [Agent] Output State, [Agent] Abandonment Turn, [Agent] Session Idle Timeout Minutes | Emit when the job genuinely ends. Use these taxonomy properties only; don't invent close-reason or duration properties, the server derives [Agent] Close Reason and rollups on the Session Record. |
[Agent] Score | Refer to thumbs feedback | Same contract with or without the SDK. |
Rules that hand-rolled instrumentation gets wrong most often:
$llm_messagemust be an object:{ text: "..." }. Amplitude silently ignores a plain string, and the thread view renders no message content.- Turn ID identifies the exchange, not the event. Increment it once per user-message round trip and stamp the same value on the user message, every tool call, and the AI response of that exchange. Ordering within a turn comes from event time, so emit tool calls before the AI Response in real execution order. Generate one
[Agent] Trace IDper round trip and share it across the same events. - Stay inside the taxonomy. Don't invent event types or properties under the
[Agent]prefix; unregistered ones may not be queryable in charts. For business actions the agent performs (a purchase, a booking, a recommendation click), emit your standard product events with your existing names and properties, carrying the sameuser_id; that keeps agent-driven and click-driven journeys comparable in one funnel. - Provide cost for direct events. Amplitude doesn't calculate
[Agent] Cost USDfrom model and token properties, so include it when you want cost reporting. Refer to How cost is calculated for details. - Token and cost properties live only on AI Response. Tool calls don't consume tokens; the deciding call and the follow-up call are both part of the AI Response. Don't place
[Agent] Cost USDon other event types: the server sums that property from any event it ingests, so a stray cost on a Tool Call or Session End silently inflates the session total.
Session lifecycle without the SDK
The server closes any idle session after 30 minutes automatically, so you have a safety net even if you never emit Session End. Emit [Agent] Session End when the job genuinely ends (chat closed, ticket resolved, run completed), and let the server timeout catch abandonment.
Setting the idle override without the SDK: include an idle_timeout_minutes key inside the [Agent] Context JSON on your events (any event works; first value wins), and mirror it as [Agent] Session Idle Timeout Minutes on your Session End if you send one. The Context route is what the server reliably reads today. For example, on the first user message of the session:
amplitude.track('[Agent] User Message', {
'[Agent] Session ID': ticketId,
'[Agent] Turn ID': 1,
'[Agent] Trace ID': traceId,
'[Agent] Agent ID': 'support-bot',
'[Agent] Message ID': messageId,
'[Agent] Context': JSON.stringify({ idle_timeout_minutes: 240 }),
$llm_message: { text: redactedText },
});
// ...and if you emit an explicit close when the ticket resolves:
amplitude.track('[Agent] Session End', {
'[Agent] Session ID': ticketId,
'[Agent] Agent ID': 'support-bot',
'[Agent] Session Idle Timeout Minutes': 240,
});
Don't build your own short idle timer. Rotating session IDs after a few quiet minutes chops one conversation into several sessions: enrichment judges half-conversations, task completion looks artificially low, and, since billing is per agent session, one conversation bills as several. If your product genuinely defines sessions by inactivity, pass a matching [Agent] Session Idle Timeout Minutes and keep the window generous. Refer to the FAQ for the two-clocks explanation.
Privacy without the SDK
You own redaction, and it must run before track(). The most common mistake is gating message content while forgetting the other content-bearing properties. Gate all four consistently with your chosen posture:
- [ ]
$llm_message.texton User Message and AI Response - [ ]
[Agent] System Prompton AI Response - [ ]
[Agent] Tool Inputand[Agent] Tool Outputon Tool Call (redact recursively; payloads are objects) - [ ]
[Agent] Commenton Score
For metadata_only behavior, omit all four entirely; you still get cost, latency, tokens, and session grouping. For full behavior, redact PII (emails, phones, SSNs, cards, IPs) before tracking.
Browser and edge notes
- Identity: a persistent anonymous UUID in localStorage as
user_idgives anonymous visitors stable identity; standard identity resolution merges them when they sign in. - Session Replay: with the replay plugin active, stamp the replay properties onto every agent event so sessions link to recordings.
- Do not set the beacon transport globally. Calling
setTransport('beacon'), for example inside a session-end handler, applies permanently to the whole SDK: every later event fires fire-and-forget with no retry and drops silently. Use the default transport. - Latency and tokens are best measured server-side and returned to the client alongside the response, then attached to the events.
Recipe: instrument inside an AI app builder
In Lovable, Superblocks, or any prompt-driven builder, paste this into the builder's agent and review what it produces:
Instrument this app's chat agent with Amplitude Agent Analytics using the standard
@amplitude/analytics-browser SDK (do NOT use @amplitude/ai, it is Node-only and will
crash this runtime). Emit these events via amplitude.track(): [Agent] User Message at
request start, one [Agent] Tool Call per tool invocation in execution order,
[Agent] AI Response after the reply, and [Agent] Session End when the chat genuinely ends
(no short idle timers; the server auto-closes idle sessions). On every event include
[Agent] Session ID (one stable UUID per conversation), [Agent] Turn ID (increment once
per user-message exchange and stamp the same value on all events of that exchange),
[Agent] Trace ID (new UUID per exchange), [Agent] Agent ID (a stable name for this agent),
and [Agent] Runtime: "browser". Message text goes in $llm_message as { text: "..." }
(an object, not a string) on User Message and AI Response only. Put [Agent] Model Name,
[Agent] Provider, token counts, [Agent] Latency Ms, and [Agent] System Prompt on AI
Response only. Put [Agent] Tool Name, [Agent] Tool Success, [Agent] Latency Ms,
[Agent] Invocation ID, and [Agent] Parent Message ID on Tool Call. Redact PII (emails,
phones, SSNs, cards, IPs) from all message text, system prompts, and tool payloads
before tracking. Use one persistent anonymous UUID from localStorage as the Amplitude
user_id. Never call setTransport("beacon"). Use functional state updates when computing
turn counters from UI state so follow-up messages aren't lost to stale closures.
Then verify: send two messages, one that triggers a tool, and confirm in Live Events that all events share a session ID, each exchange shares one Turn ID and Trace ID, tool calls precede their AI Response, and $llm_message.text renders in the session thread view.
Instrument thumbs up / thumbs down feedback
Wire your feedback control to the SDK's scoring method so every rating lands as an [Agent] Score event. Three decisions matter:
- Name it
user-feedback. This exact Score Name has special semantics: your explicit feedback overrides the detected negative feedback signal for the session, so a thumbs up from the user beats a false-positive frustration detection, and a thumbs down flags the session even when the model saw nothing wrong. - Target the message. Rate the specific AI response the user reacted to by passing its
[Agent] Message IDas the target, with target typemessage. Use target typesessiononly for end-of-conversation ratings like a CSAT prompt. - Value 1 for up, 0 for down, source
user.
// Node: inside the session, when the user clicks the control
s.score('user-feedback', 1, aiMessageId, { source: 'user' }); // thumbs up
s.score('user-feedback', 0, aiMessageId, {
source: 'user',
comment: freeTextFromUser, // optional; gated by privacy mode
}); // thumbs down
Feedback often arrives after the request that produced the response has finished (the user clicks minutes later, from the frontend). For that path, call the client-level method with the session and target IDs you stored alongside the message:
ai.trackScore({
userId,
name: 'user-feedback',
value: 0,
targetId: aiMessageId,
targetType: 'message',
source: 'user',
sessionId,
});
Notes:
- Ratings on a five-star or CSAT scale work the same way: pass the numeric value and, if it isn't binary thumbs, use a different Score Name (for example
csat) so your thumbs metric stays clean. Reserveuser-feedbackfor the binary control because of its override semantics. - Don't worry about sparse clicks. Built-in signals score every session regardless, and implicit behavior (copy, regenerate, abandonment) is captured automatically; explicit ratings sharpen the picture where you have them.
- Amplitude never generates Score events, so a chart of
[Agent] Scorewhere Score Name isuser-feedbackis a pure measure of what your users said. The classic quality chart is the disagreement view: sessions the signals judged fine that users thumbed down.
Over HTTP without the SDK, POST the same event shape and the override semantics apply identically:
curl -X POST https://api2.amplitude.com/2/httpapi \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_API_KEY",
"events": [{
"event_type": "[Agent] Score",
"user_id": "user-123",
"event_properties": {
"[Agent] Session ID": "ticket-42",
"[Agent] Agent ID": "support-bot",
"[Agent] Score Name": "user-feedback",
"[Agent] Score Value": 1,
"[Agent] Target ID": "msg-1",
"[Agent] Target Type": "message",
"[Agent] Evaluation Source": "user"
}
}]
}'
Link to Session Replay
The agent sessionId and the browser session ID identify different things. The agent session is the job (a ticket, a conversation) and your application owns that ID. The browser session is the visit, and Amplitude's Session Replay generates that ID on the frontend.
If your frontend runs Amplitude's Session Replay, pass browserSessionId and deviceId (from the standard Amplitude Analytics Browser SDK) when you open the agent session on the backend. The Session Replay ID (<deviceId>/<sessionId>) then lands as [Amplitude] Session Replay ID on the session's [Agent] events. Once at least one event carries it, a session replay link shows in the sessions pane.
Where each ID comes from:
- Agent
sessionId: your application generates or reuses this from your existing unit-of-work ID (thread, ticket, task). Refer to Choose a session ID. browserSessionIdanddeviceId: the Amplitude Analytics Browser SDK maintains both on the client. Read them withamplitude.getSessionId()andamplitude.getDeviceId(), then forward them to your backend on the request that opens the agent session.
Multi-agent systems
Create child agents from a parent to record delegation. Child agents automatically carry the parent reference, sessions record the root agent and chain depth, and provider wrappers suppress spurious user-message events inside delegated calls. Give every agent a stable, human-readable ID; those IDs become the primary dimension for comparing quality and cost across your system.
Declare children off the parent with .child(), then dispatch to them with runAs() (Node) or arun_as() (Python). All events stay correlated under one [Agent] Session ID.
const orchestrator = ai.agent('shopping-agent', { description: 'Orchestrates shopping requests' });
const recipeAgent = orchestrator.child('recipe-agent', { description: 'Finds recipes' });
await orchestrator.session({ userId }).run(async (s) => {
s.trackUserMessage(userInput);
const result = await s.runAs(recipeAgent, async (cs) => {
cs.trackUserMessage(delegatedQuery);
return openai.chat.completions.create({ model: 'gpt-4o', messages: [...] });
});
});
For the full delegation semantics (context inheritance, span wrapping, nested children), refer to the SDK reference's Multi-agent architectures section.
Verify your instrumentation
Verification checks eight gates on your events:
- Events arriving at all
- Model Name and Provider populated
- Token counts populated
- Cost USD populated
- Latency populated
- Session ID is your real ID, not an auto-generated placeholder
- Agent ID set per component
- User ID from your auth layer
The setup agent writes a verification test as part of instrumentation. If you instrumented by hand, run one real interaction and check the gates in Live Events. Gates 6 through 8 intentionally fail on placeholder IDs; that's the ladder, not a bug.
Long-lived servers and multi-turn HTTP
Three placement rules keep sessions grouped correctly on an HTTP server that handles many turns and users.
Define agents at module scope, not per request. Constructing ai.agent(...) inside a request handler mints a fresh [Agent] Agent ID on every turn and breaks session grouping. Create the agent once at module load and reuse the reference.
// agent.ts (module scope)
export const agent = ai.agent('support-bot');
// handler.ts
export async function POST(req: Request) {
const { sessionId, userId, message } = await req.json();
return agent.session({ userId, sessionId }).run(async (s) => {
s.trackUserMessage(message);
// ...
});
}
Open one session per request, threading a stable sessionId. Call agent.session({ sessionId }) inside each request handler with the same sessionId value across every turn of the same job. Turns stitch together because they share the ID, not because they share the process.
Never reuse a sessionId across users. The session belongs to one user's job; a shared or global session ID conflates their conversations and breaks per-user analytics. If you emit [Agent] events over the HTTP API instead of the SDK, the same rule applies to the raw [Agent] Session ID property.
Production checklist
Once your traces start flowing into Amplitude, validate the following:
- Valid user IDs are being used
- Valid session IDs are being used
- There are explicit session close events where the outcome is known, and there are idle timeouts when expected
- Privacy mode decided and reviewed
- Agent IDs are stable and human-readable; child agents are wired for delegation
Running custom evaluators
Amplitude's built-in signals run without any setup on your part. But if you want to run custom-defined evaluators built for your own quality criteria, or calibration runs that compare an evaluator's judgments against your own labeled examples to tune its accuracy, you need to bring your own model provider key (BYOK). Add a key under Settings > AI Controls > Model Providers for any of the supported providers:
- OpenAI
- Azure OpenAI
- Anthropic
- Google Gemini
- Fireworks AI
- Amazon Bedrock
- Google Vertex AI
Custom evals and calibration runs use this key to make model calls on your behalf. Amplitude encrypts keys at rest and doesn't show them again after you save them, so keep your own copy if you need to reference or rotate it later.
Was this helpful?