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_completed outcome from chat.trackPurchase(). Built-in sinks: httpSink, consoleSink, ga4Sink, segmentSink, mixpanelSink. Long-tail adapters (Amplitude, PostHog, Rudderstack, Datadog RUM, OpenTelemetry) live as copy-paste recipes under recipes/. Consent gating, dynamic dimensions, deterministic sampling, and an useImpressionRef React 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 /analyticsConversational Commerce Intelligence. It subscribes to the dock's real ProductAnalyticsEvent stream via useChatSession, 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 /dashboards route renders the five pre-built widgets from gecx-chat/dashboards (deflection, CSAT, AHT, agent-assist, GMV) against ~150 seeded events. For a self-hostable backend, see Hosted Dashboard and the apps/dashboard-reference demo.

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.

WidgetBuyer 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 — httpSinkapps/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,
  },
});
OptionTypeDefaultNotes
enabledbooleantrueSet false to disable collection entirely.
sinkSink | Sink[][]One or more callbacks (or built-in sinks) called for every event.
includeContentbooleanfalseOpt 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.
contextobjectStatic metadata attached to every event under event.context.
dimensions() => objectNew in v2. Resolved per event; values appear under event.dimensions. Auto-injected identity fields (identityKind, conversationId, isResumed) take precedence on key collisions.
sampleRatenumber1New in v2. Drop 1 - sampleRate of events deterministically per (sessionId, type, turnIndex). Critical events (CSAT, resolution, session lifecycle, errors) are never dropped.
respectConsentbooleantrueNew in v2. Suppresses emission when ChatGovernance consent posture is not 'analytics' or 'all'. Only active when a governance config is explicitly provided.
maxEventsnumber1000Bounded 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)

EventWhen
message_impressiontrackMessageImpression(message) from your renderer
suggestion_chip_clickedtrackSuggestionChipClick(...) from your chip handler
tool_approval_requestedAutomatic — tool needs approval
tool_approval_resolvedAutomatic — approval granted/denied
handoff_requestedAutomatic — first requested status
handoff_convertedAutomatic — first connected status
handoff_completedAutomatic
csat_submittedsubmitCSAT(...)
resolution_submittedmarkResolved(...)
resolution_without_escalationAutomatic — resolved without prior handoff

Session lifecycle (v2)

EventWhenPayload
session_startedAutomatic — start() completes{ resumed, identityKind, conversationId? }
session_resumedAutomatic — a mid-stream resume succeeds
session_endedAutomatic — end() or shutdown(){ durationMs, userTurnCount, assistantTurnCount }

session_idle and session_abandoned are exposed as manual-only methods (trackSessionIdle(), trackSessionAbandoned()). The SDK does not auto-emit them — derive them downstream from session_started without a paired session_ended within your chosen window.

Turn lifecycle (v2)

EventWhenPayload
user_message_sentAutomatic — after a user message is appended{ turnIndex, charCount, partTypes, attachmentCount }
assistant_response_startedAutomatic — first response.started transport event{ turnIndex, responseId }
assistant_response_first_tokenAutomatic — first text.delta or text.completed per response{ turnIndex, responseId, ttftMs }
assistant_response_completedAutomatic — response.completed, abort, or error{ turnIndex, responseId, durationMs, partTypeCounts, finishReason }
stream_stoppedAutomatic — user calls stop() mid-stream{ turnIndex }
regenerate_requestedAutomatic — 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)

EventWhenPayload
transport_disconnectedAutomatic — first non-abort error of a turn{ willRetry }
transport_reconnectedAutomatic — retry succeeds after a disconnect{ outageMs }

Errors (v2)

EventWhenPayload
error_shownAutomatic — driven by trackMessageImpression for messages containing ErrorPart{ errorCode, severity }
error_retry_clickedManual — call from your error UI's retry button{ errorCode }

Rich content (v2)

EventWhenPayload
citation_clickedManual{ messageId, partId, citationIndex, uri? }
product_card_clickedManual{ messageId, partId, productIndex, productSku? }
order_summary_action_clickedManual{ action? }
custom_payload_interactedManual{ payloadType, action? }
a2ui_action_dispatchedManual{ surfaceId, actionName }

Content-bearing fields (uri, productSku, action) only appear when includeContent: true.

Tools (v2)

EventWhenPayload
tool_executedAutomatic — tool.completed/failed/timed_out{ toolName, toolCallId, durationMs, success, errorCode? }

Uploads (v2)

EventWhenPayload
file_upload_startedAutomatic — attachFile() called{ kind, bytes }
file_upload_completedAutomatic — upload iterator finishes{ kind, bytes, durationMs }
file_upload_failedAutomatic — 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:

  1. context (static) — set once at construction.
  2. dimensions() (dynamic) — resolved per event. Useful for location.pathname, A/B variant, deployment ID, viewport size.
  3. Identity dimensions (automatic) — identityKind ('guest' | 'authenticated'), conversationId, isResumed. Injected by ChatSession from the IdentityManager. 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_submitted
  • resolution_submitted, resolution_without_escalation
  • session_started, session_ended
  • error_shown
analytics: { sampleRate: 0.1 }   // keep ~10% of non-critical events

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

SinkPurpose
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/:

RecipeVendor SDK global
recipes/analytics-amplitudewindow.amplitude.track
recipes/analytics-posthogwindow.posthog.capture
recipes/analytics-rudderstackwindow.rudderanalytics.track
recipes/analytics-datadog-rumwindow.DD_RUM.addAction
recipes/analytics-opentelemetrytracer.startSpan

Mixpanel was promoted from recipe to built-in sink. The recipes/analytics-mixpanel/ directory remains for reference; new integrations should use mixpanelSink() from gecx-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 questionMetricFunction
"How do I know it's working?"Deflection ratecomputeDeflectionRate
"Are customers happy?"CSAT (avg + distribution)computeCsat
"How fast do we resolve?"Average Handle TimecomputeAverageHandleTime
"Is the AI carrying the load?"Agent-assist ratecomputeAgentAssistRate
"What revenue did chat drive?"GMV / AOV / conversioncomputeGmv

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 (with escalated: boolean).
  • resolution_without_escalation — fired automatically when markResolved({ 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 otherGmvResult.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

Source: docs/guides/analytics.md