@hanzo/event 0.3.32 → 0.3.33

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.
package/hz.js DELETED
@@ -1,511 +0,0 @@
1
- /*! hz.js — the no-build distribution of @hanzo/event.
2
- *
3
- * A <script> tag for surfaces that have no bundler: a CMS page, a landing page,
4
- * a docs site. It speaks the SAME wire as the npm client and posts to the SAME
5
- * front door — one batch of WireEvents to POST {host}/v1/event:
6
- *
7
- * { batch: [ { messageId, type, event, timestamp, distinctId, anonymousId,
8
- * sessionId, product, url, path, referrer, properties }, … ] }
9
- *
10
- * <script async src="https://unpkg.com/@hanzo/event/hz.js"
11
- * data-product="hanzo.ai" // required: which surface this is
12
- * data-publishable-key="pk-…" // required off api.hanzo.ai's own origin
13
- * data-host="https://api.hanzo.ai" // optional: API host override
14
- * data-capture="1"></script> // optional: autocapture off with "0"
15
- *
16
- * It adds what a bundled app does not need and a plain page cannot get: DOM
17
- * AUTOCAPTURE. Clicks on interactive elements (with a compact element locator),
18
- * outbound links, scroll depth, form submits and core web vitals arrive as
19
- * `$click` / `$outbound` / `$scroll` / `$form` / `$vitals` events on the one
20
- * stream. Manual: window.hanzo.track(name, props) · identify(id, traits) · page().
21
- * Respects DNT. No PII beyond the short element text the locator carries.
22
- *
23
- * It used to live in hanzoai/analytics and post a BARE JSON ARRAY of
24
- * {site, ts, type, path, …} to analytics.hanzo.ai/v1/event — a second protocol
25
- * behind an identical path spelling, served by a second collector with its own
26
- * database. Both are deleted. There is one wire, one door and one client home,
27
- * and this file is that client's script-tag form.
28
- */
29
- ;(function () {
30
- var s = document.currentScript
31
- if (!s) return
32
-
33
- // ── consent ───────────────────────────────────────────────────────────────
34
- // The same three sources the bundled stack honours, restated here for the same
35
- // reason the uid minter and the scrubber are: a script tag has no bundler and
36
- // cannot import them. An EXPLICIT stored choice — `hz_consent`, the key a Hanzo
37
- // consent banner writes — outranks the browser signal in BOTH directions,
38
- // because that is what "explicit" means. Otherwise Global Privacy Control (the
39
- // signal CPRA actually obliges a site to obey) and Do Not Track are refusals.
40
- var choice = null
41
- try {
42
- choice = localStorage.getItem('hz_consent')
43
- } catch (e) {}
44
- if (choice !== 'granted') {
45
- if (choice === 'denied' || window.hzDNT) return
46
- if (navigator.globalPrivacyControl === true) return
47
- if (
48
- navigator.doNotTrack === '1' ||
49
- navigator.doNotTrack === 'yes' ||
50
- window.doNotTrack === '1' ||
51
- navigator.msDoNotTrack === '1'
52
- )
53
- return
54
- }
55
-
56
- var LIB = 'hz.js'
57
- var VERSION = '0.3.32'
58
- var host = (s.getAttribute('data-host') || 'https://api.hanzo.ai').replace(/\/+$/, '')
59
- var product = s.getAttribute('data-product') || location.hostname
60
- var capture = s.getAttribute('data-capture') !== '0'
61
- // The publishable ingest key (pk-…). Write-only and safe in page source: it
62
- // attributes a write and mints no reading principal.
63
- //
64
- // Without it a tag on any origin but the door's own sends an UNATTRIBUTED
65
- // write, and the door refuses one (401 ingest_key_required) — silently, since
66
- // nothing here reads the response. Through 0.3.11 this file had no way to
67
- // present a key at all, so every keyed static surface looked wired, measured
68
- // fine in the browser, and filed nothing.
69
- // data-publishable-key is the name; data-ingest-key is the retiring spelling.
70
- // The value is the surface's own KMS-sourced key, stamped into the tag by the
71
- // deploy — never a literal baked in here. With neither attribute the tag is
72
- // keyless and its writes file under $public; the fix is to stamp the key, not
73
- // to hardcode the org's credential into this file.
74
- var key = s.getAttribute('data-publishable-key') || s.getAttribute('data-ingest-key') || ''
75
-
76
- // ── the shared anonymous-identity chain ───────────────────────────────────
77
- // COPIED VERBATIM from @hanzo/event's src/anon.js, markers and all, for the same
78
- // reason `clean` restates scrub.ts: this file has no bundler and cannot import
79
- // anything. It is the ONE chain and the ONE key, so a page carrying this tag and
80
- // the npm client resolves to one person; src/anon.test.ts fails on a byte of
81
- // drift between the two copies. EDIT src/anon.js, never this copy, and do not
82
- // reformat it — the indentation is part of the byte comparison.
83
-
84
- /* ── BEGIN hz anon chain — copied VERBATIM into hz.js and hanzoai/cloud ────── */
85
-
86
- /** The ONE anonymous-id key, on every surface and in every distribution. */
87
- var HZ_ANON_KEY = 'hz_anon_id'
88
-
89
- /** hz.js used to write `hz_id` — a SECOND identity space, so the one-paste tag
90
- * and the npm client were two different people on one page. It is READ and never
91
- * written: an id already in the wild is ADOPTED into the shared identity, because
92
- * minting over one detaches a returning visitor from their own history. */
93
- var HZ_ANON_LEGACY_KEY = 'hz_id'
94
-
95
- /** The registrable domain the cookie is scoped to, so docs, cloud, console,
96
- * studio, pay, id and www all read the ONE id. localStorage cannot do this: it is
97
- * ORIGIN-scoped, which is what made one journey arrive as several strangers. */
98
- var HZ_ANON_DOMAIN = 'hanzo.ai'
99
-
100
- /** Two years, rewritten on every read, so the cookie rolls forward with the
101
- * visitor instead of expiring two years after first touch. Safari caps a
102
- * SCRIPT-written cookie at 7 days no matter what this says, so the rewrite is
103
- * what keeps a returning Safari visitor: each read re-arms the 7-day window. */
104
- var HZ_ANON_MAX_AGE = 2 * 365 * 24 * 60 * 60
105
-
106
- /** Last resort for a browser that refuses cookies AND localStorage: without it
107
- * every event in a page load would mint an id of its own. */
108
- var hzAnonMemo
109
-
110
- /**
111
- * hzUuidv7 mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch ms.
112
- *
113
- * It has to be v7, and this is the only minter any distribution may use. The
114
- * session rollups on the event plane derive a session's start instant FROM THE ID
115
- * and admit only ids whose version nibble is 7, so a crypto.randomUUID() (v4) id
116
- * is not merely unordered there — it is DISCARDED, silently, and the rollup stays
117
- * empty. Without crypto only the ENTROPY degrades; the shape is always a valid v7.
118
- */
119
- function hzUuidv7(now) {
120
- var b = new Uint8Array(16)
121
- var i
122
- var c = typeof crypto !== 'undefined' ? crypto : undefined
123
- if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b)
124
- else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
125
- var t = Math.floor(now === undefined ? Date.now() : now)
126
- for (i = 5; i >= 0; i--) {
127
- b[i] = t % 256
128
- t = Math.floor(t / 256)
129
- }
130
- b[6] = 0x70 | (b[6] & 0x0f) // version 7
131
- b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
132
- var h = ''
133
- for (i = 0; i < 16; i++) {
134
- h += (b[i] + 0x100).toString(16).slice(1)
135
- if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
136
- }
137
- return h
138
- }
139
-
140
- /** The cookie jar, or null wherever there is no document to read one from. */
141
- function hzAnonJar() {
142
- try {
143
- if (typeof document === 'undefined' || typeof document.cookie !== 'string') return null
144
- return document
145
- } catch (e) {
146
- return null // sandboxed frame with an opaque origin
147
- }
148
- }
149
-
150
- /** localStorage, or null when the browser refuses it (Safari private mode). */
151
- function hzAnonStore() {
152
- try {
153
- if (typeof window === 'undefined' || !window.localStorage) return null
154
- return window.localStorage
155
- } catch (e) {
156
- return null
157
- }
158
- }
159
-
160
- /** One stored value, or '' — a jar can read as well as refuse to. */
161
- function hzAnonItem(store, name) {
162
- try {
163
- return (store && store.getItem(name)) || ''
164
- } catch (e) {
165
- return ''
166
- }
167
- }
168
-
169
- /** The value of cookie `name`, or ''. */
170
- function hzAnonCookie(name) {
171
- var d = hzAnonJar()
172
- if (!d) return ''
173
- var parts = d.cookie.split(';')
174
- for (var i = 0; i < parts.length; i++) {
175
- var eq = parts[i].indexOf('=')
176
- if (eq < 0 || parts[i].slice(0, eq).trim() !== name) continue
177
- var v = parts[i].slice(eq + 1).trim()
178
- if (!v) continue
179
- try {
180
- return decodeURIComponent(v)
181
- } catch (e) {
182
- return v // not percent-encoded — take it as written
183
- }
184
- }
185
- return ''
186
- }
187
-
188
- /** Writes `name` on the registrable domain, for as long as the browser allows. */
189
- function hzAnonWrite(name, value) {
190
- var d = hzAnonJar()
191
- if (!d) return
192
- var host = ''
193
- var secure = false
194
- try {
195
- if (typeof window !== 'undefined' && window.location) {
196
- host = window.location.hostname || ''
197
- // A Secure cookie is refused outright by a non-secure origin, which would
198
- // strand http://localhost dev on the localStorage path.
199
- secure = window.location.protocol === 'https:'
200
- }
201
- } catch (e) {
202
- /* location unreachable — write a host-only, non-secure cookie */
203
- }
204
- // encodeURIComponent leaves a UUID byte-identical while making any value that is
205
- // not one unable to forge a `;` and inject an attribute.
206
- var c = name + '=' + encodeURIComponent(value)
207
- c += '; Path=/; Max-Age=' + HZ_ANON_MAX_AGE + '; SameSite=Lax'
208
- // Off hanzo.ai (localhost, previews, other registrable domains) the attribute
209
- // would be rejected and the whole cookie dropped, so it stays host-only there.
210
- // Prefixing both sides with '.' matches the domain itself and its subdomains
211
- // while refusing a suffix that merely ends in the same letters (evilhanzo.ai).
212
- if (('.' + host).slice(-(HZ_ANON_DOMAIN.length + 1)) === '.' + HZ_ANON_DOMAIN) {
213
- c += '; Domain=' + HZ_ANON_DOMAIN
214
- }
215
- if (secure) c += '; Secure'
216
- try {
217
- d.cookie = c
218
- } catch (e) {
219
- /* cookies refused — localStorage still carries the id */
220
- }
221
- }
222
-
223
- /**
224
- * hzAnonId returns the stable anonymous id for this browser, '' during SSR.
225
- *
226
- * Resolution is strictly ADDITIVE — every id that already exists is ADOPTED, and
227
- * only a browser holding none of them is given a new one:
228
- *
229
- * cookie · localStorage hz_anon_id · localStorage hz_id · in-memory · mint
230
- *
231
- * Minting over an id resets a returning visitor and detaches them from their own
232
- * history, so the order is the migration: the cookie is the shared home, the two
233
- * localStorage keys are what the three implementations wrote before it existed,
234
- * and each is read until nothing is left to adopt.
235
- *
236
- * localStorage keeps being written, so a rollback finds everyone where it left
237
- * them, and a browser that refuses cookies still holds one id per origin.
238
- */
239
- function hzAnonId() {
240
- if (typeof window === 'undefined') return '' // SSR / prerender: no browser to identify
241
- var s = hzAnonStore()
242
- var id =
243
- hzAnonCookie(HZ_ANON_KEY) ||
244
- hzAnonItem(s, HZ_ANON_KEY) ||
245
- hzAnonItem(s, HZ_ANON_LEGACY_KEY) ||
246
- hzAnonMemo ||
247
- hzUuidv7()
248
- hzAnonMemo = id
249
- hzAnonWrite(HZ_ANON_KEY, id)
250
- try {
251
- if (s && s.getItem(HZ_ANON_KEY) !== id) s.setItem(HZ_ANON_KEY, id)
252
- } catch (e) {
253
- /* quota exhausted, or a private-mode jar that reads but refuses writes */
254
- }
255
- return id
256
- }
257
-
258
- /* ── END hz anon chain ─────────────────────────────────────────────────────── */
259
-
260
- function stored(store, key) {
261
- try {
262
- var v = store.getItem(key)
263
- if (!v) store.setItem(key, (v = hzUuidv7()))
264
- return v
265
- } catch (e) {
266
- return 'anon'
267
- }
268
- }
269
- // This used to mint into a localStorage key of its own, so the tag and
270
- // @hanzo/event counted one visitor as two people. The chain adopts that old id
271
- // where it exists rather than orphaning it.
272
- var anon = hzAnonId()
273
- var sid = stored(sessionStorage, 'hz_sid')
274
- var person = null
275
- try {
276
- person = localStorage.getItem('hz_uid')
277
- } catch (e) {}
278
-
279
- var queue = [],
280
- timer
281
- function flush() {
282
- clearTimeout(timer)
283
- if (!queue.length) return
284
- var body = JSON.stringify({ batch: queue.splice(0, queue.length) })
285
- var url = host + '/v1/event'
286
- // BOTH transports send a SIMPLE request, and for the same reason. The key is
287
- // publishable (data-publishable-key, write-only and safe in page source), so
288
- // it rides the query — the one carrier neither transport needs a header for —
289
- // and the body is text/plain, the CORS-safelisted type. A simple request is
290
- // sent whatever origin it is on, because the browser asks permission to READ
291
- // a cross-origin response, never to send one, and nothing here reads it.
292
- //
293
- // That property is what lets this file run on a customer's own page at all.
294
- // Every send from there is cross-origin; an Authorization header or a JSON
295
- // content type makes the POST preflighted instead, and an origin that does not
296
- // pass the preflight loses the batch, which splice has already emptied.
297
- //
298
- // The door reads the raw body and dispatches on its first non-space byte, so
299
- // the type names the CORS class and nothing else.
300
- var wire = key ? url + '?ingest_key=' + encodeURIComponent(key) : url
301
- try {
302
- if (navigator.sendBeacon && navigator.sendBeacon(wire, new Blob([body], { type: 'text/plain' })))
303
- return
304
- } catch (e) {}
305
- fetch(wire, {
306
- method: 'POST',
307
- body: body,
308
- keepalive: true,
309
- credentials: 'omit',
310
- headers: { 'content-type': 'text/plain' },
311
- }).catch(function () {})
312
- }
313
- // ── location redaction ────────────────────────────────────────────────────
314
- // The same policy src/scrub.ts applies in the npm client, restated here because
315
- // this file has no bundler and therefore cannot import it: a reset, invite or
316
- // magic link carries a JWT in the query and an address in `?email=`, and the
317
- // location is stamped on EVERY event — so without this, one page load ships the
318
- // credential to the warehouse and every later click repeats it.
319
- //
320
- // Deliberately a SUBSET: the shapes that actually appear in a URL. Free-text
321
- // error scrubbing (PANs, private keys, stack text) has no counterpart here
322
- // because this distribution has no error plane. Keep the markers identical to
323
- // scrub.ts — a warehouse row must not reveal which distribution wrote it.
324
- var SECRETS = [
325
- /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, // JWT
326
- /\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
327
- /\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g,
328
- /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
329
- /\bhk-[A-Za-z0-9]{16,}/g,
330
- /\bgh[posru]_[A-Za-z0-9]{20,}/g,
331
- /\bAIza[0-9A-Za-z_-]{20,}/g,
332
- /\bAKIA[0-9A-Z]{16}\b/g,
333
- // Bounded like scrub.ts's: the unbounded form backtracks quadratically on
334
- // colon-rich text that never reaches an '@'.
335
- /[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g,
336
- ]
337
- var EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g
338
- function clean(u) {
339
- if (!u) return u
340
- if (u.length > 8192) u = u.slice(0, 8192) + '… [truncated]'
341
- for (var i = 0; i < SECRETS.length; i++) u = u.replace(SECRETS[i], '[redacted]')
342
- return u.replace(EMAIL, '[email]')
343
- }
344
-
345
- // send builds ONE WireEvent — the same shape core.ts build() produces, so the
346
- // server cannot tell which distribution emitted it.
347
- function send(kind, event, props) {
348
- queue.push({
349
- messageId: hzUuidv7(),
350
- type: kind,
351
- event: event,
352
- timestamp: new Date().toISOString(),
353
- distinctId: person || anon,
354
- anonymousId: anon,
355
- personId: person || undefined,
356
- sessionId: sid,
357
- product: product,
358
- url: clean(location.href),
359
- path: clean(location.pathname),
360
- referrer: clean(document.referrer) || undefined,
361
- properties: props || undefined,
362
- library: LIB,
363
- libraryVersion: VERSION,
364
- })
365
- clearTimeout(timer)
366
- timer = setTimeout(flush, 400)
367
- }
368
-
369
- // ── element locator (the autocapture detail) ──────────────────────────────
370
- // A compact, stable, PII-light descriptor of the element interacted with, so
371
- // movements read logically: tag, short text, id, data-*, and an ancestor path.
372
- function locator(el) {
373
- if (!el || el === document) return null
374
- var o = {
375
- tag: el.tagName ? el.tagName.toLowerCase() : '',
376
- id: el.id || undefined,
377
- name:
378
- (el.getAttribute && (el.getAttribute('name') || el.getAttribute('aria-label'))) ||
379
- undefined,
380
- }
381
- var txt = (el.innerText || el.value || '').trim().replace(/\s+/g, ' ').slice(0, 80)
382
- if (txt) o.text = txt
383
- // A link target is a URL like any other — a share/invite href carries the
384
- // same token shapes the page URL does.
385
- if (el.getAttribute && el.getAttribute('href')) o.href = clean(el.getAttribute('href'))
386
- if (el.dataset) for (var k in el.dataset) if (k !== 'hz') (o.data = o.data || {})[k] = el.dataset[k]
387
- var p = [],
388
- n = el,
389
- i = 0
390
- while (n && n.tagName && i++ < 4) {
391
- var seg = n.tagName.toLowerCase()
392
- if (n.id) {
393
- seg += '#' + n.id
394
- p.unshift(seg)
395
- break
396
- }
397
- if (n.className && typeof n.className === 'string')
398
- seg += '.' + n.className.trim().split(/\s+/).slice(0, 2).join('.')
399
- p.unshift(seg)
400
- n = n.parentElement
401
- }
402
- o.sel = p.join('>')
403
- return o
404
- }
405
- function interactive(el) {
406
- return el && el.closest && el.closest('a,button,[role=button],input,select,textarea,[data-hz],[onclick]')
407
- }
408
-
409
- // ── auto pageviews (initial + SPA) ────────────────────────────────────────
410
- var last = ''
411
- function page() {
412
- var k = location.pathname + location.search
413
- if (k === last) return
414
- last = k
415
- send('pageview', '$pageview')
416
- }
417
- page()
418
- ;['pushState', 'replaceState'].forEach(function (m) {
419
- var o = history[m]
420
- history[m] = function () {
421
- var r = o.apply(this, arguments)
422
- page()
423
- return r
424
- }
425
- })
426
- addEventListener('popstate', page)
427
-
428
- // ── autocapture: clicks, outbound, scroll depth, form submits ─────────────
429
- if (capture) {
430
- addEventListener(
431
- 'click',
432
- function (e) {
433
- var el = interactive(e.target)
434
- if (!el) return
435
- var loc = locator(el)
436
- send('event', '$click', loc)
437
- if (el.tagName === 'A' && el.host && el.host !== location.host)
438
- send('event', '$outbound', { url: clean(el.href), el: loc })
439
- },
440
- true,
441
- )
442
- addEventListener('submit', function (e) { send('event', '$form', locator(e.target)) }, true)
443
- var seen = {}
444
- addEventListener(
445
- 'scroll',
446
- function () {
447
- var d = document.documentElement
448
- var pct = Math.round(((scrollY + innerHeight) / (d.scrollHeight || 1)) * 100)
449
- ;[25, 50, 75, 100].forEach(function (m) {
450
- if (pct >= m && !seen[m]) {
451
- seen[m] = 1
452
- send('event', '$scroll', { depth: m })
453
- }
454
- })
455
- },
456
- { passive: true },
457
- )
458
- }
459
-
460
- // ── core web vitals (best-effort, no dep) ─────────────────────────────────
461
- var vitals = {}
462
- try {
463
- new PerformanceObserver(function (l) {
464
- l.getEntries().forEach(function (x) { vitals.lcp = Math.round(x.startTime) })
465
- }).observe({ type: 'largest-contentful-paint', buffered: true })
466
- new PerformanceObserver(function (l) {
467
- l.getEntries().forEach(function (x) {
468
- if (!x.hadRecentInput) vitals.cls = +((vitals.cls || 0) + x.value).toFixed(3)
469
- })
470
- }).observe({ type: 'layout-shift', buffered: true })
471
- } catch (e) {}
472
- // A document can be taken away on either signal, and neither one alone covers
473
- // every browser: visibilitychange is what fires when a tab is backgrounded or
474
- // discarded, pagehide is what fires on the navigation path where it does not.
475
- // core.ts listens for both; this listens for both. flush() returns on an empty
476
- // queue, so whichever arrives second finds nothing left to send.
477
- var vitalsSent = false
478
- function leaving() {
479
- // The web vitals are one measurement of one page view. Hiding a tab twice
480
- // does not make two of them.
481
- if (!vitalsSent && (vitals.lcp != null || vitals.cls != null)) {
482
- vitalsSent = true
483
- send('event', '$vitals', vitals)
484
- }
485
- flush()
486
- }
487
- addEventListener('visibilitychange', function () {
488
- if (document.visibilityState === 'hidden') leaving()
489
- })
490
- addEventListener('pagehide', leaving)
491
-
492
- // ── public API (manual funnel/identify) + GA/Meta fan-out ─────────────────
493
- function assign(a, b) {
494
- if (b) for (var k in b) a[k] = b[k]
495
- return a
496
- }
497
- window.hanzo = {
498
- track: function (name, props) { send('event', name, props || undefined) },
499
- identify: function (id, traits) {
500
- person = id
501
- try { localStorage.setItem('hz_uid', id) } catch (e) {}
502
- send('identify', undefined, traits || undefined)
503
- },
504
- page: function (props) {
505
- last = ''
506
- page()
507
- if (props) send('event', 'page_props', props)
508
- },
509
- flush: flush,
510
- }
511
- })()