# Analytics

> Sending widget activity to Google Analytics, Segment, PostHog and other trackers.

Wire chat-widget activity into Google Analytics, Mixpanel, Segment,
Amplitude, PostHog, Sentry, or any custom tracker. Multiple providers
can run side-by-side — every action fires once on the widget's
internal event bus, and you subscribe as many listeners as you want.

> **No SDK plugin needed.** The widget exposes a typed pub/sub API.
> Whatever your analytics provider's SDK looks like, you call it
> inside a listener.

---

## TL;DR — one-line install

```html
<!-- 1. Load your analytics SDK as you normally would -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag() {
    dataLayer.push(arguments);
  }
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXX');
</script>

<!-- 2. Load the widget -->
<script src="https://cdn.help.ai/sdk/js/elements/latest/elements.js" data-public-key="agent_pk_..." data-ai-agent-deployment-id="01900000-0000-7000-8000-000000000000" defer></script>

<!-- 3. Subscribe to every widget event with one wildcard hook. The stub queues the call until the deferred script runs. -->
<script>
  window.HelpAI =
    window.HelpAI ||
    function () {
      (HelpAI.q = HelpAI.q || []).push(arguments);
    };
  HelpAI('hook', 'default', '*', (chat, event, payload) => {
    gtag('event', `chat_${event}`, flatten(payload));
  });

  function flatten(p) {
    if (p == null) return {};
    if (typeof p !== 'object') return { value: p };
    return p;
  }
</script>
```

Reload the page, click around the widget, watch GA4's DebugView. Every
open / close / send / theme change / file attach shows up as its own
event.

---

## Event catalogue

Every interactive action emits one event with a typed payload. Subscribe
via `chat.on(name, fn)` (instance) or `HelpAI.hook(widgetId, name, fn)`
(global, pre-boot-safe). Full schema in [Events & hooks](/sdk/events-and-hooks).

| Category         | Event                 | Payload                                            |
| ---------------- | --------------------- | -------------------------------------------------- |
| **Panel**        | `open`                | `void`                                             |
|                  | `close`               | `void`                                             |
|                  | `expand`              | `boolean` (true = expanded)                        |
|                  | `fullscreen`          | `boolean` (true = entering fullscreen)             |
|                  | `resize`              | `{ width, height }`                                |
| **Conversation** | `message`             | `{ role, text }` (fires for user + assistant)      |
|                  | `send`                | `{ text, attachmentCount }` (fires on user submit) |
|                  | `stop`                | `void` (user cancelled in-flight stream)           |
|                  | `clear`               | `void` (new conversation)                          |
|                  | `selectConversation`  | `{ conversationId }` (resumed from history)        |
|                  | `suggestion`          | `{ text }` (clicked a quick-reply chip)            |
|                  | `toggleHistory`       | `{ view: "chat" \| "history" }`                    |
| **Composer**     | `attach`              | `{ count, totalBytes }`                            |
|                  | `removeAttachment`    | `{ id }`                                           |
|                  | `voiceStart`          | `void`                                             |
|                  | `voiceStop`           | `{ durationMs }`                                   |
|                  | `voiceCancel`         | `void`                                             |
| **Preferences**  | `localeChange`        | `string` (BCP-47 tag, e.g. `"fr"`)                 |
|                  | `themeChange`         | `"auto" \| "light" \| "dark"`                      |
|                  | `soundToggle`         | `{ muted }`                                        |
|                  | `sidebarToggle`       | `{ collapsed }` (page mode)                        |
|                  | `calloutDismiss`      | `void`                                             |
| **Your page**    | `hostEvent`           | `{ name, data? }` (from `chat.track()`)            |
| **Lifecycle**    | `error`               | `unknown` (any unhandled error)                    |
|                  | `handshake`           | `HandshakeResponse` (full handshake payload)       |
|                  | `conversationRebound` | `{ previous, current }`                            |

---

## The two subscription APIs

### `chat.on(event, fn)` — instance-scoped

Returns an `off` function. Use when you have a handle to the mounted
instance (NPM / ESM / `mount()` returns it; `HelpAI.getInstance()`
fetches the default).

