@hanzo/event 0.3.13 → 0.3.14

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.
@@ -0,0 +1,257 @@
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('lets the cookie win over a divergent origin-local id', async () => {
117
+ // docs already minted its own before the migration; the shared cookie is now
118
+ // the source of truth and localStorage converges onto it.
119
+ const b = browser({ jar: new Map([[ANON, OTHER]]), storage: { [ANON]: LEGACY } })
120
+ try {
121
+ const { anonId } = await load()
122
+ expect(anonId()).toBe(OTHER)
123
+ expect(b.store!.get(ANON)).toBe(OTHER)
124
+ } finally {
125
+ b.restore()
126
+ }
127
+ })
128
+
129
+ it('mints a v7 id when neither store holds one, and writes both', async () => {
130
+ const b = browser()
131
+ try {
132
+ const { anonId } = await load()
133
+ const id = anonId()!
134
+ expect(isUuidV7(id)).toBe(true)
135
+ expect(b.jar.get(ANON)).toBe(id)
136
+ expect(b.store!.get(ANON)).toBe(id)
137
+ expect(anonId()).toBe(id) // stable across calls
138
+ } finally {
139
+ b.restore()
140
+ }
141
+ })
142
+
143
+ it('is the SAME id on a second *.hanzo.ai surface sharing the jar', async () => {
144
+ // The goal: one visitor is one person from marketing through checkout.
145
+ const shared = new Map<string, string>()
146
+ const docs = browser({ href: 'https://docs.hanzo.ai/guide', jar: shared })
147
+ let first: string
148
+ try {
149
+ first = (await load()).anonId()!
150
+ } finally {
151
+ docs.restore()
152
+ }
153
+ // A different origin: its own empty localStorage, the same cookie jar.
154
+ const cloud = browser({ href: 'https://cloud.hanzo.ai/', jar: shared })
155
+ try {
156
+ expect((await load()).anonId()).toBe(first)
157
+ } finally {
158
+ cloud.restore()
159
+ }
160
+ })
161
+
162
+ it('returns undefined during SSR rather than minting a server-side id', async () => {
163
+ const had = { window: 'window' in g, document: 'document' in g }
164
+ const prev = { window: g.window, document: g.document }
165
+ delete g.window
166
+ delete g.document
167
+ try {
168
+ const { anonId } = await load()
169
+ expect(anonId()).toBeUndefined()
170
+ } finally {
171
+ if (had.window) g.window = prev.window
172
+ if (had.document) g.document = prev.document
173
+ }
174
+ })
175
+
176
+ it('scopes the cookie to the registrable domain, secure and long-lived', async () => {
177
+ const b = browser({ href: 'https://cloud.hanzo.ai/billing' })
178
+ try {
179
+ const { anonId } = await load()
180
+ anonId()
181
+ const w = b.writes[0]
182
+ expect(w).toContain('Domain=hanzo.ai')
183
+ expect(w).toContain('Path=/')
184
+ expect(w).toContain('SameSite=Lax')
185
+ expect(w).toContain('Secure')
186
+ expect(w).toContain(`Max-Age=${2 * 365 * 24 * 60 * 60}`)
187
+ } finally {
188
+ b.restore()
189
+ }
190
+ })
191
+
192
+ it('omits Domain and Secure off hanzo.ai, where both would drop the cookie', async () => {
193
+ const b = browser({ href: 'http://localhost:3000/' })
194
+ try {
195
+ const { anonId } = await load()
196
+ const id = anonId()!
197
+ expect(b.writes[0]).not.toContain('Domain=')
198
+ expect(b.writes[0]).not.toContain('Secure')
199
+ expect(b.jar.get(ANON)).toBe(id) // still durable, just host-only
200
+ } finally {
201
+ b.restore()
202
+ }
203
+ })
204
+
205
+ it('falls back to localStorage when cookies are refused', async () => {
206
+ const b = browser({ refuseCookies: true, storage: { [ANON]: LEGACY } })
207
+ try {
208
+ const { anonId } = await load()
209
+ expect(anonId()).toBe(LEGACY)
210
+ expect(anonId()).toBe(LEGACY)
211
+ expect(b.jar.size).toBe(0)
212
+ } finally {
213
+ b.restore()
214
+ }
215
+ })
216
+
217
+ it('holds one id in memory when both cookies and localStorage are refused', async () => {
218
+ const b = browser({ refuseCookies: true, noStorage: true })
219
+ try {
220
+ const { anonId } = await load()
221
+ const id = anonId()!
222
+ expect(isUuidV7(id)).toBe(true)
223
+ expect(anonId()).toBe(id) // one identity per page load, not one per event
224
+ } finally {
225
+ b.restore()
226
+ }
227
+ })
228
+
229
+ it('survives a browser with no document at all', async () => {
230
+ const b = browser({ noDocument: true, storage: { [ANON]: LEGACY } })
231
+ try {
232
+ const { anonId } = await load()
233
+ expect(anonId()).toBe(LEGACY)
234
+ } finally {
235
+ b.restore()
236
+ }
237
+ })
238
+ })
239
+
240
+ describe('sessionId', () => {
241
+ // Deliberately unchanged by the anon migration: a session is origin-local and
242
+ // rotates on a 30-minute idle window.
243
+ it('stays in localStorage and rotates after the idle window', async () => {
244
+ const b = browser()
245
+ try {
246
+ const { sessionId } = await load()
247
+ const t0 = Date.now()
248
+ const a = sessionId(t0)
249
+ // The window runs from the LAST call, not from the session's start.
250
+ expect(sessionId(t0 + 60_000)).toBe(a)
251
+ expect(sessionId(t0 + 60_000 + 31 * 60_000)).not.toBe(a)
252
+ expect(b.jar.has('hz_session')).toBe(false)
253
+ } finally {
254
+ b.restore()
255
+ }
256
+ })
257
+ })
package/src/storage.ts CHANGED
@@ -1,6 +1,9 @@
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, so it lives in a
6
+ // cookie on the registrable domain; everything else here stays origin-local.
4
7
 
5
8
  import type { Attribution, Cohort } from './types'
6
9
  import { uuidv7 } from './uid'
@@ -15,6 +18,14 @@ const KEY = {
15
18
  /** 30-minute inactivity window defines a session (PostHog/GA convention). */
16
19
  const SESSION_TTL_MS = 30 * 60 * 1000
17
20
 
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
+
18
29
  function ls(): Storage | undefined {
19
30
  try {
20
31
  if (typeof window === 'undefined' || !window.localStorage) return undefined
@@ -24,16 +35,98 @@ function ls(): Storage | undefined {
24
35
  }
25
36
  }
26
37
 
27
- /** Stable anonymous id, minted once per browser and reused across sessions. */
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
+ /**
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.
111
+ *
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.
117
+ */
28
118
  export function anonId(): string | undefined {
119
+ if (typeof window === 'undefined') return undefined // SSR / prerender
29
120
  const s = ls()
30
- if (!s) return undefined
31
- let v = s.getItem(KEY.anon)
32
- if (!v) {
33
- v = uuidv7()
34
- s.setItem(KEY.anon, v)
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 */
35
128
  }
36
- return v
129
+ return id
37
130
  }
38
131
 
39
132
  interface SessionState {
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.13'
4
+ export const VERSION = '0.3.14'