@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/LICENSE.md +21 -0
- package/dist/index.cjs +125 -87
- 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 +125 -87
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +122 -86
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +122 -86
- 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/core.ts +8 -1
- package/src/hz.test.ts +67 -12
- package/src/storage.test.ts +28 -0
- package/src/storage.ts +14 -99
- 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
|
package/src/storage.test.ts
CHANGED
|
@@ -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,
|
|
6
|
-
//
|
|
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
|
-
*
|
|
107
|
-
*
|
|
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
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
|
|
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.
|
|
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.16'
|