# Instrument with @amplitude/ai > **Reference:** [Agent Analytics SDK docs](https://amplitude.com/docs/sdks/agent-analytics/sdk#install-the-sdk) Auto-instrument a JS/TS AI app with `@amplitude/ai` in 5 phases: **Detect → Discover → Instrument → Verify → Ship**. The result is a fully instrumented app with provider wrappers, session lifecycle, multi-agent delegation (when detected), and a verification test proving correctness — all before deploying anything. --- ## Phase 1: Detect Environment 1. Read `package.json` for dependencies 2. Detect framework: `next` → Next.js, `express` → Express, `fastify` → Fastify, `hono` → Hono 3. Detect LLM providers: `openai`, `@anthropic-ai/sdk`, `@google/generative-ai`, `@google/genai`, `@aws-sdk/client-bedrock-runtime`, `@mistralai/mistralai`. Also detect **local/on-device LLMs**: `node-llama-cpp`, `@ollama/sdk`, `ollama`, `lmstudio` in deps, or a `baseURL` pointing to `localhost` / `127.0.0.1` that is not a known proxy (LiteLLM, OpenRouter, etc.) — flag these as `Local LLM: yes`. No `@amplitude/ai` wrapper exists for local models; plan manual tracking. Also detect **OpenAI-compatible proxies** (custom `baseURL` that does **not** match OpenAI's official endpoints `api.openai.com` or `us.api.openai.com` — e.g., an in-house gateway or a client library that forwards to multiple models): there is often **no** `@amplitude/ai` provider wrapper for that hop — plan **`trackAiMessage`** with **`usage`** from the **completion response** (or final stream chunk), same as stock `openai`. **Do not flag a `baseURL` set to an official OpenAI endpoint as a proxy** — the standard provider wrapper works fine. 4. Detect agent frameworks: `langchain`, `@langchain/core`, `llamaindex`, `@openai/agents`, `crewai` 5. Detect existing instrumentation: `@amplitude/ai` in deps, `patch({` or `AmplitudeAI` in source. Also detect **plain `@amplitude/analytics-node` or `@amplitude/analytics-browser` instrumentation** — files calling `amplitude.track('EventName', ...)` with custom event names. This produces no `[Agent]` events and will not appear in Agent Analytics. Flag it explicitly: **"Found plain Amplitude tracking with custom event names — these events will not show in Agent Analytics sessions. They must be replaced with `@amplitude/ai` `track*` calls."** Do not silently leave both in place. **If plain Amplitude packages are present, inspect their initialization (e.g. `amplitude.init(...)`, `new Amplitude(...)`) to find the API key env var already in use** (e.g. `process.env.AMPLITUDE_API_KEY`). Ask the developer: "I found an existing Amplitude setup using `AMPLITUDE_API_KEY` — should I reuse that key for Agent Analytics, or use a separate key under a different env var?" Use whichever they confirm; do not assume `AMPLITUDE_AI_API_KEY` if they already have a key configured. 6. Check for multi-agent signals: multiple files with LLM calls, tool definitions that call other LLM-calling functions, delegation patterns 7. Check for streaming: `stream: true` in provider calls 8. Determine whether the server delivers a browser UI. Ask: does any route or static handler send HTML to a browser? Evidence: an `index.html` anywhere in the project, a static file directory (`public/`, `static/`, `dist/`), HTML template files, or any response with `Content-Type: text/html`. Framework-specific deps (`react`, `vue`, `svelte`) are one signal but not the only one. If yes, flag `Frontend: yes` — a browser client is generating or persisting `sessionId` 9. Check for Vercel AI SDK: `@ai-sdk/*` in deps 10. Check for edge runtime: `runtime = 'edge'` in route files **Output to the developer:** ``` Detected environment: Runtime: Node.js (TypeScript) Framework: [framework or "none"] Providers: [list] Agent frameworks: [list or "none"] Existing instrumentation: [yes/no] Multi-agent signals: [yes/no] Streaming: [yes/no] Frontend deps: [yes/no] Recommended: full instrumentation ``` After listing the environment, derive the instrumentation requirements and append them: ``` Your instrumentation requirements: REQUIRED [if non-serverless — no Lambda/Vercel/Cloud Functions signals detected] Long-lived server → call `await ai.flush()` after each `session.run()`; use a stable sessionId tied to the conversation (not a per-request UUID); define agent at module level REQUIRED [if Bedrock and "type":"module" in package.json] Bedrock + ESM → pass `bedrockModule` explicitly to `Bedrock` constructor (no CJS require() available) REQUIRED [if streaming] Streaming → keep `session.run()` open until stream is fully consumed; do not return the stream before `run()` exits REQUIRED [if Local LLM: yes] No provider wrapper available → use manual tracking: `trackUserMessage` + `trackAiMessage` + `trackToolCall`. Doctor will report `provider_dependency` — this is a false positive, not a bug; the instrumentation is correct. Pass `totalCostUsd: 0` on every `trackAiMessage` call — omitting it will silently fail the Phase 4 data quality cost check. REQUIRED [if Frontend: yes] Client sessionId scope → verify the browser client resets `sessionId` when a new conversation starts; a sessionId persisted in localStorage across page loads will silently attach new events to an old session that predates instrumentation and will never appear in Agent Analytics WARNING [if cross-region Bedrock model ID e.g. us.*, eu.*] Cross-region model ID → cost tracking will be missing; use canonical Bedrock model ID for cost (e.g. anthropic.claude-3-5-sonnet-20241022-v2:0) ``` Only emit requirement lines that apply to the detected environment. Carry this list through Phases 3 and 4: Phase 3 implements every REQUIRED item; Phase 4 verifies each one behaviorally. **PAUSE.** Show the detection output and requirements list to the developer. Ask if anything looks wrong or missing before continuing. Do not start Phase 2 until they confirm. Once confirmed, proceed to Phase 2 — always instrument with agents, sessions, provider wrappers, tool tracking, and scoring. If multi-agent signals are detected, also add child agents and `runAs` delegation. --- ## Phase 2: Discover Agents and Call Sites 1. Identify files with LLM call sites (search for `chat.completions.create`, `messages.create`, `generateContent`, `streamText`, `generateText`) 2. For each file with call sites, read the actual source and review: - Is it a route handler / API endpoint? - What provider(s) does it use? - Does it call other files with LLM call sites? (delegation → multi-agent) 3. Identify: - Agent boundaries (each distinct orchestration unit = one agent) - Delegation patterns (parent calls child → `runAs`) - Feedback handlers (thumbs up/down UI components) - Tool functions (functions called by the LLM via function calling) - **If Frontend: yes was flagged in Phase 1**: also find the client-side file where `sessionId` is generated and stored. Identify whether it uses `localStorage`, a React state, or another mechanism — this is the file that Step 3j will fix. 4. For each event emission you plan to add, trace **all code paths** that should emit the same event type. Look for error handlers, retry-exhaustion paths, timeout handlers, and fallback branches that represent the same logical operation failing — these should also emit the event (typically with `success: false` or an `errorMessage`). ### Multi-Agent Detection Look for these patterns by reading the source files: 1. Do any tool functions call other functions that make LLM calls? → **delegation-as-tools** (A2A) pattern 2. Does a parent function call a function in another file that has LLM call sites? → **sequential delegation** 3. Are there multiple distinct agent roles or personas with separate system prompts? 4. Identify **orchestration wrappers** — functions that invoke sub-agents and should measure their execution. These are candidates for `observe()` / `trackSpan()` to capture delegation latency, input/output summaries, and error status. Look for: try/catch blocks around sub-agent calls, functions that dispatch to multiple agents in sequence or parallel, and any function that measures duration of a delegated operation. Only mark the architecture as multi-agent once you've confirmed one of these patterns by reading the source. **Output to the developer:** ``` Found N agents across M files: Agent 1: "chat-handler" Description: "Handles user chat requests via streaming OpenAI GPT-4o" File: src/app/api/chat/route.ts Provider: OpenAI (chat.completions.create) Entry point: POST /api/chat Agent 2: "recipe-agent" (child of chat-handler, called as a tool) Description: "Specialized recipe planning agent called by the orchestrator" File: src/lib/recipe-agent.ts Provider: OpenAI (chat.completions.create) Delegation: ask_recipe_agent() tool in Agent 1 delegates to this file Multi-agent architecture: delegation-as-tools (A2A) → will instrument with ai.agent().child() + session.runAs() Requirements to implement (from Phase 1): □ [copy each REQUIRED item that applied — do not omit any] Files to create or modify: CREATE src/lib/amplitude.ts — bootstrap (AmplitudeAI, AIConfig, provider wrappers) MODIFY src/app/api/chat/route.ts — wrap handler with agent.session().run() [list all files that will change] Proceed with instrumentation? [Review changes first / Apply / Skip] ``` **PAUSE.** Present the agent map, the REQUIRED items list, and the file plan to the developer — no code has been written yet. Confirm agent names and descriptions (these appear in dashboards) and get the go-ahead before starting Phase 3. The developer must see and approve the REQUIRED items here; do not begin Phase 3 until confirmed. --- ## Phase 3: Instrument **Before writing any code**, verify your implementation plan covers every REQUIRED item from Phase 1. Each maps to a specific step: | REQUIRED item | Implemented in | |---------------|---------------| | Non-serverless flush (`await ai.flush()` + `try/finally`) | Step 3d | | Stable `sessionId` tied to conversation, not per-request UUID | Step 3d | | Agent defined at module level (singleton) | Step 3b | | Bedrock + ESM → pass `bedrockModule` explicitly | Step 3b | | Streaming → keep `session.run()` open until stream consumed | Step 3i | | Local LLM → manual tracking + `totalCostUsd: 0` on every call | Step 3e | | Frontend `sessionId` scope → reset on new conversation | Step 3j | If a REQUIRED item from your Phase 1 list does not appear in this table, implement it in the most relevant step and do not skip it. Every REQUIRED item must have code written for it before Phase 4. ### Step 3a: Install dependencies Check whether `@amplitude/ai` is already in `package.json` dependencies. If it is, skip this step. If not, detect the package manager (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, otherwise npm) and install: ```bash pnpm add @amplitude/ai # pnpm yarn add @amplitude/ai # yarn npm install @amplitude/ai # npm ``` ### Step 3b: Create bootstrap file **Use the agent names confirmed with the developer in Phase 2** — not the example names below. Agent IDs appear in dashboards and the Application Registry; a wrong name here means wrong names everywhere. Create `src/lib/amplitude.ts` (or the project's conventional lib path, as listed in the Phase 2 file plan): **Choose `contentMode` based on privacy needs:** - **`full`** — captures full prompt/response text. Best for debugging and enrichment. `redactPii` defaults to `true` in this mode, so emails, phone numbers (US and international E.164), SSNs, credit cards, and IPv4/IPv6 addresses are scrubbed automatically. Use `customRedactionPatterns` (strings or `{ pattern, replacement }` objects) and `customRedactionFn` for domain-specific PII. Only set `redactPii: false` with explicit customer consent. - **`metadata_only`** — captures token counts, latency, model, cost, but no text. Use for sensitive PII or regulated data. - **`customer_enriched`** — no text captured by default, but the customer can send enriched summaries via `trackSessionEnrichment()`. ```typescript import { AmplitudeAI, AIConfig, OpenAI } from '@amplitude/ai'; export const ai = new AmplitudeAI({ apiKey: process.env.AMPLITUDE_AI_API_KEY!, config: new AIConfig({ contentMode: 'full', redactPii: true, }), }); // One wrapped client per provider detected in Phase 1 export const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY!, amplitude: ai, }); // Add more providers as detected: // export const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY!, amplitude: ai }); ``` Add `AMPLITUDE_AI_API_KEY` to `.env.example`. Check `.gitignore` includes `.env`. > **`.env` is not loaded automatically by Node.** `process.env.AMPLITUDE_AI_API_KEY` is read at module load time — if the env var isn't in the shell when `node` starts, the SDK initializes with `undefined` and every session becomes a silent no-op (no error, no log output). Start the server with `node --env-file=.env server.js` (Node 20+) or add `import 'dotenv/config'` at the top of your entry point before any SDK imports. Verify the var is present before starting: `echo $AMPLITUDE_AI_API_KEY`. > **Agent as module-level singleton:** Define agents at the top level of the bootstrap file or route module — never inside a request handler. Re-creating `ai.agent(...)` on every request produces a different `[Agent] Agent ID` per turn, which breaks session grouping in Agent Analytics. > **Note:** If you cannot modify provider instantiation sites, use `wrap(existingClient, ai)` to instrument an existing client, or `patch({ amplitudeAI: ai })` for zero-code verification. These capture fewer event types — always prefer provider wrappers when possible. `wrap()` supports OpenAI, Azure OpenAI, Anthropic, Gemini (`@google/generative-ai`), Google Gen AI (`@google/genai`), Bedrock, and Mistral clients. ### Step 3c: Swap provider imports Replace direct provider instantiation with imports from the bootstrap file: **Before:** ```typescript import OpenAI from 'openai'; const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); ``` **After:** ```typescript import { openai as client } from '@/lib/amplitude'; ``` ### Step 3d: Add session context Wrap route handlers with agent + session: ```typescript import { ai } from '@/lib/amplitude'; const agent = ai.agent('chat-handler', { description: 'Handles user chat requests via streaming OpenAI GPT-4o', }); export async function POST(req: Request) { const { messages, userId, sessionId } = await req.json(); return agent.session({ userId, sessionId }).run(async (s) => { // Do NOT call s.trackUserMessage() here — the provider wrapper auto-emits // [Agent] User Message from the messages array. Calling both produces duplicates. const response = await client.chat.completions.create({ model: 'gpt-4o', messages }); return Response.json(response); }); // session.run() auto-flushes in serverless (Vercel, Lambda, Netlify, GCP Cloud Functions, Azure Functions, Cloudflare Pages Functions) // For non-serverless, or tracking outside session.run(), call: await ai.flush() } ``` - Pass **`sessionId`** from the request — use the thread, ticket, call, or run ID the app already tracks (not a random UUID in production). - Let **`session.run()`** exit (or call `trackSessionEnd()`) when the job is done. `[Agent] Session End` is optional and recommended. If it never arrives, the server closes the session after 30 idle minutes by default. Enrichment runs once after that close, and the Session Record typically arrives 15–20 minutes after the ingestion batch processes it. Set **`idleTimeoutMinutes`** if turns can be hours apart. - Full semantics: see README [What is an agent session?](#what-is-an-agent-session). > **[Frontend: yes] Client-managed sessionId — required check.** If the browser client generates its own `sessionId` (common when the app doesn't use the Amplitude browser SDK), verify it is conversation-scoped: generate a fresh `crypto.randomUUID()` when a new conversation starts, and do not persist it indefinitely across page loads. A sessionId stored in `localStorage` and never reset will silently route all new events under an old session — the server accepts and enriches them correctly, but they never appear as new sessions in Agent Analytics because the ID predates instrumentation. This is invisible at the server level: HTTP 200, events ingested, nothing in the logs. The fix must happen in the client. During verification (Step 4d-live), use an incognito window, which starts with empty storage and guarantees a fresh sessionId. > **Latency on error paths:** The happy-path examples above capture latency only on success. For production code, start the timer before the LLM call and record latency in a `finally` block so it's captured whether the call succeeds or throws: > ```typescript > const start = performance.now(); > try { > const response = await client.chat.completions.create({ model, messages }); > // provider wrapper auto-emits [Agent] AI Response with latency on success > } catch (e) { > s.trackAiMessage('', model, 'openai', performance.now() - start, { > isError: true, > errorMessage: (e as Error).message, > }); > throw e; > } > ``` > Without this, a failed LLM call produces no `[Agent] AI Response` event — the turn is invisible in Agent Analytics and latency/error-rate charts miss the failure entirely. > **Multi-turn HTTP servers:** Close the session when the unit of work finishes, not after every request. `session.run()` emits `[Agent] Session End` on exit by default, and the first close is the one enrichment uses. For a conversation that continues across requests, open each request with `trackSessionEnd: false` and call `trackSessionEnd()` once when the job is done. Keep one stable `sessionId` on every request, and define the agent at module level so `[Agent] Agent ID` stays consistent. If either drifts, turns appear as separate sessions. > > **Non-serverless flush:** `session.run()` auto-flushes only in serverless environments (Vercel, Lambda, Netlify). For long-lived servers (Express, Fastify, plain Node `http`), call `await ai.flush()` after each `session.run()`. Use `try/finally` so events are flushed even when `session.run()` throws — without it, an unhandled error silently drops all queued events: > ```typescript > try { > await agent.session({ userId, sessionId }).run(async (s) => { ... }); > } finally { > await ai.flush(); > } > ``` > Without the flush, events are queued in memory and sent on the next interval — or never, if the process restarts. No error, no log output; silence here means data loss. **User message text vs structured pipeline data (critical for Agent Analytics UI):** - `s.trackUserMessage(...)` **first argument** (the string `content`) becomes **`$llm_message.text`** on `[Agent] User Message`. That string is what **session lists, segmentation, and enrichment** treat as “what the user said.” - **Do not** pass large JSON blobs, RAG context packs, or internal pipeline state as the **only** user message body — the product will show that JSON as the session title and break down charts by raw JSON. - **Do** pass a **short natural-language** line (the real end-user prompt, or a canonical summary for headless jobs, e.g. `"Summarize the attached design doc and list open questions"`). - Put structured segmentation dimensions in the **options** object: **`context`** (object → `[Agent] Context` JSON, always queryable in charts). Avoid `eventProperties` for custom dimensions on `[Agent]` events — unregistered properties may be silently dropped by schema enforcement. ```typescript // GOOD: readable user line + structured state in context s.trackUserMessage('Summarize the attached design doc and list open questions', { context: { structuredPayload: payloadRecord }, }); // BAD: entire pipeline state as the "user message" (shows up as session label / $llm_message.text) s.trackUserMessage(JSON.stringify(payloadRecord)); ``` **Provider wrapper auto-tracking caution:** Wrapped providers automatically emit `[Agent] User Message` for every `role: "user"` message in the LLM request. **Do not call `s.trackUserMessage()` for the same user input you are also passing to a provider wrapper** — you will get two `[Agent] User Message` events for a single user turn. Choose one: either call `s.trackUserMessage()` manually and suppress auto-tracking, or rely on the provider wrapper and omit the manual call. If internal delegation calls include structured prompts as `role: "user"`, those will appear as duplicate user turns unless you use `runAs()` (which suppresses auto user-message tracking). See **Step 3g-b** for the canonical fan-out pattern. Structured content channels, including tool input/output, attachments, and span state, recognize complete JSON object/array strings up to 100,000 characters, within the runtime parser limits. Within serialized JSON strings, built-in PII redaction scans decoded string values and object keys. Numeric tokens under recognized phone, card, and SSN field names (for example, `phone_number`, `creditCardNumber`, and `ssn`, ignoring case and punctuation) become string placeholders; that context extends through nested objects, arrays, and serialized JSON strings. Other numeric tokens, including timestamps and large integers, remain verbatim. Distinct original keys that would collapse to the same redacted key fail the entire serialized document closed. This field-sensitive handling does not change native object/array sanitization. Ambiguous unlabeled numbers require producer-selected content or configured custom redaction. Custom patterns and callbacks retain their original string boundary and run once after built-in redaction, followed by base64 redaction. If these later steps make an originally valid JSON document invalid, the SDK emits `[content redacted]`. Native parser limits or excessive nested JSON-string depth also fail closed. Numeric-looking strings, including timestamps embedded in HTML source, remain ordinary redaction. Malformed or oversized JSON follows the ordinary text-redaction and truncation path; existing final size limits can still truncate output. **HTML artifact capture:** HTML inside `$agent_artifact` envelopes uses the same content settings, ordinary string redaction, and serialization as other tool/span input/output and AI/user message attachments. `full` captures these fields; `metadata_only` and `customer_enriched` omit them. This runtime behavior does not require a new artifact-specific SDK API or a minimum SDK version beyond the tracking APIs you use. Version 0.24.0 adds optional `$agent_artifact` typing for inline attachment literals; it does not add schema validation or change capture. Select source data approved for analytics capture before constructing HTML. The producer owns the snapshot content. Ordinary redaction may retain text encoded in entities, split across elements, generated by CSS, or hidden by layout. The SDK does not parse HTML, inspect rendered output, enforce a CSS subset, or guarantee removal of every rendered identifier. Its configured PII patterns and custom redactor make one ordinary best-effort pass over each string value; they can change the HTML source and are not a browser-aware sanitizer. Do not add a second SDK redaction pass around these tracking calls. A custom callback should return the intended final string and can still fail closed through the existing ordinary sanitizer behavior. Preflight the completed snapshot and containing serialized field against the receiving artifact contract and existing SDK/event size limits before tracking. Ordinary serialization can truncate oversized values; it does not provide artifact-specific completeness guarantees. Omit an optional snapshot at the producer when it cannot fit intact. Keep the original event and useful metadata, and emit each event once. HTTP/OTLP/warehouse integrations have their own transport-specific capture and redaction configuration; see `docs/integrations/privacy.md`. **Enrichment vs Agent Analytics UI:** A short **`content`** line plus structured data in **`context`** keeps session titles and segmentation readable. Amplitude’s **server-side LLM enrichment** builds eval input primarily from **turn text** stored on each session (`input_text` / `output_text` derived from `$llm_message`). If automated evaluators must reason over the **full** structured payload, keep essential facts in **`content`**, add **scalar event properties** for key fields, or coordinate a pipeline change to merge allowlisted **`context`** into enrichment input—do not assume enrichments automatically parse large JSON blobs only present in `[Agent] Context`. If multi-agent signals were detected, add delegation with `runAs`: ```typescript const orchestrator = ai.agent('shopping-agent', { description: 'Orchestrates shopping requests' }); const recipeAgent = orchestrator.child('recipe-agent', { description: 'Finds recipes' }); // Inside parent's session.run() — track user message at orchestrator level, // then delegate. Auto user-message tracking is suppressed inside runAs. s.trackUserMessage(delegatedQuery); const result = await s.runAs(recipeAgent, async () => { return openai.chat.completions.create({ model: 'gpt-4o', messages: [...] }); }); ``` ### Step 3e: Track tools and explicit AI responses **Tool tracking** with the `tool()` higher-order function: ```typescript import { tool } from '@amplitude/ai'; const searchProducts = tool(searchDB, { name: 'search_products' }); // Inside session.run(), just call the wrapped function: const result = await searchProducts(query); // [Agent] Tool Call event automatically emitted with duration, success, input/output ``` > `tool()` applies to **any function the agent calls as a tool step** — not just external API calls. Deterministic helpers, local classifiers, pre-processing functions — if the agent invokes it as part of its reasoning flow, wrap it. Without wrapping, the tool step is invisible to Agent Analytics even though the rest of the session is tracked. **Agentic actions that drive business outcomes:** When an agent performs a business action on the user's behalf — adding to cart, completing a purchase, submitting a form — emit **two** events, on two different planes: 1. **`[Agent] Tool Call`** (via `tool()` or `trackToolCall()`) — operational telemetry: latency, success/failure, error rate on the action itself. 2. **The standard product event via the base Amplitude SDK** — business attribution, in the **same taxonomy as non-agent (web/mobile) journeys**. The bootstrap (`src/lib/amplitude.ts`) creates only the AI SDK, so the business event uses whatever base analytics SDK the product already runs — `@amplitude/analytics-node` server-side (shown here) or `@amplitude/analytics-browser` client-side. Do not introduce a second analytics pipeline. ```typescript import { track } from '@amplitude/analytics-node'; import { tool } from '@amplitude/ai'; const addToCart = tool( async (productId: string, quantity: number) => { const result = await cartService.add(productId, quantity); // throws on failure → no false conversion // Same event name + properties the web/mobile clients already send, plus one discriminator. track( 'Product Added', { product_id: productId, quantity, journey_type: 'agent' }, // distinguishes agent- from click-driven { user_id: currentUserId }, // same user_id the [Agent] events use → join key ); return result; }, { name: 'add_to_cart' }, ); ``` This gives you, with no joining: - **Operational view:** `[Agent] Tool Call where tool_name="add_to_cart"` → latency, error rate, success rate on the action. - **Business view:** `Product Added where journey_type=agent` vs `journey_type=web` in the **same funnel** — comparable because they share event taxonomy and user. Rules to get this right (a coding agent must follow these, not guess): - **Only for business actions that have a click-driven equivalent** (add to cart, purchase, signup, form submit). Read-only tools (`search_products`, `get*`) emit **only** `[Agent] Tool Call` — no product event. - **Reuse the product's existing event name and property names — do not invent them.** Add only the `journey_type` discriminator. If you cannot locate/confirm the existing taxonomy in the codebase (common when instrumenting an agent-only service), **stop and ask the developer** rather than guess — inventing `Product Added`/property names silently forks the taxonomy and breaks the funnel join. - **Emit after the action succeeds**, attributed to the **same `user_id`** the `[Agent]` events use. Use a consistent enum across the codebase (`agent` / `web` / `mobile`). - `journey_type` is a new convention with no schema backstop — consistency is on you. > This is the **one** place the base SDK's `track()` is correct (see Safety Rules). It applies only to genuine business/product events for cross-journey attribution — never to agent telemetry, which must always go through `track*`. **Explicit AI response capture** (when provider wrappers can't auto-capture): ```typescript s.trackAiMessage(completedMessage.content, 'gpt-4o', 'openai', latencyMs, { inputTokens: usage.prompt_tokens, outputTokens: usage.completion_tokens, totalTokens: usage.total_tokens, }); ``` **Proxies and OpenAI-compatible gateways:** When calls go through a gateway (custom `baseURL`, unified API, etc.), `@amplitude/ai` may not wrap that client. After each completion, read **`usage`** from the response (or final stream chunk) and pass **`inputTokens` / `outputTokens` / `totalTokens`** into `trackAiMessage`. For the **model** argument, use the **real provider model id** the gateway routed to (e.g. `gpt-4o-mini`, `claude-sonnet-4-20250514`) — not an internal gateway product label. > **Cost tracking for proxies/gateways:** `client.trackAiMessage()` auto-calculates cost via genai-prices when `model` and token counts are provided and `totalCostUsd` is not set. If the model cannot be priced, the SDK omits `[Agent] Cost USD` rather than recording an authoritative `$0`. > > **1. Unrecognized model name.** Common causes of omitted cost: > - Vertex AI model aliases (e.g. `claude-sonnet-4-6` instead of canonical `claude-sonnet-4-20250514`) > - Internal gateway product labels (e.g. `my-company/gpt4` instead of `gpt-4o`) > - Brand-new models not yet in genai-prices > > **2. Incorrect `inputTokens` with prompt caching.** `calculateCost` always expects `inputTokens` to be the **cache-inclusive total** (cache tokens are a subset, never additive). The built-in provider wrappers normalize this for you; only manual `trackAiMessage` callers need to handle it. Provider conventions for the raw API values: > - **OpenAI:** `prompt_tokens` **already includes** `cached_tokens` — pass it directly as `inputTokens` > - **Anthropic / Bedrock (Converse):** raw `input_tokens` **excludes** cache tokens — add them: `inputTokens = input_tokens + cache_read_input_tokens + cache_creation_input_tokens`. The Anthropic and Bedrock wrappers do this internally (AA-151026). > - **Gemini:** `promptTokenCount` **already includes** cached tokens; cache reads are reported separately as `cachedContentTokenCount`. The Gemini wrapper now captures it so the cache discount is applied (AA-151026). > > Pass `cacheReadTokens` / `cacheCreationTokens` separately so the SDK applies differential pricing (cached tokens are cheaper). > > Fix cost issues by: > 1. Normalizing the model name to the canonical provider ID > 2. Normalizing `inputTokens` per the provider convention above > 3. Or calling `calculateCost({ modelName, inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens })` yourself and passing **`totalCostUsd`** explicitly — this overrides auto-calculation > 4. Use the Phase 4 data quality gate (below) to catch missing cost during development **Cost guarantee contract (Monitor / Nova):** | Path | Requirement | |------|-------------| | **Provider wrap** | Every LLM completion → `[Agent] AI Response`; `[Agent] Cost USD` is included automatically when pricing is available | | **Custom / batch / artifact-only** | Call **`trackRunCost()`** at run end when wrap did not emit per-call AI Responses | | **Session End** | Lifecycle only — **do not** attach cost fields | `trackRunCost()` emits **delta** cost only. Default **empty content** — omit `$llm_message` for cost-only balancing events. Pass `content` when you want a session-viewer bubble. ```typescript s.trackRunCost(runCostUsd, inputTokens, outputTokens, modelName, 'openai', { latencyMs, content: '[Artifact: batch run]', // optional }); ``` ### Step 3f: Managed and hosted agent architectures **Use this pattern when LLM calls happen server-side** — Anthropic Managed Agents, OpenAI Assistants API, agent-as-a-service platforms, or LLM gateways where you poll for results rather than calling `messages.create` directly. Provider wrappers have nothing to intercept in this architecture; use manual tracking instead. **Privacy / content mode:** For managed agents, use `contentMode: 'full'` with `redactPii: true`. The managed API already stores message content server-side, so `metadata_only` offers no additional privacy benefit — but PII redaction remains valuable. **Track user messages** sent to the managed agent: ```typescript s.trackUserMessage(userInput); ``` **Track AI responses** from polled events, passing token counts and cost from the API response: ```typescript s.trackAiMessage(responseText, event.model, 'anthropic', responseLatencyMs, { inputTokens: event.usage.input_tokens, outputTokens: event.usage.output_tokens, totalCostUsd: event.usage.cost, // or omit — SDK auto-calculates from model + tokens }); ``` **Track tool calls** observed from the managed agent's event stream. Use `trackToolCall` (not `trackSpan`) — it emits the correct `[Agent] Tool Call` event type: ```typescript s.trackToolCall(toolEvent.name, toolEvent.durationMs, !toolEvent.isError, { input: toolEvent.input, output: toolEvent.output, }); ``` **Anthropic Managed Agents example** — polling `client.beta.sessions` for events: ```typescript import Anthropic from '@anthropic-ai/sdk'; import { AmplitudeAI } from '@amplitude/ai'; const ai = new AmplitudeAI({ apiKey: process.env.AMPLITUDE_AI_API_KEY! }); const agent = ai.agent('design-agent', { description: 'Anthropic managed design agent' }); const client = new Anthropic(); // Create a managed agent session const managedSession = await client.beta.sessions.create({ agent_id: 'your-anthropic-agent-id', messages: [{ role: 'user', content: userInput }], }); const session = agent.session({ userId, sessionId }); await session.run(async (s) => { s.trackUserMessage(userInput); // Poll for completion events const events = await client.beta.sessions.messages.list({ session_id: managedSession.id, }); for (const event of events) { if (event.type === 'message' && event.role === 'assistant') { s.trackAiMessage(event.content[0].text, event.model, 'anthropic', event.latencyMs, { inputTokens: event.usage.input_tokens, outputTokens: event.usage.output_tokens, }); } else if (event.type === 'tool_use') { s.trackToolCall(event.name, event.durationMs, true, { input: event.input, output: event.output, }); } } }); ``` ### Step 3g: Add span tracking for orchestration **`[Agent] Span` and `observe()` / `trackSpan()` do not replace turn-level events.** Agent Analytics **turn counts** and conversation views are driven by **`[Agent] User Message`** and **`[Agent] AI Response`** (with **`[Agent] Session ID`** and **`[Agent] Turn ID`**). If you only add spans around internal completion steps, Amplitude may show **spans in a trace** but **missing or incomplete** turn-level analytics — always ensure **each user-visible cycle** has the **user + AI response** pair (or explicitly document intentional single-turn pipelines). Add spans **in addition to** that pair, not instead of it. When a parent agent delegates work to a child agent, wrap the delegation call with span tracking to measure latency and capture errors. Look for existing try/catch blocks around sub-agent execution — these are natural places to add span tracking with both success and error paths: ```typescript import { observe } from '@amplitude/ai'; // Option A: higher-order function on orchestration functions const runSubAgent = observe(async (prompt: string) => { return await subAgent.execute(prompt); }, { name: 'sub-agent-execution' }); // Option B: explicit tracking when you need more control const start = Date.now(); try { const result = await subAgent.execute(prompt); s.trackSpan({ name: childAgentName, latencyMs: Date.now() - start, inputState: { prompt: prompt.slice(0, 1000) }, outputState: { response: result.slice(0, 1000) }, }); } catch (e) { s.trackSpan({ name: childAgentName, latencyMs: Date.now() - start, isError: true, errorType: (e as Error).name, errorMessage: (e as Error).message, }); throw e; } ``` ### Step 3g-b: Fan-out LLM workflows (parallel child calls, single user turn) **When to use:** A single user action triggers 2+ parallel internal LLM calls (e.g. scoring + content generation + matching). Each parallel call is a child agent sharing the parent's session and trace — it must never create its own user message or trace. **Key invariants:** 1. `newTrace()` is called **once** per user-visible interaction, at the orchestrator level. 2. `trackUserMessage()` is called **once**, at the orchestrator level, before dispatching. 3. Each parallel LLM call runs inside `runAs(child, fn)`. Auto user-message tracking is **suppressed** inside delegation (provider wrappers will not emit `[Agent] User Message` for internal `role: "user"` prompts). 4. `trackAiMessage()` is called **once**, at the orchestrator level, with the assembled result. ```typescript const orchestrator = ai.agent('orchestrator', { userId: uid }); const scorer = orchestrator.child('focus-scorer'); const matcher = orchestrator.child('catalog-matcher'); await orchestrator.session({ sessionId }).run(async (s) => { s.trackUserMessage('Generate plan from quiz results', { context: structuredState }); const [a, b] = await Promise.all([ s.runAs(scorer, () => openai.chat.completions.create({ model: 'gpt-4o', messages: [...] })), s.runAs(matcher, () => openai.chat.completions.create({ model: 'gpt-4o', messages: [...] })), ]); s.trackAiMessage(assemble(a, b), 'gpt-4o', 'openai', latency, { inputTokens: inTok, outputTokens: outTok, }); }); ``` **Anti-patterns to avoid:** - Calling `newTrace()` or `trackUserMessage()` inside `runAs` — inflates trace and turn counts. - Passing raw JSON as the `content` string of `trackUserMessage()` — use `context` for structured data. - Not using `runAs` for parallel calls — provider wrappers will auto-emit a `[Agent] User Message` for every `role: "user"` message in the request, creating duplicate turns. > **Minimum SDK version:** `@amplitude/ai >= 0.11.0` for auto-suppression of user messages inside `runAs`. ### Step 3g-c: Trace ID assignment and Turn grouping **`[Agent] Trace ID` controls how the trace tab groups events into visual "Turns."** Each distinct `[Agent] Trace ID` becomes one collapsible "Turn N" card in the Agent Analytics session viewer. Events without a `[Agent] Trace ID` fall back to using `[Agent] Session ID` as the trace ID — which causes every event without an explicit trace to collapse into a single "Turn 1." When using the SDK's session `.run()`, `newTrace()` handles this automatically. But if you are constructing events manually (e.g., via the HTTP API), you must assign trace IDs yourself: 1. **One unique trace ID per user-to-response exchange.** All events within a single exchange (user message, AI response, tool calls, spans) share the same `[Agent] Trace ID`. 2. **`[Agent] Session End` needs its own trace ID** (or be placed on the last turn's trace ID with the latest timestamp). Without one, it falls back to the session ID and appears under "Turn 1." 3. **Opening AI messages (before the user's first input)** still need a trace ID. Assign them to a distinct trace (e.g., `${sessionId}-trace-0`) so they form their own turn. ```typescript // SDK handles trace IDs automatically: await agent.session({ sessionId }).run(async (s) => { s.newTrace(); // creates a unique trace ID for this turn s.trackUserMessage('hello'); // ... LLM call ... // AI Response auto-inherits the trace ID }); // HTTP API: you must set [Agent] Trace ID explicitly on every event // All events in the same turn share the same trace ID const traceId = `${sessionId}-trace-${turnNumber}`; ``` ### Step 3g-d: Span events for rich UI components When your agent renders **embedded UI components** — forms, accordions, suggestion panels, carousels, disclaimers — alongside text responses, use `[Agent] Span` events to capture the component metadata in the trace. This makes each component visible in the trace tab with its own identity and data payload. ```typescript // After the AI response that triggered the UI render: s.trackSpan({ name: 'disclaimer-accordion', // component type latencyMs: 0, // rendering latency, or 0 for static inputState: { disclaimerType: 'return_policy', sections: ['eligibility', 'terms'] }, outputState: { userExpanded: true, accepted: false }, }); s.trackSpan({ name: 'order-options', latencyMs: 0, inputState: { options: ['Track order', 'Return an item', 'Exchange'] }, outputState: { selected: 'Return an item' }, }); ``` **Key points:** - Use `name` as the component type (e.g., `disclaimer-accordion`, `order-options`, `inline-form`). - Put the component's data payload in `inputState` (what was rendered) and user interaction data in `outputState` (what the user did). - These spans inherit the session's trace context, so they appear in the correct turn. - Span events appear in the **trace tab** but not in the **conversation view**. For the conversation view, the `[Agent] AI Response` with text content is what drives the chat bubbles. **Empty AI responses (when the agent renders a UI component instead of text):** If your agent's response is purely a UI component with no text, you still need an `[Agent] AI Response` event for turn counting. Put a brief description in the content so the session viewer shows a readable bubble: ```typescript s.trackAiMessage('[Displayed: plan-comparison-table]', 'gpt-4o', 'openai', 200); // Then emit the span with the full component data: s.trackSpan({ name: 'plan-comparison-table', latencyMs: 200, inputState: { plans: [{ name: 'Basic', monthlyPrice: 10 }, ...] }, }); ``` Sending an `[Agent] AI Response` with empty `$llm_message` content produces **no bubble** in the session viewer's conversation view and the turn's text is invisible to the enrichment pipeline (evaluators can't judge an empty response). Always include at least a placeholder description. ### Step 3g-e: Sending `[Agent]` events without the SDK (HTTP API) For platform integrations where installing the SDK isn't practical (e.g., Sierra's export API or push delivery, Intercom Fin, custom chat platforms), you can send `[Agent]` events directly to the [Amplitude HTTP V2 API](https://www.docs.developers.amplitude.com/analytics/apis/http-v2-api/). **Hosted agent platforms:** if the agent runs on Sierra or Decagon, stop here and follow the platform guide instead; each is a complete, self-contained procedure with a tested forwarder: [`docs/integrations/sierra.md`](docs/integrations/sierra.md), [`docs/integrations/decagon.md`](docs/integrations/decagon.md). For other platforms, the same guides' forwarder core works with your own `normalize` function. **Required structure** — each event in the `events` array: ```json { "event_type": "[Agent] AI Response", "user_id": "user-123", "device_id": "anon-device-456", "time": 1719446400000, "event_properties": { "[Agent] Session ID": "session-abc-123", "[Agent] Trace ID": "session-abc-123-trace-1", "[Agent] Turn ID": 2, "[Agent] Agent ID": "my-agent", "[Agent] SDK Version": "http-api/1.0", "[Agent] Runtime": "custom", "$llm_message": {"text": "Here's what I found..."} } } ``` **Identity:** Set `user_id` and/or `device_id` at the top level. At least one is required for user-level analytics. **Content:** User and AI message text goes in `$llm_message.text` inside `event_properties`, not at the top level. **Trace grouping:** Set `[Agent] Trace ID` on every event (see Step 3g-c). Events without it are grouped into a single turn. **Session lifecycle:** Send `[Agent] Session End` when the unit of work finishes. It is optional and recommended. There is no session context manager on the HTTP path to close it for you. If it never arrives, the server closes the session after 30 idle minutes by default, and after 24 hours from the session start when no per-session override is set. Enrichment runs once after the first close. **Batching:** The HTTP V2 API accepts up to 2000 events per request and 20MB payload. For large sessions, batch events in groups (e.g., 10-50 per request). Event ordering is reconstructed from `[Agent] Turn ID` + `time` regardless of batch arrival order. **Event types to send** (minimum viable session): 1. `[Agent] User Message` — one per user input 2. `[Agent] AI Response` — one per AI output (never empty `$llm_message`) 3. `[Agent] Session End` — optional, one when the unit of work finishes. If you omit it, the idle timeout closes the session. 4. `[Agent] Span` (optional) — for tool calls, UI components, or sub-operations 5. `[Agent] Score` (optional) — for user feedback (thumbs up/down) ### Step 3h: Add scoring If feedback handlers were detected (thumbs up/down UI), add scoring. Check whether the handler receives a `messageId` — if so, target the specific message for finer-grained scoring. Otherwise fall back to session-level scoring: ```typescript const targetId = messageId ?? sessionId; const targetType = messageId ? 'message' : 'session'; ai.score({ userId, name: 'user-feedback', value: thumbsUp ? 1 : 0, targetId, targetType, source: 'user', sessionId, comment: feedbackText, }); ``` ### Step 3i: Streaming session lifecycle If the app uses streaming, the session must stay open until the stream is fully consumed: ```typescript // WRONG: session ends before stream is consumed return agent.session({ userId }).run(async (s) => { const stream = await openai.chat.completions.create({ model: 'gpt-4o', messages, stream: true }); return new Response(stream.toReadableStream()); }); // CORRECT: session stays open until stream completes return agent.session({ userId }).run(async (s) => { const stream = await openai.chat.completions.create({ model: 'gpt-4o', messages, stream: true }); const readable = stream.toReadableStream(); const [passthrough, forClient] = readable.tee(); const reader = passthrough.getReader(); (async () => { while (!(await reader.read()).done) {} })(); return new Response(forClient); }); ``` ### Step 3j: Browser-server session linking If `Frontend: yes` was detected, determine how the client generates and passes `sessionId`: **Case A — App uses the Amplitude browser SDK** (`@amplitude/analytics-browser` in client deps or a `