```ts
const off = chat.on('send', ({ text, attachmentCount }) => {
  console.log('user sent', text, 'with', attachmentCount, 'files');
});

// Later — unsubscribe
off();
```

### `HelpAI.hook(widgetId, event, fn)` — global, pre-boot-safe

Targets every widget that matches `(widgetId, event)`. Use when:

- You want to register **before** the bundle finishes loading (queue replays once boot completes).
- You have **multiple widgets** on one page and want a single listener for all of them (`widgetId: "*"`).
- You want to track every event with one listener (`event: "*"`).

```html
<!-- This runs BEFORE elements.js parses. The queued call replays after boot. -->
<script>
  window.HelpAI =
    window.HelpAI ||
    function () {
      (HelpAI.q = HelpAI.q || []).push(arguments);
    };
  HelpAI('hook', '*', '*', (chat, event, payload) => {
    track(`chat_${event}`, { widgetId: chat.widgetId, ...payload });
  });
</script>
<script src="https://cdn.help.ai/sdk/js/elements/latest/elements.js" data-public-key="..." data-ai-agent-deployment-id="01900000-0000-7000-8000-000000000000" defer></script>
```

The listener receives `(chat, event, payload)` — `chat` is the
`ChatWidgetInstance` (so you can branch on `chat.widgetId`), `event`
is the string event name, `payload` is the typed payload (or
`undefined` for `void` events).

---

## Multiple analytics providers — three patterns

### Pattern A — one listener per provider (simplest)

Each provider registers its own wildcard listener. Order doesn't
matter; one provider erroring out doesn't break the others (the bus
isolates listener exceptions).

```html
<script>
  HelpAI(() => {
    const chat = HelpAI.getInstance();

    // GA4
    chat.on('send', ({ text, attachmentCount }) => gtag('event', 'chat_send', { text_length: text.length, attachments: attachmentCount }));
    chat.on('themeChange', (mode) => gtag('event', 'chat_theme', { mode }));

    // Mixpanel
    chat.on('send', ({ text }) => mixpanel.track('Chat Send', { textLength: text.length }));
    chat.on('themeChange', (mode) => mixpanel.track('Chat Theme', { mode }));

    // Sentry for errors only
    chat.on('error', (err) => Sentry.captureException(err, { tags: { source: 'chat-widget' } }));
  });
</script>
```

### Pattern B — one wildcard listener, fan out internally

When you want every event in every provider, declare a small
`providers` array and let one listener push to all of them. New
provider? Add a function to the array.

```js
HelpAI(() => {
  const chat = HelpAI.getInstance();

  // Each provider is `(event, payload) => void`.
  const providers = [
    // GA4
    (event, payload) => gtag('event', `chat_${event}`, flatten(payload)),

    // Mixpanel
    (event, payload) => mixpanel.track(`Chat ${titleCase(event)}`, flatten(payload)),

    // Segment / RudderStack
    (event, payload) => analytics.track(`Chat ${titleCase(event)}`, flatten(payload)),

    // Amplitude
    (event, payload) => amplitude.track(`chat_${event}`, flatten(payload)),

    // PostHog
    (event, payload) => posthog.capture(`chat_${event}`, flatten(payload)),

    // Server-side beacon (your own endpoint)
    (event, payload) => navigator.sendBeacon('/api/telemetry', JSON.stringify({ event, payload })),
  ];

  chat.on('open', () => fanOut('open'));
  chat.on('close', () => fanOut('close'));
  chat.on('send', (p) => fanOut('send', p));
  chat.on('message', (p) => fanOut('message', p));
  chat.on('clear', () => fanOut('clear'));
  chat.on('selectConversation', (p) => fanOut('select_conversation', p));
  chat.on('suggestion', (p) => fanOut('suggestion', p));
  chat.on('themeChange', (m) => fanOut('theme_change', { mode: m }));
  chat.on('localeChange', (l) => fanOut('locale_change', { locale: l }));
  chat.on('attach', (p) => fanOut('attach', p));
  chat.on('removeAttachment', (p) => fanOut('remove_attachment', p));
  chat.on('voiceStart', () => fanOut('voice_start'));
  chat.on('voiceStop', (p) => fanOut('voice_stop', p));
  chat.on('expand', (v) => fanOut('expand', { expanded: v }));
  chat.on('fullscreen', (v) => fanOut('fullscreen', { fullscreen: v }));
  chat.on('resize', (p) => fanOut('resize', p));
  chat.on('toggleHistory', (p) => fanOut('toggle_history', p));
  chat.on('soundToggle', (p) => fanOut('sound_toggle', p));
  chat.on('sidebarToggle', (p) => fanOut('sidebar_toggle', p));
  chat.on('calloutDismiss', () => fanOut('callout_dismiss'));
  chat.on('error', (e) => fanOut('error', { description: String(e) }));
  chat.on('handshake', (r) => fanOut('handshake', { aiAgentId: r.agent?.id }));

  function fanOut(event, payload) {
    for (const send of providers) {
      try {
        send(event, payload);
      } catch (e) {
        console.error('[analytics]', e);
      }
    }
  }
});

function flatten(p) {
  return p == null ? {} : typeof p === 'object' ? p : { value: p };
}
function titleCase(s) {
  return s.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
}
```

