@hanzo/event 0.3.13 → 0.3.15
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/LICENSE.md +21 -0
- package/dist/index.cjs +124 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.mjs +124 -30
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +121 -29
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +121 -29
- package/dist/react.mjs.map +1 -1
- package/hz.js +188 -27
- package/package.json +10 -10
- package/src/anon.d.ts +14 -0
- package/src/anon.js +199 -0
- package/src/anon.test.ts +80 -0
- package/src/hz.test.ts +67 -12
- package/src/storage.test.ts +285 -0
- package/src/storage.ts +18 -10
- package/src/uid.ts +9 -35
- package/src/version.ts +1 -1
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 = {}): {
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
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
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// The anonymous id is the join key for the whole pre-signup journey, and it used
|
|
2
|
+
// to live in localStorage — which is ORIGIN-scoped, so docs, cloud and console
|
|
3
|
+
// each minted their own for the same person (463 anonymous identities carried 545
|
|
4
|
+
// events in a week). It is now a cookie on the registrable domain. These tests
|
|
5
|
+
// pin the two properties that migration turns on: that one jar yields ONE id
|
|
6
|
+
// across surfaces, and that an existing localStorage id is ADOPTED rather than
|
|
7
|
+
// minted over — minting there resets every returning visitor.
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
10
|
+
|
|
11
|
+
const ANON = 'hz_anon_id'
|
|
12
|
+
const LEGACY = '01920000-0000-7000-8000-0000000000aa'
|
|
13
|
+
const OTHER = '01920000-0000-7000-8000-0000000000bb'
|
|
14
|
+
|
|
15
|
+
const g = globalThis as Record<string, unknown>
|
|
16
|
+
|
|
17
|
+
interface Browser {
|
|
18
|
+
/** Every raw `document.cookie = …` write, so attributes can be asserted. */
|
|
19
|
+
writes: string[]
|
|
20
|
+
/** name → raw cookie value, shared between surfaces to model one browser. */
|
|
21
|
+
jar: Map<string, string>
|
|
22
|
+
store: Map<string, string> | undefined
|
|
23
|
+
restore: () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A minimal browser. `jar` can be passed in to model two *.hanzo.ai surfaces
|
|
27
|
+
* reading the same cookie store, which is the whole point of the change. */
|
|
28
|
+
function browser(
|
|
29
|
+
opts: {
|
|
30
|
+
href?: string
|
|
31
|
+
jar?: Map<string, string>
|
|
32
|
+
storage?: Record<string, string>
|
|
33
|
+
noStorage?: boolean
|
|
34
|
+
refuseCookies?: boolean
|
|
35
|
+
noDocument?: boolean
|
|
36
|
+
} = {},
|
|
37
|
+
): Browser {
|
|
38
|
+
const had = { window: 'window' in g, document: 'document' in g }
|
|
39
|
+
const prev = { window: g.window, document: g.document }
|
|
40
|
+
const jar = opts.jar ?? new Map<string, string>()
|
|
41
|
+
const writes: string[] = []
|
|
42
|
+
const store = opts.noStorage ? undefined : new Map(Object.entries(opts.storage ?? {}))
|
|
43
|
+
const url = new URL(opts.href ?? 'https://docs.hanzo.ai/guide')
|
|
44
|
+
|
|
45
|
+
g.window = {
|
|
46
|
+
location: {
|
|
47
|
+
href: url.href,
|
|
48
|
+
hostname: url.hostname,
|
|
49
|
+
protocol: url.protocol,
|
|
50
|
+
pathname: url.pathname,
|
|
51
|
+
search: url.search,
|
|
52
|
+
},
|
|
53
|
+
localStorage: store && {
|
|
54
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
55
|
+
setItem: (k: string, v: string) => void store.set(k, v),
|
|
56
|
+
},
|
|
57
|
+
addEventListener: () => {},
|
|
58
|
+
}
|
|
59
|
+
if (opts.noDocument) {
|
|
60
|
+
delete g.document
|
|
61
|
+
} else {
|
|
62
|
+
g.document = {
|
|
63
|
+
get cookie(): string {
|
|
64
|
+
return [...jar].map(([k, v]) => `${k}=${v}`).join('; ')
|
|
65
|
+
},
|
|
66
|
+
set cookie(raw: string) {
|
|
67
|
+
writes.push(raw)
|
|
68
|
+
if (opts.refuseCookies) return // a jar that accepts the write and drops it
|
|
69
|
+
const eq = raw.split(';')[0].indexOf('=')
|
|
70
|
+
if (eq < 0) return
|
|
71
|
+
jar.set(raw.slice(0, eq).trim(), raw.split(';')[0].slice(eq + 1).trim())
|
|
72
|
+
},
|
|
73
|
+
referrer: '',
|
|
74
|
+
visibilityState: 'visible',
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
writes,
|
|
80
|
+
jar,
|
|
81
|
+
store,
|
|
82
|
+
restore: () => {
|
|
83
|
+
if (had.window) g.window = prev.window
|
|
84
|
+
else delete g.window
|
|
85
|
+
if (had.document) g.document = prev.document
|
|
86
|
+
else delete g.document
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A fresh module instance — the in-memory fallback is module state, and each
|
|
92
|
+
* surface in these tests is a separately loaded copy of the client. */
|
|
93
|
+
const load = async () => {
|
|
94
|
+
vi.resetModules()
|
|
95
|
+
return await import('./storage')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isUuidV7 = (id: string) =>
|
|
99
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id)
|
|
100
|
+
|
|
101
|
+
describe('anonId', () => {
|
|
102
|
+
it('adopts an existing localStorage id into the cookie instead of minting', async () => {
|
|
103
|
+
// THE migration invariant. Minting here would hand every returning visitor a
|
|
104
|
+
// new identity and detach them from their own history.
|
|
105
|
+
const b = browser({ storage: { [ANON]: LEGACY } })
|
|
106
|
+
try {
|
|
107
|
+
const { anonId } = await load()
|
|
108
|
+
expect(anonId()).toBe(LEGACY)
|
|
109
|
+
expect(b.jar.get(ANON)).toBe(LEGACY)
|
|
110
|
+
expect(b.store!.get(ANON)).toBe(LEGACY)
|
|
111
|
+
} finally {
|
|
112
|
+
b.restore()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
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
|
+
|
|
144
|
+
it('lets the cookie win over a divergent origin-local id', async () => {
|
|
145
|
+
// docs already minted its own before the migration; the shared cookie is now
|
|
146
|
+
// the source of truth and localStorage converges onto it.
|
|
147
|
+
const b = browser({ jar: new Map([[ANON, OTHER]]), storage: { [ANON]: LEGACY } })
|
|
148
|
+
try {
|
|
149
|
+
const { anonId } = await load()
|
|
150
|
+
expect(anonId()).toBe(OTHER)
|
|
151
|
+
expect(b.store!.get(ANON)).toBe(OTHER)
|
|
152
|
+
} finally {
|
|
153
|
+
b.restore()
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('mints a v7 id when neither store holds one, and writes both', async () => {
|
|
158
|
+
const b = browser()
|
|
159
|
+
try {
|
|
160
|
+
const { anonId } = await load()
|
|
161
|
+
const id = anonId()!
|
|
162
|
+
expect(isUuidV7(id)).toBe(true)
|
|
163
|
+
expect(b.jar.get(ANON)).toBe(id)
|
|
164
|
+
expect(b.store!.get(ANON)).toBe(id)
|
|
165
|
+
expect(anonId()).toBe(id) // stable across calls
|
|
166
|
+
} finally {
|
|
167
|
+
b.restore()
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('is the SAME id on a second *.hanzo.ai surface sharing the jar', async () => {
|
|
172
|
+
// The goal: one visitor is one person from marketing through checkout.
|
|
173
|
+
const shared = new Map<string, string>()
|
|
174
|
+
const docs = browser({ href: 'https://docs.hanzo.ai/guide', jar: shared })
|
|
175
|
+
let first: string
|
|
176
|
+
try {
|
|
177
|
+
first = (await load()).anonId()!
|
|
178
|
+
} finally {
|
|
179
|
+
docs.restore()
|
|
180
|
+
}
|
|
181
|
+
// A different origin: its own empty localStorage, the same cookie jar.
|
|
182
|
+
const cloud = browser({ href: 'https://cloud.hanzo.ai/', jar: shared })
|
|
183
|
+
try {
|
|
184
|
+
expect((await load()).anonId()).toBe(first)
|
|
185
|
+
} finally {
|
|
186
|
+
cloud.restore()
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('returns undefined during SSR rather than minting a server-side id', async () => {
|
|
191
|
+
const had = { window: 'window' in g, document: 'document' in g }
|
|
192
|
+
const prev = { window: g.window, document: g.document }
|
|
193
|
+
delete g.window
|
|
194
|
+
delete g.document
|
|
195
|
+
try {
|
|
196
|
+
const { anonId } = await load()
|
|
197
|
+
expect(anonId()).toBeUndefined()
|
|
198
|
+
} finally {
|
|
199
|
+
if (had.window) g.window = prev.window
|
|
200
|
+
if (had.document) g.document = prev.document
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('scopes the cookie to the registrable domain, secure and long-lived', async () => {
|
|
205
|
+
const b = browser({ href: 'https://cloud.hanzo.ai/billing' })
|
|
206
|
+
try {
|
|
207
|
+
const { anonId } = await load()
|
|
208
|
+
anonId()
|
|
209
|
+
const w = b.writes[0]
|
|
210
|
+
expect(w).toContain('Domain=hanzo.ai')
|
|
211
|
+
expect(w).toContain('Path=/')
|
|
212
|
+
expect(w).toContain('SameSite=Lax')
|
|
213
|
+
expect(w).toContain('Secure')
|
|
214
|
+
expect(w).toContain(`Max-Age=${2 * 365 * 24 * 60 * 60}`)
|
|
215
|
+
} finally {
|
|
216
|
+
b.restore()
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('omits Domain and Secure off hanzo.ai, where both would drop the cookie', async () => {
|
|
221
|
+
const b = browser({ href: 'http://localhost:3000/' })
|
|
222
|
+
try {
|
|
223
|
+
const { anonId } = await load()
|
|
224
|
+
const id = anonId()!
|
|
225
|
+
expect(b.writes[0]).not.toContain('Domain=')
|
|
226
|
+
expect(b.writes[0]).not.toContain('Secure')
|
|
227
|
+
expect(b.jar.get(ANON)).toBe(id) // still durable, just host-only
|
|
228
|
+
} finally {
|
|
229
|
+
b.restore()
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('falls back to localStorage when cookies are refused', async () => {
|
|
234
|
+
const b = browser({ refuseCookies: true, storage: { [ANON]: LEGACY } })
|
|
235
|
+
try {
|
|
236
|
+
const { anonId } = await load()
|
|
237
|
+
expect(anonId()).toBe(LEGACY)
|
|
238
|
+
expect(anonId()).toBe(LEGACY)
|
|
239
|
+
expect(b.jar.size).toBe(0)
|
|
240
|
+
} finally {
|
|
241
|
+
b.restore()
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('holds one id in memory when both cookies and localStorage are refused', async () => {
|
|
246
|
+
const b = browser({ refuseCookies: true, noStorage: true })
|
|
247
|
+
try {
|
|
248
|
+
const { anonId } = await load()
|
|
249
|
+
const id = anonId()!
|
|
250
|
+
expect(isUuidV7(id)).toBe(true)
|
|
251
|
+
expect(anonId()).toBe(id) // one identity per page load, not one per event
|
|
252
|
+
} finally {
|
|
253
|
+
b.restore()
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('survives a browser with no document at all', async () => {
|
|
258
|
+
const b = browser({ noDocument: true, storage: { [ANON]: LEGACY } })
|
|
259
|
+
try {
|
|
260
|
+
const { anonId } = await load()
|
|
261
|
+
expect(anonId()).toBe(LEGACY)
|
|
262
|
+
} finally {
|
|
263
|
+
b.restore()
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('sessionId', () => {
|
|
269
|
+
// Deliberately unchanged by the anon migration: a session is origin-local and
|
|
270
|
+
// rotates on a 30-minute idle window.
|
|
271
|
+
it('stays in localStorage and rotates after the idle window', async () => {
|
|
272
|
+
const b = browser()
|
|
273
|
+
try {
|
|
274
|
+
const { sessionId } = await load()
|
|
275
|
+
const t0 = Date.now()
|
|
276
|
+
const a = sessionId(t0)
|
|
277
|
+
// The window runs from the LAST call, not from the session's start.
|
|
278
|
+
expect(sessionId(t0 + 60_000)).toBe(a)
|
|
279
|
+
expect(sessionId(t0 + 60_000 + 31 * 60_000)).not.toBe(a)
|
|
280
|
+
expect(b.jar.has('hz_session')).toBe(false)
|
|
281
|
+
} finally {
|
|
282
|
+
b.restore()
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
})
|
package/src/storage.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// SSR-safe browser storage for stable identifiers and first-touch state. Every
|
|
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
|
+
//
|
|
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.
|
|
4
8
|
|
|
9
|
+
import { hzAnonId } from './anon.js'
|
|
5
10
|
import type { Attribution, Cohort } from './types'
|
|
6
11
|
import { uuidv7 } from './uid'
|
|
7
12
|
|
|
8
13
|
const KEY = {
|
|
9
|
-
anon: 'hz_anon_id',
|
|
10
14
|
session: 'hz_session',
|
|
11
15
|
firstTouch: 'hz_first_touch',
|
|
12
16
|
cohort: 'hz_cohort',
|
|
@@ -24,16 +28,20 @@ function ls(): Storage | undefined {
|
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
/**
|
|
31
|
+
/**
|
|
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.
|
|
36
|
+
*
|
|
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.
|
|
42
|
+
*/
|
|
28
43
|
export function anonId(): string | undefined {
|
|
29
|
-
|
|
30
|
-
if (!s) return undefined
|
|
31
|
-
let v = s.getItem(KEY.anon)
|
|
32
|
-
if (!v) {
|
|
33
|
-
v = uuidv7()
|
|
34
|
-
s.setItem(KEY.anon, v)
|
|
35
|
-
}
|
|
36
|
-
return v
|
|
44
|
+
return hzAnonId() || undefined // '' during SSR / prerender
|
|
37
45
|
}
|
|
38
46
|
|
|
39
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.
|
|
25
|
-
//
|
|
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
|
|
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.
|
|
4
|
+
export const VERSION = '0.3.15'
|