@hanzo/event 0.3.14 → 0.3.16

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/hz.test.ts CHANGED
@@ -42,6 +42,9 @@ interface StubOptions {
42
42
  navigator?: Record<string, unknown>
43
43
  /** Seed localStorage (e.g. an explicit hz_consent choice). */
44
44
  storage?: Record<string, string>
45
+ /** The cookie jar. Pass one in to model a browser that already carries an id —
46
+ * from another *.hanzo.ai surface, or from the npm client on this same page. */
47
+ jar?: Map<string, string>
45
48
  /** Let navigator.sendBeacon succeed, so the beacon path is the one measured. */
46
49
  beacon?: boolean
47
50
  }
@@ -54,16 +57,20 @@ type Api = { track(n: string, p?: unknown): void; flush(): void }
54
57
  * descriptor is an accessor with no setter, so the plain assignment this
55
58
  * harness used threw — and every hz.js test failed on a current runtime,
56
59
  * leaving the shipped file with no executed coverage again. */
57
- function runSnippet(opts: StubOptions = {}): { posts: Post[]; api: Api | undefined } {
60
+ function runSnippet(opts: StubOptions = {}): {
61
+ posts: Post[]
62
+ api: Api | undefined
63
+ local: Map<string, string>
64
+ jar: Map<string, string>
65
+ } {
58
66
  const posts: Post[] = []
59
- const store = (seed: Record<string, string> = {}) => {
60
- const m = new Map<string, string>(Object.entries(seed))
61
- return {
62
- getItem: (k: string) => m.get(k) ?? null,
63
- setItem: (k: string, v: string) => void m.set(k, v),
64
- removeItem: (k: string) => void m.delete(k),
65
- }
66
- }
67
+ const local = new Map<string, string>(Object.entries(opts.storage ?? {}))
68
+ const jar = opts.jar ?? new Map<string, string>()
69
+ const store = (m: Map<string, string>) => ({
70
+ getItem: (k: string) => m.get(k) ?? null,
71
+ setItem: (k: string, v: string) => void m.set(k, v),
72
+ removeItem: (k: string) => void m.delete(k),
73
+ })
67
74
  const attrs: Record<string, string> = { 'data-product': 'test', ...opts.attrs }
68
75
  const g = globalThis as unknown as Record<string, unknown>
69
76
  const define = (name: string, value: unknown) =>
@@ -78,6 +85,16 @@ function runSnippet(opts: StubOptions = {}): { posts: Post[]; api: Api | undefin
78
85
  })
79
86
  define('document', {
80
87
  currentScript: { getAttribute: (a: string) => attrs[a] ?? null },
88
+ // A real jar: the anonymous id is a cookie now, so a stub with no `cookie`
89
+ // would leave the whole identity chain unexecuted by these tests.
90
+ get cookie(): string {
91
+ return [...jar].map(([k, v]) => `${k}=${v}`).join('; ')
92
+ },
93
+ set cookie(raw: string) {
94
+ const first = raw.split(';')[0]
95
+ const eq = first.indexOf('=')
96
+ if (eq > 0) jar.set(first.slice(0, eq).trim(), first.slice(eq + 1).trim())
97
+ },
81
98
  referrer: '',
82
99
  addEventListener: () => {},
83
100
  documentElement: { scrollHeight: 1000 },
@@ -104,8 +121,8 @@ function runSnippet(opts: StubOptions = {}): { posts: Post[]; api: Api | undefin
104
121
  }
105
122
  },
106
123
  )
107
- define('localStorage', store(opts.storage))
108
- define('sessionStorage', store())
124
+ define('localStorage', store(local))
125
+ define('sessionStorage', store(new Map()))
109
126
  define('history', { pushState: () => {}, replaceState: () => {} })
110
127
  define('addEventListener', () => {})
111
128
  define('PerformanceObserver', undefined)
@@ -122,7 +139,7 @@ function runSnippet(opts: StubOptions = {}): { posts: Post[]; api: Api | undefin
122
139
  define('window', g)
123
140
 
124
141
  new Function(SRC)()
125
- return { posts, api: (g.window as { hanzo?: Api }).hanzo }
142
+ return { posts, api: (g.window as { hanzo?: Api }).hanzo, local, jar }
126
143
  }
127
144
 
128
145
  /** Every event across every transmission, in order. */
@@ -170,6 +187,44 @@ describe('hz.js', () => {
170
187
  expect(pv!.libraryVersion).toBe(PKG.version)
171
188
  })
172
189
 
190
+ // ── identity ──────────────────────────────────────────────────────────────
191
+ // This file used to mint into `hz_id`, a key nothing else read or wrote, so a
192
+ // page carrying both this tag and the npm client sent two anonymous ids for one
193
+ // visitor — and every surface counted them as two people. It now runs the same
194
+ // chain, from the same file, against the same key.
195
+
196
+ const SEEDED = '01920000-0000-7000-8000-0000000000cc'
197
+ const LEGACY = '01920000-0000-7000-8000-0000000000dd'
198
+
199
+ it('is the same person as every other Hanzo client on the browser', () => {
200
+ // The cookie the npm client (or another *.hanzo.ai surface) already wrote.
201
+ const r = runSnippet({ jar: new Map([['hz_anon_id', SEEDED]]) })
202
+ r.api!.track('checkout_started')
203
+ r.api!.flush()
204
+ const sent = sentOf(r)
205
+ expect(sent.length).toBeGreaterThan(0)
206
+ for (const ev of sent) expect(ev.anonymousId).toBe(SEEDED)
207
+ })
208
+
209
+ it('adopts the `hz_id` it used to mint rather than making a stranger', () => {
210
+ // Every browser that has ever loaded this tag holds one of these. Minting
211
+ // over it would detach a returning visitor from their own history.
212
+ const r = runSnippet({ storage: { hz_id: LEGACY } })
213
+ r.api!.flush()
214
+ for (const ev of sentOf(r)) expect(ev.anonymousId).toBe(LEGACY)
215
+ expect(r.jar.get('hz_anon_id')).toBe(LEGACY) // carried onto the shared key
216
+ expect(r.local.get('hz_anon_id')).toBe(LEGACY)
217
+ })
218
+
219
+ it('writes the one key, in the durable place, and no longer mints its own', () => {
220
+ const r = runSnippet()
221
+ r.api!.flush()
222
+ const id = sentOf(r)[0].anonymousId
223
+ expect(r.jar.get('hz_anon_id')).toBe(id) // the cookie outlives the ORIGIN
224
+ expect(r.local.get('hz_anon_id')).toBe(id)
225
+ expect(r.local.has('hz_id')).toBe(false)
226
+ })
227
+
173
228
  // ── the publishable key ───────────────────────────────────────────────────
174
229
  // Through 0.3.11 this file could present none at all: no header, no query. A
175
230
  // keyed static surface therefore sent UNATTRIBUTED writes, which the door
@@ -113,6 +113,34 @@ describe('anonId', () => {
113
113
  }
114
114
  })