### Pattern C — single wildcard hook, multi-provider fan-out (most concise)

The `'*' / '*'` wildcard hook receives every event from every widget
on the page. Combined with a provider array, this is the same idea as
Pattern B but in 5 lines.

```html
<script>
  // Provider table — add / remove rows as you onboard more trackers.
  const TRACKERS = [(event, payload) => gtag('event', `chat_${event}`, payload), (event, payload) => mixpanel.track(`chat_${event}`, payload), (event, payload) => analytics.track(`chat_${event}`, payload)];

  HelpAI('hook', '*', '*', (chat, event, payload) => {
    const flat = payload == null ? {} : typeof payload === 'object' ? payload : { value: payload };
    for (const send of TRACKERS) {
      try {
        send(event, { widgetId: chat.widgetId, ...flat });
      } catch (e) {
        console.error('[analytics]', e);
      }
    }
  });
</script>
```

---

## Multiple GA4 properties

Same SDK, multiple `config` calls — gtag sends every event to every
configured measurement id by default. No widget-specific change needed:

```html
<script async src="https://www.googletagmanager.com/gtag/js?id=G-PRIMARY"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag() {
    dataLayer.push(arguments);
  }
  gtag('js', new Date());
  gtag('config', 'G-PRIMARY');
  gtag('config', 'G-SECONDARY');
  gtag('config', 'G-DEBUG-PROPERTY');
</script>
```

Need to send some events to only one of them? Use `gtag`'s `send_to`:

```js
chat.on('send', ({ text, attachmentCount }) =>
  gtag('event', 'chat_send', {
    text_length: text.length,
    attachments: attachmentCount,
    send_to: ['G-PRIMARY', 'G-DEBUG-PROPERTY'], // skip secondary
  }),
);
```

---

## Per-provider recipes

### Google Tag Manager (dataLayer)

GTM consumes the `dataLayer` push — every push becomes a GTM event
you can use as a trigger.

```js
chat.on('send', ({ text, attachmentCount }) => {
  window.dataLayer.push({
    event: 'chat_send',
    chat: { textLength: text.length, attachments: attachmentCount, widgetId: chat.widgetId },
  });
});
```

In GTM, create a custom-event trigger on `chat_send` and a variable
chain reading `chat.textLength` etc.

### Segment

```js
chat.on('send', ({ text, attachmentCount }) => analytics.track('Chat Send', { textLength: text.length, attachmentCount }));
chat.on('clear', () => analytics.track('Chat Cleared'));
chat.on('selectConversation', ({ conversationId }) => analytics.track('Chat Resumed', { conversationId }));
chat.on('themeChange', (mode) => analytics.track('Chat Theme Changed', { mode }));
chat.on('error', (err) => analytics.track('Chat Error', { description: String(err) }));
```

