@hanzo/event 0.3.8 → 0.3.10

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/src/core.test.ts CHANGED
@@ -356,6 +356,40 @@ describe('Analytics capture', () => {
356
356
  expect(tx.sent[0].url).toBe('/v1/event')
357
357
  })
358
358
 
359
+ it('reads the ingest key from the build env when config omits it', () => {
360
+ // The failure this closes is silent: a surface with no key attributes nothing
361
+ // for a logged-out visitor, the door refuses the write, and the page shows no
362
+ // sign of it. The key must resolve from the env exactly as the DSN does.
363
+ process.env.NEXT_PUBLIC_HANZO_EVENT_KEY = 'pk-live-from-env'
364
+ try {
365
+ const a = mk() // no key in config
366
+ a.capture('x')
367
+ a.flush(true)
368
+ expect(tx.sent[0].ingestKey).toBe('pk-live-from-env')
369
+ } finally {
370
+ delete process.env.NEXT_PUBLIC_HANZO_EVENT_KEY
371
+ }
372
+ })
373
+
374
+ it('prefers an explicit ingest key over the build env', () => {
375
+ process.env.NEXT_PUBLIC_HANZO_EVENT_KEY = 'pk-live-from-env'
376
+ try {
377
+ const a = mk({ ingestKey: 'pk-live-explicit' })
378
+ a.capture('x')
379
+ a.flush(true)
380
+ expect(tx.sent[0].ingestKey).toBe('pk-live-explicit')
381
+ } finally {
382
+ delete process.env.NEXT_PUBLIC_HANZO_EVENT_KEY
383
+ }
384
+ })
385
+
386
+ it('stays keyless when neither config nor env names a key', () => {
387
+ const a = mk()
388
+ a.capture('x')
389
+ a.flush(true)
390
+ expect(tx.sent[0].ingestKey).toBeUndefined()
391
+ })
392
+
359
393
  it('setCohort rides subsequent events', () => {
360
394
  const a = mk()
361
395
  a.setCohort({ signupWeek: '2026-W29', channel: 'paid', refCode: 'REF9' })
package/src/core.ts CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  getCohort,
62
62
  mergeCohort,
63
63
  } from './storage'
64
+ import { uuidv7 } from './uid'
64
65
  import type {
65
66
  AnalyticsConfig,
66
67
  Attribution,
@@ -111,12 +112,6 @@ function appendQuery(url: string, key: string, value: string): string {
111
112
  return url + (url.includes('?') ? '&' : '?') + key + '=' + encodeURIComponent(value)
112
113
  }
113
114
 
114
- function uid(): string {
115
- const c = typeof crypto !== 'undefined' ? crypto : undefined
116
- if (c && 'randomUUID' in c) return c.randomUUID()
117
- return 'm-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
118
- }
119
-
120
115
  /** Normalize anything thrown (Error | string | unknown) into an Exception. */
121
116
  /** normalizeError adapts the shared, hostile-input-safe normalizer (sentry.ts) to
122
117
  * the event stream's Exception shape. ONE normalizer serves both planes: a thrown
@@ -236,6 +231,15 @@ export class Analytics {
236
231
  enabled: true,
237
232
  captureErrors: true,
238
233
  ...config,
234
+ // The publishable key resolves the SAME way the DSN below does: an explicit
235
+ // config wins, else the inlined build-time env. Without this the key was the
236
+ // one piece of wiring a surface could not declare the way it declares every
237
+ // other piece, so every surface that shipped without passing it in code sent
238
+ // its beacons unattributed — and an unattributed write is refused (401
239
+ // ingest_key_required), which is silent in the page and invisible until you
240
+ // read the warehouse and find the host missing entirely.
241
+ ingestKey:
242
+ config.ingestKey ?? readEnv('NEXT_PUBLIC_HANZO_EVENT_KEY') ?? readEnv('HANZO_EVENT_KEY'),
239
243
  }
240
244
  this.transport = config.transport ?? new DefaultTransport()
241
245
  // Error plane, most specific source first: an explicit DSN wins, then the
@@ -472,7 +476,7 @@ export class Analytics {
472
476
  private build(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): WireEvent {
473
477
  const anon = anonId()
474
478
  const wire: WireEvent = {
475
- messageId: uid(),
479
+ messageId: uuidv7(),
476
480
  type: kind,
477
481
  event,
478
482
  timestamp: new Date().toISOString(),
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. */
package/src/storage.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // client imports cleanly in a Next.js server component.
4
4
 
5
5
  import type { Attribution, Cohort } from './types'
6
+ import { uuidv7 } from './uid'
6
7
 
7
8
  const KEY = {
8
9
  anon: 'hz_anon_id',
@@ -23,19 +24,13 @@ function ls(): Storage | undefined {
23
24
  }
24
25
  }
25
26
 
26
- function uid(): string {
27
- const c = typeof crypto !== 'undefined' ? crypto : undefined
28
- if (c && 'randomUUID' in c) return c.randomUUID()
29
- return 'a-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
30
- }
31
-
32
27
  /** Stable anonymous id, minted once per browser and reused across sessions. */
33
28
  export function anonId(): string | undefined {
34
29
  const s = ls()
35
30
  if (!s) return undefined
36
31
  let v = s.getItem(KEY.anon)
37
32
  if (!v) {
38
- v = uid()
33
+ v = uuidv7()
39
34
  s.setItem(KEY.anon, v)
40
35
  }
41
36
  return v
@@ -46,7 +41,14 @@ interface SessionState {
46
41
  last: number
47
42
  }
48
43
 
49
- /** Current session id, rotated after SESSION_TTL_MS of inactivity. */
44
+ /**
45
+ * Current session id, rotated after SESSION_TTL_MS of inactivity.
46
+ *
47
+ * Minted at `now`, so the v7 timestamp the id carries IS the session's start
48
+ * instant — which is what the session rollups partition and order on. Passing the
49
+ * caller's clock rather than reading Date.now() again keeps the id's embedded time
50
+ * and the recorded `last` from disagreeing.
51
+ */
50
52
  export function sessionId(now = Date.now()): string | undefined {
51
53
  const s = ls()
52
54
  if (!s) return undefined
@@ -57,7 +59,7 @@ export function sessionId(now = Date.now()): string | undefined {
57
59
  state = null
58
60
  }
59
61
  if (!state || now - state.last > SESSION_TTL_MS) {
60
- state = { id: uid(), last: now }
62
+ state = { id: uuidv7(now), last: now }
61
63
  } else {
62
64
  state.last = now
63
65
  }
package/src/types.ts CHANGED
@@ -112,14 +112,26 @@ export interface AnalyticsConfig {
112
112
  /** Bearer token provider for token-auth apps. Omit for cookie/session apps
113
113
  * (the client then relies on same-origin credentials). */
114
114
  getToken?: () => string | undefined | null
115
- /** Publishable ingest key (pk_…). When set, the client authenticates to the ONE
116
- * front door `/v1/event` with this key instead of a bearer/cookie: it rides
117
- * Authorization: Bearer pk_… on fetch and ?ingest_key=pk_… on a headerless
118
- * page-unload beacon, so anonymous traffic is accepted and unload beacons work
119
- * without a bearer. The key is write-only (cannot read) and safe to ship in a
120
- * bundle; mint one per org via POST /v1/ingest/keys. This authenticates the
121
- * EVENT STREAM only — the error plane authenticates independently with `dsn`,
122
- * and one does not stand in for the other. */
115
+ /** Publishable ingest key (pk-…). When set, the client attributes writes to the
116
+ * ONE front door `/v1/event` with this key instead of a bearer/cookie: it rides
117
+ * Authorization: Bearer pk-… on fetch and ?ingest_key=pk-… on a headerless
118
+ * page-unload beacon, so ANONYMOUS traffic is attributed and unload beacons work
119
+ * without a bearer. The key is write-only it attributes a write and never mints
120
+ * a reading principal so it is safe to ship in a bundle. Mint one per org with
121
+ * POST /v1/keys {"type":"publishable"}.
122
+ *
123
+ * Omit it and the client reads NEXT_PUBLIC_HANZO_EVENT_KEY, then HANZO_EVENT_KEY,
124
+ * from the inlined build env — the same resolution `dsn` uses, so a surface
125
+ * declares BOTH planes the same way and neither needs code to switch on.
126
+ *
127
+ * A surface with no key at all still reports for whoever is SIGNED IN (the
128
+ * session credential attributes them), and drops every logged-out visitor: the
129
+ * door refuses an unattributable write rather than filing it where its owner
130
+ * cannot read it. That failure is invisible from the page, which is why the key
131
+ * belongs in the env next to the DSN and not in a checklist.
132
+ *
133
+ * This attributes the EVENT STREAM only — the error plane authenticates
134
+ * independently with `dsn`, and one does not stand in for the other. */
123
135
  ingestKey?: string
124
136
  /** Max events buffered before an automatic flush. */
125
137
  batchSize?: number
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, afterEach } from 'vitest'
2
+ import { uuidv7, uuidv7Time } from './uid'
3
+ import { eventId } from './sentry'
4
+
5
+ /** The event plane's admission gate, transcribed from the session rollup's own SQL:
6
+ * `bitAnd(bitShiftRight(toUInt128(accurateCastOrNull(id,'UUID')), 76), 15) = 7`.
7
+ * An id that fails this is dropped by the materialized view without an error. */
8
+ const versionNibble = (id: string): bigint => (BigInt('0x' + id.replace(/-/g, '')) >> 76n) & 15n
9
+
10
+ /** The session start the rollup partitions and orders on:
11
+ * `fromUnixTimestamp(intDiv(toUInt64(bitShiftRight(session_id_v7, 80)), 1000))`. */
12
+ const embeddedMs = (id: string): bigint => BigInt('0x' + id.replace(/-/g, '')) >> 80n
13
+
14
+ /** RFC 9562 variant: the two high bits of octet 8 are 0b10. */
15
+ const variantBits = (id: string): bigint => (BigInt('0x' + id.replace(/-/g, '')) >> 62n) & 3n
16
+
17
+ const SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
18
+
19
+ describe('uuidv7', () => {
20
+ const realCrypto = globalThis.crypto
21
+ afterEach(() => {
22
+ Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true })
23
+ })
24
+
25
+ it('passes the plane admission gate that v4 fails', () => {
26
+ for (let i = 0; i < 200; i++) expect(versionNibble(uuidv7())).toBe(7n)
27
+ // The gate is real: crypto.randomUUID (v4) is what the SDK used to mint.
28
+ expect(versionNibble(crypto.randomUUID())).toBe(4n)
29
+ })
30
+
31
+ it('carries the mint instant the rollup keys on', () => {
32
+ for (const ms of [0, 1, 1_000, Date.now(), 253_402_300_799_000]) {
33
+ const id = uuidv7(ms)
34
+ expect(embeddedMs(id)).toBe(BigInt(ms))
35
+ expect(uuidv7Time(id)).toBe(ms)
36
+ }
37
+ })
38
+
39
+ it('sets the RFC 9562 variant', () => {
40
+ for (let i = 0; i < 50; i++) expect(variantBits(uuidv7())).toBe(2n)
41
+ })
42
+
43
+ it('sorts lexically by time', () => {
44
+ const t = Date.now()
45
+ const ids = [t + 3000, t, t + 1000, t + 2000].map((ms) => uuidv7(ms))
46
+ expect([...ids].sort()).toEqual([ids[1], ids[2], ids[3], ids[0]])
47
+ })
48
+
49
+ it('is unique within a single millisecond', () => {
50
+ const ms = Date.now()
51
+ const seen = new Set(Array.from({ length: 5000 }, () => uuidv7(ms)))
52
+ expect(seen.size).toBe(5000)
53
+ })
54
+
55
+ it('keeps a valid v7 shape with no crypto at all', () => {
56
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
57
+ const id = uuidv7(1_700_000_000_000)
58
+ // The old fallback emitted 'a-<base36>', which the plane casts to NULL and drops
59
+ // by the same gate v4 fails. Only entropy may degrade — never the shape.
60
+ expect(id).toMatch(SHAPE)
61
+ expect(versionNibble(id)).toBe(7n)
62
+ expect(embeddedMs(id)).toBe(1_700_000_000_000n)
63
+ })
64
+ })
65
+
66
+ describe('eventId', () => {
67
+ it('is the one minter formatted for the Sentry wire', () => {
68
+ const id = eventId()
69
+ expect(id).toMatch(/^[0-9a-f]{32}$/)
70
+ const dashed = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`
71
+ expect(versionNibble(dashed)).toBe(7n)
72
+ })
73
+ })
package/src/uid.ts ADDED
@@ -0,0 +1,70 @@
1
+ // The ONE id minter for this client — UUIDv7 (RFC 9562 §5.7).
2
+ //
3
+ // WHY NOT crypto.randomUUID(): it mints v4, whose 122 bits are pure entropy and
4
+ // carry no time. The session rollups on the event plane derive a session's start
5
+ // instant FROM THE ID — their PARTITION BY and ORDER BY are
6
+ // `fromUnixTimestamp(intDiv(toUInt64(bitShiftRight(session_id_v7, 80)), 1000))` —
7
+ // so they admit only ids whose version nibble is 7
8
+ // (`bitAnd(bitShiftRight(toUInt128(session_id), 76), 15) = 7`). A v4 session id is
9
+ // not merely unordered there: it is DISCARDED at the door, silently, and the
10
+ // rollup stays empty forever. Minting v7 is the whole reason those rollups can
11
+ // exist; it also clusters index writes by time instead of scattering them across
12
+ // the keyspace.
13
+ //
14
+ // Layout, 16 bytes big-endian:
15
+ // 0..5 unix_ts_ms — 48-bit millisecond timestamp
16
+ // 6 0111 (version 7) in the high nibble | rand_a
17
+ // 7 rand_a
18
+ // 8 10 (variant) in the high 2 bits | rand_b
19
+ // 9..15 rand_b
20
+ //
21
+ // Never returns a non-UUID shape. The old minters fell back to `'a-' + base36`
22
+ // when crypto was absent, and the plane casts a session id with
23
+ // accurateCastOrNull(…, 'UUID') — a shape that does not parse becomes NULL and is
24
+ // dropped by the same gate, so the fallback failed exactly like v4 did. Here only
25
+ // the ENTROPY degrades without crypto; the shape is always a valid v7 UUID.
26
+
27
+ /** 00..ff, so formatting is a lookup rather than 16 padStart calls. */
28
+ const HEX: string[] = Array.from({ length: 256 }, (_, i) => (i + 0x100).toString(16).slice(1))
29
+
30
+ /** Cryptographic randomness when the host has it, Math.random when it does not. */
31
+ function fill(b: Uint8Array): Uint8Array {
32
+ const c = typeof crypto !== 'undefined' ? crypto : undefined
33
+ if (c && typeof c.getRandomValues === 'function') {
34
+ c.getRandomValues(b)
35
+ return b
36
+ }
37
+ for (let i = 0; i < b.length; i++) b[i] = (Math.random() * 256) | 0
38
+ return b
39
+ }
40
+
41
+ /**
42
+ * uuidv7 mints a time-ordered UUIDv7 for `now` (epoch milliseconds).
43
+ *
44
+ * Two ids minted in the same millisecond sort arbitrarily between themselves; ids
45
+ * from different milliseconds sort by time, lexically and numerically alike.
46
+ */
47
+ export function uuidv7(now: number = Date.now()): string {
48
+ const b = fill(new Uint8Array(16))
49
+ // 48-bit big-endian timestamp. Milliseconds stay exact well past year 10000, so
50
+ // the arithmetic never leaves the safe-integer range.
51
+ let t = Math.floor(now)
52
+ for (let i = 5; i >= 0; i--) {
53
+ b[i] = t % 256
54
+ t = Math.floor(t / 256)
55
+ }
56
+ b[6] = 0x70 | (b[6] & 0x0f) // version 7
57
+ b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
58
+ return (
59
+ HEX[b[0]] + HEX[b[1]] + HEX[b[2]] + HEX[b[3]] + '-' +
60
+ HEX[b[4]] + HEX[b[5]] + '-' +
61
+ HEX[b[6]] + HEX[b[7]] + '-' +
62
+ HEX[b[8]] + HEX[b[9]] + '-' +
63
+ HEX[b[10]] + HEX[b[11]] + HEX[b[12]] + HEX[b[13]] + HEX[b[14]] + HEX[b[15]]
64
+ )
65
+ }
66
+
67
+ /** The millisecond timestamp a v7 id was minted at — the inverse of uuidv7. */
68
+ export function uuidv7Time(id: string): number {
69
+ return parseInt(id.slice(0, 8) + id.slice(9, 13), 16)
70
+ }
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  // The library version, stamped on every event (`libraryVersion`) and on the
2
2
  // Sentry `sdk` block. It lives alone so `sentry.ts` can read it without importing
3
3
  // `core.ts` — core imports sentry, so the reverse would be an import cycle.
4
- export const VERSION = '0.3.8'
4
+ export const VERSION = '0.3.9'