@hanzo/event 0.3.6 → 0.3.9

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 ADDED
@@ -0,0 +1,328 @@
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-host="https://api.hanzo.ai" // optional: API host override
13
+ * data-ga="G-XXXX" data-fb="123" // optional: also fan out to GA4 / Meta
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
+ if (navigator.doNotTrack === '1' || navigator.doNotTrack === 'yes' || window.hzDNT) return
33
+
34
+ var LIB = 'hz.js'
35
+ var VERSION = '0.3.9'
36
+ var host = (s.getAttribute('data-host') || 'https://api.hanzo.ai').replace(/\/+$/, '')
37
+ var product = s.getAttribute('data-product') || location.hostname
38
+ var capture = s.getAttribute('data-capture') !== '0'
39
+
40
+ // uuidv7 (RFC 9562 §5.7) — the same minter as src/uid.ts, restated here for the
41
+ // same reason `clean` restates scrub.ts: this file has no bundler and cannot
42
+ // import it. It has to be v7. The session rollups on the plane derive a session's
43
+ // start instant from the 48-bit millisecond timestamp the id carries and admit
44
+ // only ids whose version nibble is 7, so a crypto.randomUUID() (v4) session id —
45
+ // and equally the old base36 fallback, which does not even parse as a UUID — is
46
+ // dropped there silently and the session never appears.
47
+ function uid(now) {
48
+ var b = new Uint8Array(16),
49
+ i
50
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(b)
51
+ else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
52
+ var t = Math.floor(now || Date.now())
53
+ for (i = 5; i >= 0; i--) {
54
+ b[i] = t % 256
55
+ t = Math.floor(t / 256)
56
+ }
57
+ b[6] = 0x70 | (b[6] & 0x0f) // version 7
58
+ b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
59
+ var h = ''
60
+ for (i = 0; i < 16; i++) {
61
+ h += (b[i] + 0x100).toString(16).slice(1)
62
+ if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
63
+ }
64
+ return h
65
+ }
66
+ function stored(store, key) {
67
+ try {
68
+ var v = store.getItem(key)
69
+ if (!v) store.setItem(key, (v = uid()))
70
+ return v
71
+ } catch (e) {
72
+ return 'anon'
73
+ }
74
+ }
75
+ var anon = stored(localStorage, 'hz_id')
76
+ var sid = stored(sessionStorage, 'hz_sid')
77
+ var person = null
78
+ try {
79
+ person = localStorage.getItem('hz_uid')
80
+ } catch (e) {}
81
+
82
+ var queue = [],
83
+ timer
84
+ function flush() {
85
+ clearTimeout(timer)
86
+ if (!queue.length) return
87
+ var body = JSON.stringify({ batch: queue.splice(0, queue.length) })
88
+ var url = host + '/v1/event'
89
+ try {
90
+ if (
91
+ navigator.sendBeacon &&
92
+ navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))
93
+ )
94
+ return
95
+ } catch (e) {}
96
+ fetch(url, {
97
+ method: 'POST',
98
+ body: body,
99
+ keepalive: true,
100
+ headers: { 'content-type': 'application/json' },
101
+ }).catch(function () {})
102
+ }
103
+ // ── location redaction ────────────────────────────────────────────────────
104
+ // The same policy src/scrub.ts applies in the npm client, restated here because
105
+ // this file has no bundler and therefore cannot import it: a reset, invite or
106
+ // magic link carries a JWT in the query and an address in `?email=`, and the
107
+ // location is stamped on EVERY event — so without this, one page load ships the
108
+ // credential to the warehouse and every later click repeats it.
109
+ //
110
+ // Deliberately a SUBSET: the shapes that actually appear in a URL. Free-text
111
+ // error scrubbing (PANs, private keys, stack text) has no counterpart here
112
+ // because this distribution has no error plane. Keep the markers identical to
113
+ // scrub.ts — a warehouse row must not reveal which distribution wrote it.
114
+ var SECRETS = [
115
+ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, // JWT
116
+ /\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
117
+ /\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g,
118
+ /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
119
+ /\bhk-[A-Za-z0-9]{16,}/g,
120
+ /\bgh[posru]_[A-Za-z0-9]{20,}/g,
121
+ /\bAIza[0-9A-Za-z_-]{20,}/g,
122
+ /\bAKIA[0-9A-Z]{16}\b/g,
123
+ // Bounded like scrub.ts's: the unbounded form backtracks quadratically on
124
+ // colon-rich text that never reaches an '@'.
125
+ /[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g,
126
+ ]
127
+ var EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g
128
+ function clean(u) {
129
+ if (!u) return u
130
+ if (u.length > 8192) u = u.slice(0, 8192) + '… [truncated]'
131
+ for (var i = 0; i < SECRETS.length; i++) u = u.replace(SECRETS[i], '[redacted]')
132
+ return u.replace(EMAIL, '[email]')
133
+ }
134
+
135
+ // send builds ONE WireEvent — the same shape core.ts build() produces, so the
136
+ // server cannot tell which distribution emitted it.
137
+ function send(kind, event, props) {
138
+ queue.push({
139
+ messageId: uid(),
140
+ type: kind,
141
+ event: event,
142
+ timestamp: new Date().toISOString(),
143
+ distinctId: person || anon,
144
+ anonymousId: anon,
145
+ personId: person || undefined,
146
+ sessionId: sid,
147
+ product: product,
148
+ url: clean(location.href),
149
+ path: clean(location.pathname),
150
+ referrer: clean(document.referrer) || undefined,
151
+ properties: props || undefined,
152
+ library: LIB,
153
+ libraryVersion: VERSION,
154
+ })
155
+ clearTimeout(timer)
156
+ timer = setTimeout(flush, 400)
157
+ }
158
+
159
+ // ── element locator (the autocapture detail) ──────────────────────────────
160
+ // A compact, stable, PII-light descriptor of the element interacted with, so
161
+ // movements read logically: tag, short text, id, data-*, and an ancestor path.
162
+ function locator(el) {
163
+ if (!el || el === document) return null
164
+ var o = {
165
+ tag: el.tagName ? el.tagName.toLowerCase() : '',
166
+ id: el.id || undefined,
167
+ name:
168
+ (el.getAttribute && (el.getAttribute('name') || el.getAttribute('aria-label'))) ||
169
+ undefined,
170
+ }
171
+ var txt = (el.innerText || el.value || '').trim().replace(/\s+/g, ' ').slice(0, 80)
172
+ if (txt) o.text = txt
173
+ // A link target is a URL like any other — a share/invite href carries the
174
+ // same token shapes the page URL does.
175
+ if (el.getAttribute && el.getAttribute('href')) o.href = clean(el.getAttribute('href'))
176
+ if (el.dataset) for (var k in el.dataset) if (k !== 'hz') (o.data = o.data || {})[k] = el.dataset[k]
177
+ var p = [],
178
+ n = el,
179
+ i = 0
180
+ while (n && n.tagName && i++ < 4) {
181
+ var seg = n.tagName.toLowerCase()
182
+ if (n.id) {
183
+ seg += '#' + n.id
184
+ p.unshift(seg)
185
+ break
186
+ }
187
+ if (n.className && typeof n.className === 'string')
188
+ seg += '.' + n.className.trim().split(/\s+/).slice(0, 2).join('.')
189
+ p.unshift(seg)
190
+ n = n.parentElement
191
+ }
192
+ o.sel = p.join('>')
193
+ return o
194
+ }
195
+ function interactive(el) {
196
+ return el && el.closest && el.closest('a,button,[role=button],input,select,textarea,[data-hz],[onclick]')
197
+ }
198
+
199
+ // ── auto pageviews (initial + SPA) ────────────────────────────────────────
200
+ var last = ''
201
+ function page() {
202
+ var k = location.pathname + location.search
203
+ if (k === last) return
204
+ last = k
205
+ send('pageview', '$pageview')
206
+ }
207
+ page()
208
+ ;['pushState', 'replaceState'].forEach(function (m) {
209
+ var o = history[m]
210
+ history[m] = function () {
211
+ var r = o.apply(this, arguments)
212
+ page()
213
+ return r
214
+ }
215
+ })
216
+ addEventListener('popstate', page)
217
+
218
+ // ── autocapture: clicks, outbound, scroll depth, form submits ─────────────
219
+ if (capture) {
220
+ addEventListener(
221
+ 'click',
222
+ function (e) {
223
+ var el = interactive(e.target)
224
+ if (!el) return
225
+ var loc = locator(el)
226
+ send('event', '$click', loc)
227
+ if (el.tagName === 'A' && el.host && el.host !== location.host)
228
+ send('event', '$outbound', { url: clean(el.href), el: loc })
229
+ },
230
+ true,
231
+ )
232
+ addEventListener('submit', function (e) { send('event', '$form', locator(e.target)) }, true)
233
+ var seen = {}
234
+ addEventListener(
235
+ 'scroll',
236
+ function () {
237
+ var d = document.documentElement
238
+ var pct = Math.round(((scrollY + innerHeight) / (d.scrollHeight || 1)) * 100)
239
+ ;[25, 50, 75, 100].forEach(function (m) {
240
+ if (pct >= m && !seen[m]) {
241
+ seen[m] = 1
242
+ send('event', '$scroll', { depth: m })
243
+ }
244
+ })
245
+ },
246
+ { passive: true },
247
+ )
248
+ }
249
+
250
+ // ── core web vitals (best-effort, no dep) ─────────────────────────────────
251
+ var vitals = {}
252
+ try {
253
+ new PerformanceObserver(function (l) {
254
+ l.getEntries().forEach(function (x) { vitals.lcp = Math.round(x.startTime) })
255
+ }).observe({ type: 'largest-contentful-paint', buffered: true })
256
+ new PerformanceObserver(function (l) {
257
+ l.getEntries().forEach(function (x) {
258
+ if (!x.hadRecentInput) vitals.cls = +((vitals.cls || 0) + x.value).toFixed(3)
259
+ })
260
+ }).observe({ type: 'layout-shift', buffered: true })
261
+ } catch (e) {}
262
+ addEventListener('visibilitychange', function () {
263
+ if (document.visibilityState !== 'hidden') return
264
+ if (vitals.lcp != null || vitals.cls != null) send('event', '$vitals', vitals)
265
+ flush()
266
+ })
267
+
268
+ // ── public API (manual funnel/identify) + GA/Meta fan-out ─────────────────
269
+ function assign(a, b) {
270
+ if (b) for (var k in b) a[k] = b[k]
271
+ return a
272
+ }
273
+ window.hanzo = {
274
+ track: function (name, props) { send('event', name, props || undefined) },
275
+ identify: function (id, traits) {
276
+ person = id
277
+ try { localStorage.setItem('hz_uid', id) } catch (e) {}
278
+ send('identify', undefined, traits || undefined)
279
+ },
280
+ page: function (props) {
281
+ last = ''
282
+ page()
283
+ if (props) send('event', 'page_props', props)
284
+ },
285
+ flush: flush,
286
+ }
287
+ var ga = s.getAttribute('data-ga'),
288
+ fb = s.getAttribute('data-fb')
289
+ function load(src) {
290
+ var el = document.createElement('script')
291
+ el.async = true
292
+ el.src = src
293
+ document.head.appendChild(el)
294
+ }
295
+ if (ga) {
296
+ load('https://www.googletagmanager.com/gtag/js?id=' + ga)
297
+ window.dataLayer = window.dataLayer || []
298
+ window.gtag = function () { dataLayer.push(arguments) }
299
+ gtag('js', new Date())
300
+ gtag('config', ga)
301
+ var _t = window.hanzo.track
302
+ window.hanzo.track = function (n, p) {
303
+ _t(n, p)
304
+ try { gtag('event', n, p || {}) } catch (e) {}
305
+ }
306
+ }
307
+ if (fb) {
308
+ !(function (f) {
309
+ if (f.fbq) return
310
+ var n = (f.fbq = function () {
311
+ n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments)
312
+ })
313
+ if (!f._fbq) f._fbq = n
314
+ n.push = n
315
+ n.loaded = !0
316
+ n.version = '2.0'
317
+ n.queue = []
318
+ })(window)
319
+ load('https://connect.facebook.net/en_US/fbevents.js')
320
+ fbq('init', fb)
321
+ fbq('track', 'PageView')
322
+ var _u = window.hanzo.track
323
+ window.hanzo.track = function (n, p) {
324
+ _u(n, p)
325
+ try { fbq('trackCustom', n, p || {}) } catch (e) {}
326
+ }
327
+ }
328
+ })()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/event",
3
- "version": "0.3.6",
3
+ "version": "0.3.9",
4
4
  "description": "Hanzo Event \u2014 the ONE telemetry client. Emits pageview/event/identify/group to the Hanzo Cloud event stream (POST /v1/event), AND reports errors to Sentry as real Sentry envelopes \u2014 the error plane needs a DSN, without one nothing reaches Sentry. First-touch attribution, beacon-on-unload, auto error capture, client-side secret/PII scrubbing, a shared event + goal vocabulary. Subsumes @sentry.",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/",