### Amplitude

```js
chat.on('send', ({ text, attachmentCount }) => amplitude.track('chat_send', { text_length: text.length, attachments: attachmentCount }));
chat.on('themeChange', (mode) => amplitude.track('chat_theme', { mode }));
chat.on('voiceStop', ({ durationMs }) => amplitude.track('chat_voice', { duration_ms: durationMs }));
```

### PostHog

```js
chat.on('send', ({ text, attachmentCount }) => posthog.capture('chat_send', { text_length: text.length, attachments: attachmentCount }));
chat.on('handshake', (res) => posthog.capture('chat_connected', { agent: res.agent?.id }));
chat.on('error', (err) => posthog.capture('chat_error', { description: String(err) }));
```

### Sentry (error tracking)

The `error` event fires for **every** caught error — the stream, async
handlers, AND render crashes (the widget wraps its UI in an error boundary, so a
render throw degrades to a quiet fallback instead of white-screening the host
page; the error is reported here, never swallowed). It's also delivered to the
per-instance `options.onError` callback.

```js
chat.on('error', (err) => {
  Sentry.captureException(err, {
    tags: { source: 'chat-widget', widgetId: chat.widgetId },
  });
});
```

### Forward logs to your observability stack (`onLog`)

Beyond semantic events, the widget's scoped logger can stream **every log line**
to a sink — pipe warnings/errors/debug into Sentry breadcrumbs, Datadog, etc.
One global sink per page; it respects the active level (warnings + errors by
default), so raise it with `setDebug("trace")` to capture everything.

```js
// Pre-boot safe — wire it in the loader snippet before the bundle loads.
HelpAI('onLog', ({ level, scope, message, payload }) => {
  if (level === 'error' || level === 'warn') {
    Sentry.addBreadcrumb({ category: `widget:${scope}`, level, message, data: payload });
  }
});
HelpAI('setDebug', 'debug'); // capture more than just warnings/errors

// Or on a handle: chat.onLog(fn); pass null to detach.
```

### Custom backend beacon

`navigator.sendBeacon` survives page unload — useful for logging
the last action before a visitor navigates away.

```js
chat.on('close', () => {
  navigator.sendBeacon(
    '/api/telemetry',
    JSON.stringify({
      event: 'chat_close',
      widgetId: chat.widgetId,
      ts: Date.now(),
    }),
  );
});
```

---

## Common patterns

### Your own events (`chat.track`)

Report what the visitor does on your site, outside the chat, with `chat.track(name, data)`:

```ts
chat.track('plan_viewed', { plan: 'pro' });
chat.track('cart_updated', { items: 2, total: 48.5 });
```

Each call does two things:

- **Analytics.** It sends one hit to help.ai's Elements analytics as a `hostEvent`, with your event name and data,
  like the widget's own events. A deployment with tracking off (or a browser sending Global Privacy Control or Do
  Not Track) sends nothing.
- **The agent.** The widget keeps your last 10 events and sends them with the visitor's next message, as
  `recentEvents` in the page context (`{ name, data?, secondsAgo }`, oldest first), so an answer can take into
  account what the visitor just did: "I see you were looking at the Pro plan." Like the rest of the page context, the
  agent treats them as information from your site, never as instructions. They live only in this page view.

The name is 1–64 characters. The data is flat: strings, numbers and booleans (other values are left out), up to 256
characters as JSON (longer data is not sent; the name still is). Nothing personal should go in either: use ids and
plan names, not emails. `track()` also fires the `hostEvent` event, so your own listeners see it.

Also `HelpAI('track', name, data)` with the script tag, `element.track(name, data)` on `<web-ai-chat>`, and
`useHelpAI().track` in React.

### Identify the visitor

The widget mints a stable anonymous `visitorId` on first load and
includes it in the `handshake` event's payload. Surface it to your
analytics for cross-conversation attribution.

