Analytics
What's new. The SDK auto-emits 40+ event types covering session lifecycle, turn latency (TTFT, response duration), transport health, errors, tool execution timing, file uploads, rich-content engagement, memory operations, sentiment/intent signals, agent-graph routing decisions, A2UI catalog install/render/action, and the new
purchase_completedoutcome fromchat.trackPurchase(). Built-in sinks:httpSink,consoleSink,ga4Sink,segmentSink,mixpanelSink. Long-tail adapters (Amplitude, PostHog, Rudderstack, Datadog RUM, OpenTelemetry) live as copy-paste recipes underrecipes/. Consent gating, dynamic dimensions, deterministic sampling, and anuseImpressionRefReact hook round out the surface. The v1 surface keeps working unchanged — every section below is additive.
See it live, two ways. (1) The Applied AI Retail demo ships a polished, fully wired analytics page at
/analytics— Conversational Commerce Intelligence. It subscribes to the dock's realProductAnalyticsEventstream viauseChatSession, seeds a deterministic backlog so charts are populated for screenshots, and renders the funnel, latency percentiles, tool approval rates, rich-content engagement, errors, transport uptime, and a live event feed. (2) The showcase/dashboardsroute renders the five pre-built widgets fromgecx-chat/dashboards(deflection, CSAT, AHT, agent-assist, GMV) against ~150 seeded events. For a self-hostable backend, see Hosted Dashboard and theapps/dashboard-referencedemo.
Outcomes and pre-built dashboards
Analytics v2 emits the events; the outcomes layer plus pre-built widgets answer the questions buyers actually ask.
A vendor-neutral outcome layer in packages/gecx-chat/src/analytics/outcomes.ts exposes OutcomeEvent, OutcomeKind, isOutcomeEvent(), and outcomeKind(). Existing event names (resolution_submitted, csat_submitted, handoff_*, purchase_completed) carry the outcome semantics — there are no aliases like conversation_resolved to confuse mental models.
Pure-function aggregation primitives live in packages/gecx-chat/src/analytics/aggregation/: computeDeflectionRate, computeCsat, computeAverageHandleTime, computeAgentAssistRate, computeGmv, plus windows.ts / sessions.ts. All are window-aware, return null (not NaN) on zero-divide, and run anywhere that has a ProductAnalyticsEvent[] — your warehouse, a serverless function, a server component.
Five headless React widgets and a composite <MetricsDashboard> ship under gecx-chat/dashboards. Recharts is an optional peer dependency: consumers who never import this subpath see no install footprint, and two of the widgets (AgentAssistRate, CsatDistribution) render pure SVG without pulling recharts at all.
| Widget | Buyer question |
|---|---|
DeflectionTrend | "Are we keeping support volume down without losing customers?" |
CsatDistribution | "Are users happy?" |
AhtHistogram | "How fast?" |
AgentAssistRate | "How much human time are we saving?" |
GmvBreakdown | "How much revenue is this driving?" |
For an end-to-end deploy path — httpSink → apps/dashboard-reference → widget rendering — see Hosted Dashboard.
What the analytics system tracks
The SDK provides vendor-neutral product analytics for understanding end-user outcomes. It tracks message impressions, suggestion chip engagement, tool approval rates, handoff conversion, CSAT scores, resolution without escalation, and (new in v2) full turn latency, transport disconnects, error-shown impressions, tool execution timing, file uploads, and rich-content engagement.
Analytics is separate from developer diagnostics. Debug bundles and trace events help engineers debug integrations. Product analytics helps product and CX teams measure how well the chat experience works.
Configuration
Pass an analytics object when creating the client:
import { createChatClient, httpSink, ga4Sink, segmentSink, tokenEndpointAuth } from 'gecx-chat';
const client = createChatClient({
auth: tokenEndpointAuth({ endpoint: '/api/chat-token' }),
analytics: {
sink: [
httpSink({ url: '/api/events' }),
ga4Sink({ measurementId: 'G-XXXX' }),
segmentSink(),
],
context: { surface: 'support-chat', locale: 'en-US' },
dimensions: () => ({ page: location.pathname }),
sampleRate: 1,
includeContent: false,
respectConsent: true,
},
});
| Option | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | true | Set false to disable collection entirely. |
sink | Sink | Sink[] | [] | One or more callbacks (or built-in sinks) called for every event. |
includeContent | boolean | false | Opt into chip labels/values, CSAT comments, citation URIs, product SKUs, custom-payload actions, and order-summary actions. Set true only when your team owns the data pipeline. |
context | object | — | Static metadata attached to every event under event.context. |
dimensions | () => object | — | New in v2. Resolved per event; values appear under event.dimensions. Auto-injected identity fields (identityKind, conversationId, isResumed) take precedence on key collisions. |
sampleRate | number | 1 | New in v2. Drop 1 - sampleRate of events deterministically per (sessionId, type, turnIndex). Critical events (CSAT, resolution, session lifecycle, errors) are never dropped. |
respectConsent | boolean | true | New in v2. Suppresses emission when ChatGovernance consent posture is not 'analytics' or 'all'. Only active when a governance config is explicitly provided. |
maxEvents | number | 1000 | Bounded local buffer; oldest evicted first. |
Event types
The SDK auto-emits these events. v1 events are tracked manually or automatically as documented; v2 events are auto-emitted from ChatSession lifecycle unless noted.
v1 (unchanged)
| Event | When |
|---|---|
message_impression | trackMessageImpression(message) from your renderer |
suggestion_chip_clicked | trackSuggestionChipClick(...) from your chip handler |
tool_approval_requested | Automatic — tool needs approval |
tool_approval_resolved | Automatic — approval granted/denied |
handoff_requested | Automatic — first requested status |
handoff_converted | Automatic — first connected status |
handoff_completed | Automatic |
csat_submitted | submitCSAT(...) |
resolution_submitted | markResolved(...) |
resolution_without_escalation | Automatic — resolved without prior handoff |
Session lifecycle (v2)
| Event | When | Payload |
|---|---|---|
session_started | Automatic — start() completes | { resumed, identityKind, conversationId? } |
session_resumed | Automatic — a mid-stream resume succeeds | — |
session_ended | Automatic — end() or shutdown() | { durationMs, userTurnCount, assistantTurnCount } |
session_idleandsession_abandonedare exposed as manual-only methods (trackSessionIdle(),trackSessionAbandoned()). The SDK does not auto-emit them — derive them downstream fromsession_startedwithout a pairedsession_endedwithin your chosen window.
Turn lifecycle (v2)
| Event | When | Payload |
|---|---|---|
user_message_sent | Automatic — after a user message is appended | { turnIndex, charCount, partTypes, attachmentCount } |
assistant_response_started | Automatic — first response.started transport event | { turnIndex, responseId } |
assistant_response_first_token | Automatic — first text.delta or text.completed per response | { turnIndex, responseId, ttftMs } |
assistant_response_completed | Automatic — response.completed, abort, or error | { turnIndex, responseId, durationMs, partTypeCounts, finishReason } |
stream_stopped | Automatic — user calls stop() mid-stream | { turnIndex } |
regenerate_requested | Automatic — at entry of regenerate() | { turnIndex } |
finishReason is 'natural' for a clean response.completed, 'stopped' for user-initiated stops, 'aborted' for other aborts (shutdown, network), and 'errored' for thrown errors. userTurnCount/assistantTurnCount on session_ended count only natural completions.
Transport health (v2)
| Event | When | Payload |
|---|---|---|
transport_disconnected | Automatic — first non-abort error of a turn | { willRetry } |
transport_reconnected | Automatic — retry succeeds after a disconnect | { outageMs } |
Errors (v2)
| Event | When | Payload |
|---|---|---|
error_shown | Automatic — driven by trackMessageImpression for messages containing ErrorPart | { errorCode, severity } |
error_retry_clicked | Manual — call from your error UI's retry button | { errorCode } |
Rich content (v2)
| Event | When | Payload |
|---|---|---|
citation_clicked | Manual | { messageId, partId, citationIndex, uri? } |
product_card_clicked | Manual | { messageId, partId, productIndex, productSku? } |
order_summary_action_clicked | Manual | { action? } |
custom_payload_interacted | Manual | { payloadType, action? } |
a2ui_action_dispatched | Manual | { surfaceId, actionName } |
Content-bearing fields (uri, productSku, action) only appear when includeContent: true.
Tools (v2)
| Event | When | Payload |
|---|---|---|
tool_executed | Automatic — tool.completed/failed/timed_out | { toolName, toolCallId, durationMs, success, errorCode? } |
Uploads (v2)
| Event | When | Payload |
|---|---|---|
file_upload_started | Automatic — attachFile() called | { kind, bytes } |
file_upload_completed | Automatic — upload iterator finishes | { kind, bytes, durationMs } |
file_upload_failed | Automatic — upload yields 'failed' or throws | { kind, errorCode } |
The full schema lives at schemas/product-analytics.schema.json. A schema-drift test in CI catches additions to the TS union that aren't mirrored in the schema.
Dimensions
Three dimension sources flow onto every event:
context(static) — set once at construction.dimensions()(dynamic) — resolved per event. Useful forlocation.pathname, A/B variant, deployment ID, viewport size.- Identity dimensions (automatic) —
identityKind('guest' | 'authenticated'),conversationId,isResumed. Injected byChatSessionfrom theIdentityManager. These take precedence on key collisions.
Sensitive keys (token, apiKey, secret, password, authorization, credential, cookie) are redacted in any of these layers before events reach your sinks.
analytics: {
context: { surface: 'support', locale: 'en-US' },
dimensions: () => ({
page: location.pathname,
experiment: window.__experiment ?? 'control',
}),
},
Sampling
Set sampleRate to a value between 0 and 1 to drop a fraction of events deterministically. The decision is keyed on (sessionId, eventType, turnIndex) so the same logical event is always kept or always dropped — useful when you replay events through different sinks.
Critical events are never sampled out regardless of sampleRate:
csat_submittedresolution_submitted,resolution_without_escalationsession_started,session_endederror_shown
analytics: { sampleRate: 0.1 } // keep ~10% of non-critical events
Consent
When you configure a ChatGovernance policy, the analytics collector subscribes to consent posture changes. Emission is permitted when posture is 'analytics' or 'all', suppressed when 'none' or 'functional'.
Set respectConsent: false to bypass the gate (for enterprise or employee-facing deployments where consent is handled elsewhere). The gate is only active when governance is explicitly configured — v1 users with no governance config keep emitting unchanged.
Sinks
A sink is just a function:
type ProductAnalyticsSink = (event: ProductAnalyticsEvent) => void | Promise<void>;
You can pass one or an array. The SDK calls each sink for every event and swallows their errors so a broken sink can't break chat.
Built-in sinks
| Sink | Purpose |
|---|---|
httpSink({ url, headers?, batch? }) | Batched JSON POSTs (default 20 events / 5s); drains via navigator.sendBeacon on pagehide, falls back to fetch({ keepalive: true }). The load-bearing adapter for warehouses and your own collectors. |
consoleSink({ level? }) | Logs each event to the console at the given level. Dev only. |
ga4Sink({ measurementId }) | Calls window.gtag('event', type, properties). Queues until gtag is loaded. |
segmentSink() | Calls window.analytics.track(type, properties). Queues until Segment's analytics.js is loaded. |
mixpanelSink({ token? }) | Calls window.mixpanel.track(type, properties). Queues until Mixpanel's snippet is loaded. |
import {
createChatClient,
httpSink,
consoleSink,
ga4Sink,
segmentSink,
mixpanelSink,
} from 'gecx-chat';
createChatClient({
// ...
analytics: {
sink: [
httpSink({ url: '/api/events' }),
consoleSink({ level: 'debug' }),
ga4Sink({ measurementId: 'G-XXXX' }),
segmentSink(),
mixpanelSink({ token: 'YOUR_PROJECT_TOKEN' }),
],
},
});
All five sinks are tree-shakeable — importing one does not pull the others.
Other vendors (recipes)
For vendors that don't justify shipping in core, copy a single-file adapter from recipes/:
| Recipe | Vendor SDK global |
|---|---|
recipes/analytics-amplitude | window.amplitude.track |
recipes/analytics-posthog | window.posthog.capture |
recipes/analytics-rudderstack | window.rudderanalytics.track |
recipes/analytics-datadog-rum | window.DD_RUM.addAction |
recipes/analytics-opentelemetry | tracer.startSpan |
Mixpanel was promoted from recipe to built-in sink. The
recipes/analytics-mixpanel/directory remains for reference; new integrations should usemixpanelSink()fromgecx-chat.
Copy the recipe's component.tsx into your codebase and wire it the same way as the built-in sinks. Recipes follow the same queue-until-loaded pattern and the same error-swallowing contract.
Tracking events
Some events are tracked automatically by the SDK (session lifecycle, turn lifecycle, transport health, tools, uploads, error impressions when a message with an ErrorPart is rendered). Others require explicit calls from your UI because the SDK cannot know when a message enters the viewport or which chip the user clicked.
Message impressions
Call trackMessageImpression when a message becomes visible. Duplicate impressions for the same messageId are ignored.
chat.trackMessageImpression(message);
For React, use the new useImpressionRef hook — it wraps an IntersectionObserver with a 50% threshold and SSR-safe fallback:
import { useImpressionRef } from 'gecx-chat/react';
function MessageView({ message }) {
const ref = useImpressionRef(message);
return <div ref={ref}>{/* ... */}</div>;
}
Suggestion chip clicks
chat.trackSuggestionChipClick({
messageId: message.id,
partId: part.id,
chipIndex: 2,
chip: { label: 'Track my order', value: 'track_order' },
turnIndex: message.turnIndex,
responseId: message.responseId,
});
CSAT and resolution
session.submitCSAT({ score: 4, maxScore: 5, comment: 'Very helpful!' });
session.markResolved({ resolved: true });
submitCSAT validates the score range. markResolved automatically emits resolution_without_escalation if no handoff was requested earlier in the session.
Summary metrics
Every session exposes a computed summary:
const summary = session.getProductAnalyticsSummary();
summary.messageImpressions; // number
summary.suggestionChipClickThroughRate; // number | null
summary.toolApprovalRate; // number | null
summary.handoffConversionRate; // number | null
summary.averageCsatScore; // number | null
summary.resolutionWithoutEscalationRate; // number | null
summary.purchaseCount; // number
summary.grossMerchandiseValue; // Record<currency, total>
summary.averageOrderValue; // Record<currency, number | null>
Rates are null when the denominator is zero. v2 lifecycle events do not contribute to summary metrics; ask the warehouse for latency aggregates, error rates, and funnels.
Outcomes and pre-built dashboards
The SDK ships vendor-neutral aggregation primitives for the five metrics buyers ask about most:
| Buyer question | Metric | Function |
|---|---|---|
| "How do I know it's working?" | Deflection rate | computeDeflectionRate |
| "Are customers happy?" | CSAT (avg + distribution) | computeCsat |
| "How fast do we resolve?" | Average Handle Time | computeAverageHandleTime |
| "Is the AI carrying the load?" | Agent-assist rate | computeAgentAssistRate |
| "What revenue did chat drive?" | GMV / AOV / conversion | computeGmv |
Each function takes events: ProductAnalyticsEvent[] and returns a strongly-typed result. They are pure, window-aware, and tree-shakeable.
import {
computeDeflectionRate,
computeCsat,
computeAverageHandleTime,
computeAgentAssistRate,
computeGmv,
} from 'gecx-chat';
const events = session.getProductAnalyticsEvents();
const deflection = computeDeflectionRate(events, { filter: { range: '7d' } });
const csat = computeCsat(events, { filter: { range: '30d' } });
const aht = computeAverageHandleTime(events, { filter: { range: '7d' } });
const assist = computeAgentAssistRate(events, { filter: { range: '30d' } });
const gmv = computeGmv(events, { filter: { range: '30d' }, groupByCategory: true });
Canonical outcome events
The SDK uses these existing event names as the vendor-neutral outcome layer. We deliberately did not add new aliases like conversation_resolved or csat_collected:
resolution_submitted— task completion (withescalated: boolean).resolution_without_escalation— fired automatically whenmarkResolved({ escalated: false }).csat_submitted— customer satisfaction.handoff_requested/handoff_converted/handoff_completed— escalation funnel.purchase_completed— new commerce outcome event (see below).
Use isOutcomeEvent(event) and outcomeKind(event) from gecx-chat to filter or label.
Purchase tracking
To populate GMV dashboards, call chat.trackPurchase() from your checkout success handler:
chat.trackPurchase({
orderId: order.id,
revenue: order.total,
currency: 'USD',
items: order.lines.map((l) => ({
productId: l.productId,
name: l.name,
quantity: l.quantity,
unitPrice: l.unitPrice,
category: l.category,
})),
paymentMethod: 'card',
});
Revenue must be non-negative. Currency must be an ISO 4217 3-letter code (normalized to upper-case). Currencies are never summed across each other — GmvResult.byCurrency is a per-currency map.
Pre-built dashboard widgets
gecx-chat/dashboards ships five headless React widgets and a composite dashboard that render the metrics above using recharts as an optional peer dependency.
import { MetricsDashboard } from 'gecx-chat/dashboards';
export function ChatOpsPage({ events }) {
return <MetricsDashboard events={events} window="7d" />;
}
Individual widgets:
<DeflectionTrend>— area chart of deflection rate over time.<CsatDistribution>— bar histogram of CSAT scores (pure SVG, no recharts).<AhtHistogram>— distribution of session durations, segmentable by channel (bot/human/all).<AgentAssistRate>— big-number card with sparkline (pure SVG, no recharts).<GmvBreakdown>— bar or pie chart, group by category / currency / day.
Install recharts to use the chart-based widgets:
npm install recharts
Bundle impact: the gecx-chat/dashboards entry is ~13 KB gzipped of widget code, plus ~30 KB gzipped of recharts when any chart widget is imported. Widgets that render pure SVG (AgentAssistRate, CsatDistribution) do not pull recharts — a consumer importing only those keeps the chart dep tree-shaken out.
See hosted-dashboard.md for the self-hostable reference server.
Privacy defaults
By default, analytics events strip content to protect end-user privacy:
- Message text is not included.
- Suggestion chip labels and values are omitted.
- Tool inputs and outputs are excluded.
- CSAT comments are excluded.
- Handoff target agent names are excluded.
- Citation URIs, product SKUs, custom-payload action labels, and order-summary action labels are all gated by
includeContent. - Metadata keys that look sensitive (
token,apiKey,secret,password,authorization,credential,cookie) are redacted before events are stored or sent to sinks.
Set includeContent: true only when your team owns the data pipeline and needs content fields for analysis.
What's next
- Data Governance — consent postures that control whether analytics events are collected.
- React Integration — using
useChatSessionanduseImpressionRef. - Testing — asserting analytics events in tests.
apps/showcase/src/app/analytics/page.tsx— live demo with event stream and adapter comparison.apps/agi-coolaid-stand— reference deployment wiringhttpSink+segmentSink.
docs/guides/analytics.md