@@ -17,6 +17,7 @@
17
17
  "files": [
18
18
  "dist",
19
19
  "src",
20
+ "hz.js",
20
21
  "README.md",
21
22
  "TAXONOMY.md"
22
23
  ],
package/src/core.test.ts CHANGED
@@ -181,6 +181,108 @@ describe('Analytics capture', () => {
181
181
  }
182
182
  })
183
183
 
184
+ // Stamping the location on EVERY event multiplied an exposure that used to
185
+ // cost one row per page load: a reset/invite/magic link carries a JWT in the
186
+ // query and an address in `?email=`, so without scrubbing, every click on that
187
+ // page ships both to the warehouse in cleartext. The error plane has always
188
+ // scrubbed its free text; the location field is free text too.
189
+ describe('location scrubbing', () => {
190
+ const withLocation = (href: string, referrer: string, fn: () => void) => {
191
+ const g = globalThis as Record<string, unknown>
192
+ const hadWindow = 'window' in g
193
+ const hadDocument = 'document' in g
194
+ const u = new URL(href)
195
+ g.window = {
196
+ location: { href, pathname: u.pathname, search: u.search },
197
+ addEventListener: () => {},
198
+ }
199
+ g.document = { referrer, visibilityState: 'visible' }
200
+ try {
201
+ fn()
202
+ } finally {
203
+ if (!hadWindow) delete g.window
204
+ if (!hadDocument) delete g.document
205
+ }
206
+ }
207
+
208
+ const SECRET_URL =
209
+ 'https://hanzo.ai/invite/accept?token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.QWxnSWdub3JlZA&email=cfo@acme.com'
210
+
211
+ it('redacts a secret and PII from the url of every event kind', () => {
212
+ withLocation(SECRET_URL, '', () => {
213
+ const a = mk()
214
+ a.capture('$click')
215
+ a.pageview()
216
+ a.flush()
217
+
218
+ // Both kinds, because pageview() reaches the wire through a different
219
+ // branch than autocapture does.
220
+ for (const e of tx.all) {
221
+ expect(e.url).not.toContain('eyJhbGciOiJIUzI1NiJ9')
222
+ expect(e.url).not.toContain('cfo@acme.com')
223
+ expect(e.url).toContain('[redacted]')
224
+ expect(e.url).toContain('[email]')
225
+ // Scrubbed, not dropped — the page is still attributable, which is the
226
+ // whole reason the field is stamped.
227
+ expect(e.url).toContain('https://hanzo.ai/invite/accept')
228
+ }
229
+ })
230
+ })
231
+
232
+ // pageview() used to pass its own `url` through `...extra`, which merges
233
+ // AFTER the field build() reads — so scrubbing only the read would have left
234
+ // the highest-volume event emitting the raw location. The scrub runs on the
235
+ // assembled record precisely so no call site can route around it.
236
+ it('cannot be bypassed by a call site that supplies its own location', () => {
237
+ withLocation(SECRET_URL, '', () => {
238
+ const a = mk()
239
+ a.pageview('/invite/accept?token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.QWxnSWdub3JlZA')
240
+ a.flush()
241
+ const view = tx.all.find((e) => e.type === 'pageview')!
242
+ expect(view.path).not.toContain('eyJhbGciOiJIUzI1NiJ9')
243
+ expect(view.path).toContain('[redacted]')
244
+ })
245
+ })
246
+
247
+ // document.referrer is the previous page's full URL and is stamped on every
248
+ // event, so it leaks the same way the current location does.
249
+ it('redacts the referrer', () => {
250
+ withLocation('https://hanzo.ai/dashboard', SECRET_URL, () => {
251
+ const a = mk()
252
+ a.capture('$click')
253
+ a.flush()
254
+ expect(tx.all[0].referrer).not.toContain('eyJhbGciOiJIUzI1NiJ9')
255
+ expect(tx.all[0].referrer).not.toContain('cfo@acme.com')
256
+ })
257
+ })
258
+
259
+ // capturePII is an explicit opt-in for END-USER identifiers. It is NOT a
260
+ // mode that ships credentials: there is no configuration under which a
261
+ // secret leaves the browser.
262
+ it('still redacts secrets when capturePII is enabled', () => {
263
+ withLocation(SECRET_URL, '', () => {
264
+ const a = mk({ capturePII: true })
265
+ a.capture('$click')
266
+ a.flush()
267
+ expect(tx.all[0].url).not.toContain('eyJhbGciOiJIUzI1NiJ9')
268
+ expect(tx.all[0].url).toContain('[redacted]')
269
+ expect(tx.all[0].url).toContain('cfo@acme.com')
270
+ })
271
+ })
272
+
273
+ // A redactor that mangles ordinary URLs would destroy the analytics it
274
+ // exists to protect, so the common case must pass through byte-for-byte.
275
+ it('leaves an ordinary url untouched', () => {
276
+ withLocation('https://hanzo.ai/pricing?plan=pro&utm_source=x', '', () => {
277
+ const a = mk()
278
+ a.capture('$click')
279
+ a.flush()
280
+ expect(tx.all[0].url).toBe('https://hanzo.ai/pricing?plan=pro&utm_source=x')
281
+ expect(tx.all[0].path).toBe('/pricing')
282
+ })
283
+ })
284
+ })
285
+
184
286
  it('auto-flushes when the batch size is reached', () => {
185
287
  const a = mk({ batchSize: 3 })
186
288
  a.capture('a')
package/src/core.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  } from './attribution'
46
46
  import { dsnForProduct } from './dsn'
47
47
  import { PAGEVIEW } from './events'
48
+ import { scrubText } from './scrub'
48
49
  import {
49
50
  buildEnvelope,
50
51
  buildSentryEvent,
@@ -60,6 +61,7 @@ import {
60
61
  getCohort,
61
62
  mergeCohort,
62
63
  } from './storage'
64
+ import { uuidv7 } from './uid'
63
65
  import type {
64
66
  AnalyticsConfig,
65
67
  Attribution,
@@ -110,12 +112,6 @@ function appendQuery(url: string, key: string, value: string): string {
110
112
  return url + (url.includes('?') ? '&' : '?') + key + '=' + encodeURIComponent(value)
111
113
  }
112
114
 
113
- function uid(): string {
114
- const c = typeof crypto !== 'undefined' ? crypto : undefined
115
- if (c && 'randomUUID' in c) return c.randomUUID()
116
- return 'm-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
117
- }
118
-
119
115
  /** Normalize anything thrown (Error | string | unknown) into an Exception. */
120
116
  /** normalizeError adapts the shared, hostile-input-safe normalizer (sentry.ts) to
121
117
  * the event stream's Exception shape. ONE normalizer serves both planes: a thrown
@@ -311,9 +307,12 @@ export class Analytics {
311
307
 
312
308
  /** pageview records a $pageview for the current (or given) location. */
313
309
  pageview(path?: string, properties?: Record<string, unknown>): void {
314
- const url = isBrowser() ? window.location.href : undefined
310
+ // No `url` here: build() reads window.location.href for every event, in the
311
+ // same tick, so this recomputed it to the identical value. Only `path` is
312
+ // passed, because a route change fires before window.location has caught up
313
+ // and the caller's value has to win.
315
314
  const p = path ?? (isBrowser() ? window.location.pathname : undefined)
316
- this.enqueue('pageview', PAGEVIEW, { url, path: p, properties })
315
+ this.enqueue('pageview', PAGEVIEW, { path: p, properties })
317
316
  }
318
317
 
319
318
  /** capture records a named product event with optional properties. Commerce
@@ -467,8 +466,8 @@ export class Analytics {
467
466
 
468
467
  private build(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): WireEvent {
469
468
  const anon = anonId()
470
- return {
471
- messageId: uid(),
469
+ const wire: WireEvent = {
470
+ messageId: uuidv7(),
472
471
  type: kind,
473
472
  event,
474
473
  timestamp: new Date().toISOString(),
@@ -500,6 +499,32 @@ export class Analytics {
500
499
  libraryVersion: VERSION,
501
500
  ...extra,
502
501
  }
502
+
503
+ // A location is free text an attacker (or an ordinary product flow) controls,
504
+ // and it is now stamped on EVERY event rather than only pageviews — so the
505
+ // one field that is always a URL gets the same policy the error plane has
506
+ // always applied to error text. A password-reset, invite or magic link puts
507
+ // a JWT in the query and an address in `?email=`; without this, one click on
508
+ // that page ships both to the warehouse in cleartext, and every later click
509
+ // repeats it.
510
+ //
511
+ // AFTER `...extra`, deliberately. A call site can pass its own location —
512
+ // pageview() passes `path`, and until this commit it passed `url` too — and
513
+ // `extra` merges over the fields read above, so scrubbing at the read would
514
+ // have left the highest-volume event emitting a raw location while looking
515
+ // scrubbed. Applied to the ASSEMBLED record, the guarantee holds for every
516
+ // call site, including ones not written yet.
517
+ //
518
+ // `scrubText` is the SAME policy as the error plane (secrets always,
519
+ // PII unless capturePII) rather than a second URL-specific redactor: one
520
+ // definition of "must not leave the browser", already tested, mirroring the
521
+ // server's. Guarded on presence so an absent field stays absent instead of
522
+ // becoming the empty string that `host` derivation reads as a page.
523
+ const capturePII = this.cfg.capturePII ?? false
524
+ if (wire.url) wire.url = scrubText(wire.url, capturePII)
525
+ if (wire.path) wire.path = scrubText(wire.path, capturePII)
526
+ if (wire.referrer) wire.referrer = scrubText(wire.referrer, capturePII)
527
+ return wire
503
528
  }
504
529
 
505
530
  private schedule(): void {
package/src/hz.test.ts ADDED
@@ -0,0 +1,96 @@
1
+ // hz.js is the no-build distribution — 300 lines of shipped client that no test
2
+ // had ever executed. It restates, by hand, what the bundled client imports, so the
3
+ // two can drift; this runs the real file against a minimal browser stub and reads
4
+ // the batch it actually posts.
5
+
6
+ import { describe, expect, it, beforeEach } from 'vitest'
7
+ import { readFileSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ const SRC = readFileSync(fileURLToPath(new URL('../hz.js', import.meta.url)), 'utf8')
11
+
12
+ /** The event plane's session-rollup admission gate, transcribed from its own SQL. */
13
+ const versionNibble = (id: string): bigint => (BigInt('0x' + id.replace(/-/g, '')) >> 76n) & 15n
14
+ const embeddedMs = (id: string): bigint => BigInt('0x' + id.replace(/-/g, '')) >> 80n
15
+
16
+ interface WireEvent {
17
+ messageId: string
18
+ sessionId: string
19
+ anonymousId: string
20
+ type: string
21
+ event?: string
22
+ library: string
23
+ libraryVersion: string
24
+ }
25
+
26
+ /** Runs hz.js against a stub browser and returns everything it posted. */
27
+ function runSnippet(): { sent: WireEvent[]; api: { track(n: string): void; flush(): void } } {
28
+ const sent: WireEvent[] = []
29
+ const store = () => {
30
+ const m = new Map<string, string>()
31
+ return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => void m.set(k, v) }
32
+ }
33
+ const g = globalThis as Record<string, unknown>
34
+ g.location = { href: 'https://x.test/p?a=1', pathname: '/p', search: '?a=1', hostname: 'x.test', host: 'x.test' }
35
+ g.document = {
36
+ currentScript: { getAttribute: (a: string) => (a === 'data-product' ? 'test' : null) },
37
+ referrer: '',
38
+ addEventListener: () => {},
39
+ documentElement: { scrollHeight: 1000 },
40
+ visibilityState: 'visible',
41
+ createElement: () => ({}),
42
+ head: { appendChild: () => {} },
43
+ }
44
+ g.navigator = { doNotTrack: '0' }
45
+ g.localStorage = store()
46
+ g.sessionStorage = store()
47
+ g.history = { pushState: () => {}, replaceState: () => {} }
48
+ g.addEventListener = () => {}
49
+ g.PerformanceObserver = undefined
50
+ g.fetch = (_u: string, init: { body: string }) => {
51
+ sent.push(...(JSON.parse(init.body).batch as WireEvent[]))
52
+ return Promise.resolve()
53
+ }
54
+ g.window = g
55
+ new Function(SRC)()
56
+ return { sent, api: (g.window as { hanzo: { track(n: string): void; flush(): void } }).hanzo }
57
+ }
58
+
59
+ describe('hz.js', () => {
60
+ let run: ReturnType<typeof runSnippet>
61
+ beforeEach(() => {
62
+ run = runSnippet()
63
+ })
64
+
65
+ it('mints session ids the plane admits', () => {
66
+ run.api.track('checkout_started')
67
+ run.api.flush()
68
+ expect(run.sent.length).toBeGreaterThan(0)
69
+ const before = Date.now()
70
+ for (const ev of run.sent) {
71
+ expect(versionNibble(ev.sessionId)).toBe(7n)
72
+ expect(versionNibble(ev.messageId)).toBe(7n)
73
+ expect(versionNibble(ev.anonymousId)).toBe(7n)
74
+ // The embedded instant is the real mint time, not a constant.
75
+ expect(Number(embeddedMs(ev.sessionId))).toBeGreaterThan(before - 60_000)
76
+ expect(Number(embeddedMs(ev.sessionId))).toBeLessThanOrEqual(Date.now())
77
+ }
78
+ })
79
+
80
+ it('holds one session id across every event it emits', () => {
81
+ run.api.track('a')
82
+ run.api.track('b')
83
+ run.api.flush()
84
+ const ids = new Set(run.sent.map((e) => e.sessionId))
85
+ expect(ids.size).toBe(1)
86
+ expect(new Set(run.sent.map((e) => e.messageId)).size).toBe(run.sent.length)
87
+ })
88
+
89
+ it('emits the auto pageview on load and stamps the library', () => {
90
+ run.api.flush()
91
+ const pv = run.sent.find((e) => e.type === 'pageview')
92
+ expect(pv).toBeDefined()
93
+ expect(pv!.library).toBe('hz.js')
94
+ expect(pv!.libraryVersion).toMatch(/^\d+\.\d+\.\d+$/)
95
+ })
96
+ })
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  export { Analytics, createAnalytics, VERSION, getCohort, getFirstTouch } from './core'
11
11
  export { parseDsn, buildSentryEvent, buildEnvelope, framesFromStack } from './sentry'
12
+ export { uuidv7, uuidv7Time } from './uid'
12
13
  export { PRODUCT_DSN, dsnForProduct } from './dsn'
13
14
  export type { ErrorIdentity } from './sentry'
14
15
  export { scrubText, redactSecrets, scrubPII } from './scrub'
package/src/sentry.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // normalizeEvent, computeFingerprint). No upstream (FSL) code is used.
9
9
 
10
10
  import { scrubText } from './scrub'
11
+ import { uuidv7 } from './uid'
11
12
  import { VERSION } from './version'
12
13
  import type {
13
14
  CaptureErrorOptions,
@@ -28,13 +29,10 @@ const MAX_TAG_LEN = 1024
28
29
  /** Max tags copied from properties. */
29
30
  const MAX_TAGS = 50
30
31
 
31
- /** eventId mints a 32-hex-char id (no dashes) — the Sentry event_id shape. */
32
+ /** eventId mints a 32-hex-char id (no dashes) — the Sentry event_id shape. Same
33
+ * minter as everything else, just formatted for Sentry's wire. */
32
34
  export function eventId(): string {
33
- const c = typeof crypto !== 'undefined' ? crypto : undefined
34
- if (c && 'randomUUID' in c) return c.randomUUID().replace(/-/g, '')
35
- let s = ''
36
- for (let i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16)
37
- return s
35
+ return uuidv7().replace(/-/g, '')
38
36
  }
39
37
 
40
38
  /** byteLen returns the UTF-8 byte length used for envelope item framing. */