```js
chat.on('handshake', (res) => {
  const visitorId = res.visitorId;
  // Segment
  analytics.identify(visitorId);
  // Mixpanel
  mixpanel.identify(visitorId);
  // PostHog
  posthog.identify(visitorId);
  // GA4 user_id
  gtag('config', 'G-XXXXXXX', { user_id: visitorId });
});
```

### Sample noisy events

`message` fires twice per turn (user + assistant) and adds up fast.
Drop the assistant half if you only care about user activity:

```js
chat.on('message', ({ role, text }) => {
  if (role !== 'user') return;
  track('chat_message', { length: text.length });
});
```

### Sanitize PII before sending

User-typed text may contain emails / IDs / etc. Hash or truncate
before forwarding to third-party trackers:

```js
chat.on('send', async ({ text }) => {
  const hash = await sha256(text);
  track('chat_send', { text_hash: hash, text_length: text.length });
  // text itself never leaves the page.
});
```

### Conditionally enable (consent-aware)

```js
HelpAI(() => {
  if (!userConsentedToAnalytics()) return;
  const chat = HelpAI.getInstance();
  chat.on('send', (payload) => gtag('event', 'chat_send', payload));
  // … rest of subscriptions …
});
```

### Disable on `localhost` / preview deploys

```js
const isProd = location.hostname === 'help.ai';
if (isProd) {
  chat.on('send', (payload) => gtag('event', 'chat_send', payload));
}
```

### Multi-widget pages — branch on widgetId

```js
HelpAI('hook', '*', 'send', (chat, _evt, payload) => {
  if (chat.widgetId === 'support') {
    gtag('event', 'support_chat_send', payload);
  } else if (chat.widgetId === 'sales') {
    gtag('event', 'sales_chat_send', payload);
  }
});
```

---

## TypeScript

The event names + payloads are typed — your IDE autocompletes the
shape of every callback argument:

```ts
import type { ChatWidgetInstance, EventMap } from '@helpai/elements';

function wireAnalytics(chat: ChatWidgetInstance) {
  chat.on('send', (payload) => {
    //              ^? { text: string; attachmentCount: number }
    gtag('event', 'chat_send', { text_length: payload.text.length });
  });

  chat.on('themeChange', (mode) => {
    //                    ^? "auto" | "light" | "dark"
    gtag('event', 'chat_theme', { mode });
  });
}

// Iterate every event for the fan-out pattern.
const events: Array<keyof EventMap> = ['open', 'close', 'send' /* … */];
```

---

## Troubleshooting

### "My listener never fires"

- Are you registering **before mount completes**? Use the global
  `HelpAI(() => { ... })` queue form, or `HelpAI("hook", …)` (both
  are pre-boot-safe).
- Wrong `widgetId`? Auto-boot uses `"default"`; pass `widgetId` to
  `init()` / `mount()` for named or multiple programmatic instances.
  Use `"*"` to match every widget.
- Spelling — event names are case-sensitive (`themeChange`, not `theme_change`).

### "Listener fires twice"

Every `chat.on(name, fn)` subscribes one listener. If you wired it
inside a re-running effect (React `useEffect` without cleanup),
you're registering N copies. Always call the returned `off` in your
cleanup:

```jsx
useEffect(() => {
  const off = chat.on("send", payload => analytics.track(...));
  return off;   // <-- critical
}, [chat]);
```

### "I want to see what's firing"

Enable trace logging — every action fires a structured log line at
`info` level:

```js
chat.setDebug('info'); // or "trace" for SSE-frame-level detail
```

Or from the page URL: `?helpai-debug=info`. Persists in `localStorage`.

### "One provider throws and breaks the others"

The event bus already isolates listener exceptions — one throw in
listener A doesn't prevent listener B from firing. But within a
single fan-out loop (Pattern B/C), wrap each call in try/catch
yourself — see the snippets above.

---

## See also

- [Events & hooks](/sdk/events-and-hooks) — full event reference, payload shapes, pre-boot queue semantics
- [API reference](/sdk/api-reference) — `ChatWidgetInstance` shape
- [Recipes](/sdk/recipes) — other end-to-end snippets
- [Security & privacy](/sdk/security-privacy) — what data flows where