115
115
 
116
+ it("adopts hz.js's `hz_id` when there is no canonical id to find", async () => {
117
+ // The no-build tag minted into a key of its own, so a browser that met hz.js
118
+ // first already carries an identity — under a different name. Minting here
119
+ // would make that visitor a stranger the moment they reach a bundled surface,
120
+ // which is precisely the split this migration closes.
121
+ const b = browser({ storage: { hz_id: LEGACY } })
122
+ try {
123
+ const { anonId } = await load()
124
+ expect(anonId()).toBe(LEGACY)
125
+ expect(b.jar.get(ANON)).toBe(LEGACY)
126
+ expect(b.store!.get(ANON)).toBe(LEGACY) // and converged onto the one key
127
+ } finally {
128
+ b.restore()
129
+ }
130
+ })
131
+
132
+ it('prefers the canonical id when a divergent hz_id is also present', async () => {
133
+ // Both clients ran on this origin before the merge and counted the visitor
134
+ // twice. One of the two has to win, and it is the key everything else uses.
135
+ const b = browser({ storage: { [ANON]: LEGACY, hz_id: OTHER } })
136
+ try {
137
+ const { anonId } = await load()
138
+ expect(anonId()).toBe(LEGACY)
139
+ } finally {
140
+ b.restore()
141
+ }
142
+ })
143
+
116
144
  it('lets the cookie win over a divergent origin-local id', async () => {
117
145
  // docs already minted its own before the migration; the shared cookie is now
118
146
  // the source of truth and localStorage converges onto it.
package/src/storage.ts CHANGED
@@ -2,14 +2,15 @@
2
2
  // accessor no-ops (returns undefined) when there is no window/localStorage, so the
3
3
  // client imports cleanly in a Next.js server component.
4
4
  //
5
- // The anonymous id is the one value that must outlive an ORIGIN, so it lives in a
6
- // cookie on the registrable domain; everything else here stays origin-local.
5
+ // The anonymous id is the one value that must outlive an ORIGIN, and it is also
6
+ // the one value the OTHER two distributions must agree with, so its chain lives in
7
+ // ./anon.js and this file only calls it. Everything else here stays origin-local.
7
8
 
9
+ import { hzAnonId } from './anon.js'
8
10
  import type { Attribution, Cohort } from './types'
9
11
  import { uuidv7 } from './uid'
10
12
 
11
13
  const KEY = {
12
- anon: 'hz_anon_id',
13
14
  session: 'hz_session',
14
15
  firstTouch: 'hz_first_touch',
15
16
  cohort: 'hz_cohort',
@@ -18,14 +19,6 @@ const KEY = {
18
19
  /** 30-minute inactivity window defines a session (PostHog/GA convention). */
19
20
  const SESSION_TTL_MS = 30 * 60 * 1000
20
21
 
21
- /** The registrable domain the anon cookie is scoped to, so docs, cloud, console,
22
- * studio, pay, id and www all read the ONE id. */
23
- const ANON_DOMAIN = 'hanzo.ai'
24
-
25
- /** Two years, refreshed on every read — the cookie rolls forward with the visitor
26
- * instead of expiring a fixed two years after first touch. */
27
- const ANON_MAX_AGE_S = 2 * 365 * 24 * 60 * 60
28
-
29
22
  function ls(): Storage | undefined {
30
23
  try {
31
24
  if (typeof window === 'undefined' || !window.localStorage) return undefined
@@ -35,98 +28,20 @@ function ls(): Storage | undefined {
35
28
  }
36
29
  }
37
30
 
38
- /** The cookie jar, or undefined wherever there is no document to read one from. */
39
- function jar(): Document | undefined {
40
- try {
41
- if (typeof document === 'undefined' || typeof document.cookie !== 'string') return undefined
42
- return document
43
- } catch {
44
- return undefined // sandboxed frame with an opaque origin
45
- }
46
- }
47
-
48
- function getCookie(name: string): string | undefined {
49
- const d = jar()
50
- if (!d) return undefined
51
- for (const part of d.cookie.split(';')) {
52
- const eq = part.indexOf('=')
53
- if (eq < 0 || part.slice(0, eq).trim() !== name) continue
54
- const v = part.slice(eq + 1).trim()
55
- if (!v) continue
56
- try {
57
- return decodeURIComponent(v)
58
- } catch {
59
- return v // not percent-encoded — take it as written
60
- }
61
- }
62
- return undefined
63
- }
64
-
65
- function setCookie(name: string, value: string): void {
66
- const d = jar()
67
- if (!d) return
68
- let host = ''
69
- let secure = false
70
- try {
71
- if (typeof window !== 'undefined' && window.location) {
72
- host = window.location.hostname || ''
73
- // A Secure cookie is refused outright by a non-secure origin, which would
74
- // strand http://localhost dev on the localStorage path.
75
- secure = window.location.protocol === 'https:'
76
- }
77
- } catch {
78
- /* location unreachable — write a host-only, non-secure cookie */
79
- }
80
- const attrs = [
81
- // encodeURIComponent leaves a UUID byte-identical while making any value that
82
- // is not one unable to forge a `;` and inject an attribute.
83
- `${name}=${encodeURIComponent(value)}`,
84
- 'Path=/',
85
- `Max-Age=${ANON_MAX_AGE_S}`,
86
- 'SameSite=Lax',
87
- ]
88
- // Off hanzo.ai (localhost, previews, other registrable domains) the attribute
89
- // would be rejected and the cookie dropped, so the cookie stays host-only there.
90
- if (host === ANON_DOMAIN || host.endsWith(`.${ANON_DOMAIN}`)) attrs.push(`Domain=${ANON_DOMAIN}`)
91
- if (secure) attrs.push('Secure')
92
- try {
93
- d.cookie = attrs.join('; ')
94
- } catch {
95
- /* cookies refused — localStorage still carries the id */
96
- }
97
- }
98
-
99
- /** Last resort for a browser that refuses cookies AND localStorage: without it
100
- * every event in a page load would mint its own id. */
101
- let memAnon: string | undefined
102
-
103
31
  /**
104
- * Stable anonymous id, shared by every *.hanzo.ai surface.
105
- *
106
- * It lives in a first-party cookie on the registrable domain because
107
- * localStorage is ORIGIN-scoped: docs, cloud and console each minted their own
108
- * id for the same person, so one marketing → docs → signup → checkout journey
109
- * arrived as several strangers — 463 anonymous identities carried 545 events in
110
- * a week, about 1.2 events each.
32
+ * Stable anonymous id, shared by every *.hanzo.ai surface AND by every Hanzo
33
+ * client on the page — the npm client, hz.js and the hosted tag all run the one
34
+ * chain in ./anon.js, so which snippet a surface loaded no longer decides who the
35
+ * visitor is.
111
36
  *
112
- * Resolution is strictly ADDITIVE: cookie, else the localStorage id this package
113
- * has always written (ADOPTED into the cookie, never minted over minting there
114
- * would reset every returning visitor and detach their history), else mint.
115
- * localStorage keeps being written, so a rollback finds everyone where it left
116
- * them.
37
+ * It lives in a first-party cookie on the registrable domain because localStorage
38
+ * is ORIGIN-scoped: docs, cloud and console each minted their own id for the same
39
+ * person, so one marketing docs signup checkout journey arrived as several
40
+ * strangers 463 anonymous identities carried 545 events in a week, about 1.2
41
+ * events each.
117
42
  */
118
43
  export function anonId(): string | undefined {
119
- if (typeof window === 'undefined') return undefined // SSR / prerender
120
- const s = ls()
121
- const id = getCookie(KEY.anon) || s?.getItem(KEY.anon) || memAnon || uuidv7()
122
- memAnon = id
123
- setCookie(KEY.anon, id)
124
- try {
125
- if (s && s.getItem(KEY.anon) !== id) s.setItem(KEY.anon, id)
126
- } catch {
127
- /* quota exhausted, or a private-mode jar that reads but refuses writes */
128
- }
129
- return id
44
+ return hzAnonId() || undefined // '' during SSR / prerender
130
45
  }
131
46
 
132
47
  interface SessionState {
package/src/uid.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  // The ONE id minter for this client — UUIDv7 (RFC 9562 §5.7).
2
2
  //
3
+ // The implementation is `hzUuidv7` in ./anon.js and this file only re-exports it.
4
+ // It lives there because the anonymous-id chain has to mint too, and that chain is
5
+ // inlined verbatim by two distributions that have no bundler (hz.js, and the tag
6
+ // the door hosts) — a minter here as well would be a second implementation, and
7
+ // the version nibble it produces is exactly the thing that must never diverge.
8
+ //
3
9
  // WHY NOT crypto.randomUUID(): it mints v4, whose 122 bits are pure entropy and
4
10
  // carry no time. The session rollups on the event plane derive a session's start
5
11
  // instant FROM THE ID — their PARTITION BY and ORDER BY are
@@ -21,22 +27,8 @@
21
27
  // Never returns a non-UUID shape. The old minters fell back to `'a-' + base36`
22
28
  // when crypto was absent, and the plane casts a session id with
23
29
  // 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
- }
30
+ // dropped by the same gate, so the fallback failed exactly like v4 did. Only the
31
+ // ENTROPY degrades without crypto; the shape is always a valid v7 UUID.
40
32
 
41
33
  /**
42
34
  * uuidv7 mints a time-ordered UUIDv7 for `now` (epoch milliseconds).
@@ -44,25 +36,7 @@ function fill(b: Uint8Array): Uint8Array {
44
36
  * Two ids minted in the same millisecond sort arbitrarily between themselves; ids
45
37
  * from different milliseconds sort by time, lexically and numerically alike.
46
38
  */
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
- }
39
+ export { hzUuidv7 as uuidv7 } from './anon.js'
66
40
 
67
41
  /** The millisecond timestamp a v7 id was minted at — the inverse of uuidv7. */
68
42
  export function uuidv7Time(id: string): number {
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.14'
4
+ export const VERSION = '0.3.16'