@helpai/elements 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,551 @@
1
+ # Analytics integration
2
+
3
+ Wire chat-widget activity into Google Analytics, Mixpanel, Segment,
4
+ Amplitude, PostHog, Sentry, or any custom tracker. Multiple providers
5
+ can run side-by-side — every action fires once on the widget's
6
+ internal event bus, and you subscribe as many listeners as you want.
7
+
8
+ > **No SDK plugin needed.** The widget exposes a typed pub/sub API.
9
+ > Whatever your analytics provider's SDK looks like, you call it
10
+ > inside a listener.
11
+
12
+ ---
13
+
14
+ ## TL;DR — one-line install
15
+
16
+ ```html
17
+ <!-- 1. Load your analytics SDK as you normally would -->
18
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
19
+ <script>
20
+ window.dataLayer = window.dataLayer || [];
21
+ function gtag() {
22
+ dataLayer.push(arguments);
23
+ }
24
+ gtag('js', new Date());
25
+ gtag('config', 'G-XXXXXXX');
26
+ </script>
27
+
28
+ <!-- 2. Load the widget -->
29
+ <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>
30
+
31
+ <!-- 3. Subscribe to every widget event with one wildcard hook -->
32
+ <script>
33
+ HelpAI('hook', 'default', '*', (chat, event, payload) => {
34
+ gtag('event', `chat_${event}`, flatten(payload));
35
+ });
36
+
37
+ function flatten(p) {
38
+ if (p == null) return {};
39
+ if (typeof p !== 'object') return { value: p };
40
+ return p;
41
+ }
42
+ </script>
43
+ ```
44
+
45
+ Reload the page, click around the widget, watch GA4's DebugView. Every
46
+ open / close / send / theme change / file attach shows up as its own
47
+ event.
48
+
49
+ ---
50
+
51
+ ## Event catalogue
52
+
53
+ Every interactive action emits one event with a typed payload. Subscribe
54
+ via `chat.on(name, fn)` (instance) or `HelpAI.hook(widgetId, name, fn)`
55
+ (global, pre-boot-safe). Full schema in [Events & hooks](./events-and-hooks.md).
56
+
57
+ | Category | Event | Payload |
58
+ | ---------------- | --------------------- | -------------------------------------------------- |
59
+ | **Panel** | `open` | `void` |
60
+ | | `close` | `void` |
61
+ | | `expand` | `boolean` (true = expanded) |
62
+ | | `fullscreen` | `boolean` (true = entering fullscreen) |
63
+ | | `resize` | `{ width, height }` |
64
+ | **Conversation** | `message` | `{ role, text }` (fires for user + assistant) |
65
+ | | `send` | `{ text, attachmentCount }` (fires on user submit) |
66
+ | | `stop` | `void` (user cancelled in-flight stream) |
67
+ | | `clear` | `void` (new conversation) |
68
+ | | `selectConversation` | `{ conversationId }` (resumed from history) |
69
+ | | `suggestion` | `{ text }` (clicked a quick-reply chip) |
70
+ | | `toggleHistory` | `{ view: "chat" \| "history" }` |
71
+ | **Composer** | `attach` | `{ count, totalBytes }` |
72
+ | | `removeAttachment` | `{ id }` |
73
+ | | `voiceStart` | `void` |
74
+ | | `voiceStop` | `{ durationMs }` |
75
+ | | `voiceCancel` | `void` |
76
+ | **Preferences** | `localeChange` | `string` (BCP-47 tag, e.g. `"fr"`) |
77
+ | | `themeChange` | `"auto" \| "light" \| "dark"` |
78
+ | | `soundToggle` | `{ muted }` |
79
+ | | `sidebarToggle` | `{ collapsed }` (page mode) |
80
+ | | `calloutDismiss` | `void` |
81
+ | **Lifecycle** | `error` | `unknown` (any unhandled error) |
82
+ | | `handshake` | `HandshakeResponse` (full handshake payload) |
83
+ | | `conversationRebound` | `{ previous, current }` |
84
+
85
+ ---
86
+
87
+ ## The two subscription APIs
88
+
89
+ ### `chat.on(event, fn)` — instance-scoped
90
+
91
+ Returns an `off` function. Use when you have a handle to the mounted
92
+ instance (NPM / ESM / `mount()` returns it; `HelpAI.getInstance()`
93
+ fetches the default).
94
+
95
+ ```ts
96
+ const off = chat.on('send', ({ text, attachmentCount }) => {
97
+ console.log('user sent', text, 'with', attachmentCount, 'files');
98
+ });
99
+
100
+ // Later — unsubscribe
101
+ off();
102
+ ```
103
+
104
+ ### `HelpAI.hook(widgetId, event, fn)` — global, pre-boot-safe
105
+
106
+ Targets every widget that matches `(widgetId, event)`. Use when:
107
+
108
+ - You want to register **before** the bundle finishes loading (queue replays once boot completes).
109
+ - You have **multiple widgets** on one page and want a single listener for all of them (`widgetId: "*"`).
110
+ - You want to track every event with one listener (`event: "*"`).
111
+
112
+ ```html
113
+ <!-- This runs BEFORE elements.js parses. The queued call replays after boot. -->
114
+ <script>
115
+ window.HelpAI =
116
+ window.HelpAI ||
117
+ function () {
118
+ (HelpAI.q = HelpAI.q || []).push(arguments);
119
+ };
120
+ HelpAI('hook', '*', '*', (chat, event, payload) => {
121
+ track(`chat_${event}`, { widgetId: chat.widgetId, ...payload });
122
+ });
123
+ </script>
124
+ <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>
125
+ ```
126
+
127
+ The listener receives `(chat, event, payload)` — `chat` is the
128
+ `ChatWidgetInstance` (so you can branch on `chat.widgetId`), `event`
129
+ is the string event name, `payload` is the typed payload (or
130
+ `undefined` for `void` events).
131
+
132
+ ---
133
+
134
+ ## Multiple analytics providers — three patterns
135
+
136
+ ### Pattern A — one listener per provider (simplest)
137
+
138
+ Each provider registers its own wildcard listener. Order doesn't
139
+ matter; one provider erroring out doesn't break the others (the bus
140
+ isolates listener exceptions).
141
+
142
+ ```html
143
+ <script>
144
+ HelpAI(() => {
145
+ const chat = HelpAI.getInstance();
146
+
147
+ // GA4
148
+ chat.on('send', ({ text, attachmentCount }) => gtag('event', 'chat_send', { text_length: text.length, attachments: attachmentCount }));
149
+ chat.on('themeChange', (mode) => gtag('event', 'chat_theme', { mode }));
150
+
151
+ // Mixpanel
152
+ chat.on('send', ({ text }) => mixpanel.track('Chat Send', { textLength: text.length }));
153
+ chat.on('themeChange', (mode) => mixpanel.track('Chat Theme', { mode }));
154
+
155
+ // Sentry for errors only
156
+ chat.on('error', (err) => Sentry.captureException(err, { tags: { source: 'chat-widget' } }));
157
+ });
158
+ </script>
159
+ ```
160
+
161
+ ### Pattern B — one wildcard listener, fan out internally
162
+
163
+ When you want every event in every provider, declare a small
164
+ `providers` array and let one listener push to all of them. New
165
+ provider? Add a function to the array.
166
+
167
+ ```js
168
+ HelpAI(() => {
169
+ const chat = HelpAI.getInstance();
170
+
171
+ // Each provider is `(event, payload) => void`.
172
+ const providers = [
173
+ // GA4
174
+ (event, payload) => gtag('event', `chat_${event}`, flatten(payload)),
175
+
176
+ // Mixpanel
177
+ (event, payload) => mixpanel.track(`Chat ${titleCase(event)}`, flatten(payload)),
178
+
179
+ // Segment / RudderStack
180
+ (event, payload) => analytics.track(`Chat ${titleCase(event)}`, flatten(payload)),
181
+
182
+ // Amplitude
183
+ (event, payload) => amplitude.track(`chat_${event}`, flatten(payload)),
184
+
185
+ // PostHog
186
+ (event, payload) => posthog.capture(`chat_${event}`, flatten(payload)),
187
+
188
+ // Server-side beacon (your own endpoint)
189
+ (event, payload) => navigator.sendBeacon('/api/telemetry', JSON.stringify({ event, payload })),
190
+ ];
191
+
192
+ chat.on('open', () => fanOut('open'));
193
+ chat.on('close', () => fanOut('close'));
194
+ chat.on('send', (p) => fanOut('send', p));
195
+ chat.on('message', (p) => fanOut('message', p));
196
+ chat.on('clear', () => fanOut('clear'));
197
+ chat.on('selectConversation', (p) => fanOut('select_conversation', p));
198
+ chat.on('suggestion', (p) => fanOut('suggestion', p));
199
+ chat.on('themeChange', (m) => fanOut('theme_change', { mode: m }));
200
+ chat.on('localeChange', (l) => fanOut('locale_change', { locale: l }));
201
+ chat.on('attach', (p) => fanOut('attach', p));
202
+ chat.on('removeAttachment', (p) => fanOut('remove_attachment', p));
203
+ chat.on('voiceStart', () => fanOut('voice_start'));
204
+ chat.on('voiceStop', (p) => fanOut('voice_stop', p));
205
+ chat.on('expand', (v) => fanOut('expand', { expanded: v }));
206
+ chat.on('fullscreen', (v) => fanOut('fullscreen', { fullscreen: v }));
207
+ chat.on('resize', (p) => fanOut('resize', p));
208
+ chat.on('toggleHistory', (p) => fanOut('toggle_history', p));
209
+ chat.on('soundToggle', (p) => fanOut('sound_toggle', p));
210
+ chat.on('sidebarToggle', (p) => fanOut('sidebar_toggle', p));
211
+ chat.on('calloutDismiss', () => fanOut('callout_dismiss'));
212
+ chat.on('error', (e) => fanOut('error', { description: String(e) }));
213
+ chat.on('handshake', (r) => fanOut('handshake', { aiAgentId: r.agent?.id }));
214
+
215
+ function fanOut(event, payload) {
216
+ for (const send of providers) {
217
+ try {
218
+ send(event, payload);
219
+ } catch (e) {
220
+ console.error('[analytics]', e);
221
+ }
222
+ }
223
+ }
224
+ });
225
+
226
+ function flatten(p) {
227
+ return p == null ? {} : typeof p === 'object' ? p : { value: p };
228
+ }
229
+ function titleCase(s) {
230
+ return s.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
231
+ }
232
+ ```
233
+
234
+ ### Pattern C — single wildcard hook, multi-provider fan-out (most concise)
235
+
236
+ The `'*' / '*'` wildcard hook receives every event from every widget
237
+ on the page. Combined with a provider array, this is the same idea as
238
+ Pattern B but in 5 lines.
239
+
240
+ ```html
241
+ <script>
242
+ // Provider table — add / remove rows as you onboard more trackers.
243
+ const TRACKERS = [(event, payload) => gtag('event', `chat_${event}`, payload), (event, payload) => mixpanel.track(`chat_${event}`, payload), (event, payload) => analytics.track(`chat_${event}`, payload)];
244
+
245
+ HelpAI('hook', '*', '*', (chat, event, payload) => {
246
+ const flat = payload == null ? {} : typeof payload === 'object' ? payload : { value: payload };
247
+ for (const send of TRACKERS) {
248
+ try {
249
+ send(event, { widgetId: chat.widgetId, ...flat });
250
+ } catch (e) {
251
+ console.error('[analytics]', e);
252
+ }
253
+ }
254
+ });
255
+ </script>
256
+ ```
257
+
258
+ ---
259
+
260
+ ## Multiple GA4 properties
261
+
262
+ Same SDK, multiple `config` calls — gtag sends every event to every
263
+ configured measurement id by default. No widget-specific change needed:
264
+
265
+ ```html
266
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-PRIMARY"></script>
267
+ <script>
268
+ window.dataLayer = window.dataLayer || [];
269
+ function gtag() {
270
+ dataLayer.push(arguments);
271
+ }
272
+ gtag('js', new Date());
273
+ gtag('config', 'G-PRIMARY');
274
+ gtag('config', 'G-SECONDARY');
275
+ gtag('config', 'G-DEBUG-PROPERTY');
276
+ </script>
277
+ ```
278
+
279
+ Need to send some events to only one of them? Use `gtag`'s `send_to`:
280
+
281
+ ```js
282
+ chat.on('send', ({ text, attachmentCount }) =>
283
+ gtag('event', 'chat_send', {
284
+ text_length: text.length,
285
+ attachments: attachmentCount,
286
+ send_to: ['G-PRIMARY', 'G-DEBUG-PROPERTY'], // skip secondary
287
+ }),
288
+ );
289
+ ```
290
+
291
+ ---
292
+
293
+ ## Per-provider recipes
294
+
295
+ ### Google Tag Manager (dataLayer)
296
+
297
+ GTM consumes the `dataLayer` push — every push becomes a GTM event
298
+ you can use as a trigger.
299
+
300
+ ```js
301
+ chat.on('send', ({ text, attachmentCount }) => {
302
+ window.dataLayer.push({
303
+ event: 'chat_send',
304
+ chat: { textLength: text.length, attachments: attachmentCount, widgetId: chat.widgetId },
305
+ });
306
+ });
307
+ ```
308
+
309
+ In GTM, create a custom-event trigger on `chat_send` and a variable
310
+ chain reading `chat.textLength` etc.
311
+
312
+ ### Segment
313
+
314
+ ```js
315
+ chat.on('send', ({ text, attachmentCount }) => analytics.track('Chat Send', { textLength: text.length, attachmentCount }));
316
+ chat.on('clear', () => analytics.track('Chat Cleared'));
317
+ chat.on('selectConversation', ({ conversationId }) => analytics.track('Chat Resumed', { conversationId }));
318
+ chat.on('themeChange', (mode) => analytics.track('Chat Theme Changed', { mode }));
319
+ chat.on('error', (err) => analytics.track('Chat Error', { description: String(err) }));
320
+ ```
321
+
322
+ ### Amplitude
323
+
324
+ ```js
325
+ chat.on('send', ({ text, attachmentCount }) => amplitude.track('chat_send', { text_length: text.length, attachments: attachmentCount }));
326
+ chat.on('themeChange', (mode) => amplitude.track('chat_theme', { mode }));
327
+ chat.on('voiceStop', ({ durationMs }) => amplitude.track('chat_voice', { duration_ms: durationMs }));
328
+ ```
329
+
330
+ ### PostHog
331
+
332
+ ```js
333
+ chat.on('send', ({ text, attachmentCount }) => posthog.capture('chat_send', { text_length: text.length, attachments: attachmentCount }));
334
+ chat.on('handshake', (res) => posthog.capture('chat_connected', { agent: res.agent?.id }));
335
+ chat.on('error', (err) => posthog.capture('chat_error', { description: String(err) }));
336
+ ```
337
+
338
+ ### Sentry (error tracking)
339
+
340
+ The `error` event fires for **every** caught error — the stream, async
341
+ handlers, AND render crashes (the widget wraps its UI in an error boundary, so a
342
+ render throw degrades to a quiet fallback instead of white-screening the host
343
+ page; the error is reported here, never swallowed). It's also delivered to the
344
+ per-instance `options.onError` callback.
345
+
346
+ ```js
347
+ chat.on('error', (err) => {
348
+ Sentry.captureException(err, {
349
+ tags: { source: 'chat-widget', widgetId: chat.widgetId },
350
+ });
351
+ });
352
+ ```
353
+
354
+ ### Forward logs to your observability stack (`onLog`)
355
+
356
+ Beyond semantic events, the widget's scoped logger can stream **every log line**
357
+ to a sink — pipe warnings/errors/debug into Sentry breadcrumbs, Datadog, etc.
358
+ One global sink per page; it respects the active level (warnings + errors by
359
+ default), so raise it with `setDebug("trace")` to capture everything.
360
+
361
+ ```js
362
+ // Pre-boot safe — wire it in the loader snippet before the bundle loads.
363
+ HelpAI('onLog', ({ level, scope, message, payload }) => {
364
+ if (level === 'error' || level === 'warn') {
365
+ Sentry.addBreadcrumb({ category: `widget:${scope}`, level, message, data: payload });
366
+ }
367
+ });
368
+ HelpAI('setDebug', 'debug'); // capture more than just warnings/errors
369
+
370
+ // Or on a handle: chat.onLog(fn); pass null to detach.
371
+ ```
372
+
373
+ ### Custom backend beacon
374
+
375
+ `navigator.sendBeacon` survives page unload — useful for logging
376
+ the last action before a visitor navigates away.
377
+
378
+ ```js
379
+ chat.on('close', () => {
380
+ navigator.sendBeacon(
381
+ '/api/telemetry',
382
+ JSON.stringify({
383
+ event: 'chat_close',
384
+ widgetId: chat.widgetId,
385
+ ts: Date.now(),
386
+ }),
387
+ );
388
+ });
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Common patterns
394
+
395
+ ### Identify the visitor
396
+
397
+ The widget mints a stable anonymous `visitorId` on first load and
398
+ includes it in the `handshake` event's payload. Surface it to your
399
+ analytics for cross-conversation attribution.
400
+
401
+ ```js
402
+ chat.on('handshake', (res) => {
403
+ const visitorId = res.visitorId;
404
+ // Segment
405
+ analytics.identify(visitorId);
406
+ // Mixpanel
407
+ mixpanel.identify(visitorId);
408
+ // PostHog
409
+ posthog.identify(visitorId);
410
+ // GA4 user_id
411
+ gtag('config', 'G-XXXXXXX', { user_id: visitorId });
412
+ });
413
+ ```
414
+
415
+ ### Sample noisy events
416
+
417
+ `message` fires twice per turn (user + assistant) and adds up fast.
418
+ Drop the assistant half if you only care about user activity:
419
+
420
+ ```js
421
+ chat.on('message', ({ role, text }) => {
422
+ if (role !== 'user') return;
423
+ track('chat_message', { length: text.length });
424
+ });
425
+ ```
426
+
427
+ ### Sanitize PII before sending
428
+
429
+ User-typed text may contain emails / IDs / etc. Hash or truncate
430
+ before forwarding to third-party trackers:
431
+
432
+ ```js
433
+ chat.on('send', async ({ text }) => {
434
+ const hash = await sha256(text);
435
+ track('chat_send', { text_hash: hash, text_length: text.length });
436
+ // text itself never leaves the page.
437
+ });
438
+ ```
439
+
440
+ ### Conditionally enable (consent-aware)
441
+
442
+ ```js
443
+ HelpAI(() => {
444
+ if (!userConsentedToAnalytics()) return;
445
+ const chat = HelpAI.getInstance();
446
+ chat.on('send', (payload) => gtag('event', 'chat_send', payload));
447
+ // … rest of subscriptions …
448
+ });
449
+ ```
450
+
451
+ ### Disable on `localhost` / preview deploys
452
+
453
+ ```js
454
+ const isProd = location.hostname === 'help.ai';
455
+ if (isProd) {
456
+ chat.on('send', (payload) => gtag('event', 'chat_send', payload));
457
+ }
458
+ ```
459
+
460
+ ### Multi-widget pages — branch on widgetId
461
+
462
+ ```js
463
+ HelpAI('hook', '*', 'send', (chat, _evt, payload) => {
464
+ if (chat.widgetId === 'support') {
465
+ gtag('event', 'support_chat_send', payload);
466
+ } else if (chat.widgetId === 'sales') {
467
+ gtag('event', 'sales_chat_send', payload);
468
+ }
469
+ });
470
+ ```
471
+
472
+ ---
473
+
474
+ ## TypeScript
475
+
476
+ The event names + payloads are typed — your IDE autocompletes the
477
+ shape of every callback argument:
478
+
479
+ ```ts
480
+ import type { ChatWidgetInstance, EventMap } from '@helpai/elements';
481
+
482
+ function wireAnalytics(chat: ChatWidgetInstance) {
483
+ chat.on('send', (payload) => {
484
+ // ^? { text: string; attachmentCount: number }
485
+ gtag('event', 'chat_send', { text_length: payload.text.length });
486
+ });
487
+
488
+ chat.on('themeChange', (mode) => {
489
+ // ^? "auto" | "light" | "dark"
490
+ gtag('event', 'chat_theme', { mode });
491
+ });
492
+ }
493
+
494
+ // Iterate every event for the fan-out pattern.
495
+ const events: Array<keyof EventMap> = ['open', 'close', 'send' /* … */];
496
+ ```
497
+
498
+ ---
499
+
500
+ ## Troubleshooting
501
+
502
+ ### "My listener never fires"
503
+
504
+ - Are you registering **before mount completes**? Use the global
505
+ `HelpAI(() => { ... })` queue form, or `HelpAI("hook", …)` (both
506
+ are pre-boot-safe).
507
+ - Wrong `widgetId`? Auto-boot uses `"default"`; pass `widgetId` to
508
+ `init()` / `mount()` for named or multiple programmatic instances.
509
+ Use `"*"` to match every widget.
510
+ - Spelling — event names are case-sensitive (`themeChange`, not `theme_change`).
511
+
512
+ ### "Listener fires twice"
513
+
514
+ Every `chat.on(name, fn)` subscribes one listener. If you wired it
515
+ inside a re-running effect (React `useEffect` without cleanup),
516
+ you're registering N copies. Always call the returned `off` in your
517
+ cleanup:
518
+
519
+ ```jsx
520
+ useEffect(() => {
521
+ const off = chat.on("send", payload => analytics.track(...));
522
+ return off; // <-- critical
523
+ }, [chat]);
524
+ ```
525
+
526
+ ### "I want to see what's firing"
527
+
528
+ Enable trace logging — every action fires a structured log line at
529
+ `info` level:
530
+
531
+ ```js
532
+ chat.setDebug('info'); // or "trace" for SSE-frame-level detail
533
+ ```
534
+
535
+ Or from the page URL: `?helpai-debug=info`. Persists in `localStorage`.
536
+
537
+ ### "One provider throws and breaks the others"
538
+
539
+ The event bus already isolates listener exceptions — one throw in
540
+ listener A doesn't prevent listener B from firing. But within a
541
+ single fan-out loop (Pattern B/C), wrap each call in try/catch
542
+ yourself — see the snippets above.
543
+
544
+ ---
545
+
546
+ ## See also
547
+
548
+ - [Events & hooks](./events-and-hooks.md) — full event reference, payload shapes, pre-boot queue semantics
549
+ - [API reference](./api-reference.md) — `ChatWidgetInstance` shape
550
+ - [Recipes](./recipes.md) — other end-to-end snippets
551
+ - [Security & privacy](./security-privacy.md) — what data flows where