Skip to content

Analytics

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

Last updated

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.

CategoryEventPayload
Panelopenvoid
closevoid
expandboolean (true = expanded)
fullscreenboolean (true = entering fullscreen)
resize{ width, height }
Conversationmessage{ role, text } (fires for user + assistant)
send{ text, attachmentCount } (fires on user submit)
stopvoid (user cancelled in-flight stream)
clearvoid (new conversation)
selectConversation{ conversationId } (resumed from history)
suggestion{ text } (clicked a quick-reply chip)
toggleHistory{ view: "chat" | "history" }
Composerattach{ count, totalBytes }
removeAttachment{ id }
voiceStartvoid
voiceStop{ durationMs }
voiceCancelvoid
PreferenceslocaleChangestring (BCP-47 tag, e.g. "fr")
themeChange"auto" | "light" | "dark"
soundToggle{ muted }
sidebarToggle{ collapsed } (page mode)
calloutDismissvoid
Your pagehostEvent{ name, data? } (from chat.track())
Lifecycleerrorunknown (any unhandled error)
handshakeHandshakeResponse (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.
});
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#

Still need help?

Ask the community