@hanzo/event 0.3.8 → 0.3.9
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/dist/index.cjs +33 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -2
- package/dist/index.d.ts +12 -2
- package/dist/index.mjs +32 -20
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +28 -19
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +28 -19
- package/dist/react.mjs.map +1 -1
- package/hz.js +26 -5
- package/package.json +1 -1
- package/src/core.ts +2 -7
- package/src/hz.test.ts +96 -0
- package/src/index.ts +1 -0
- package/src/sentry.ts +4 -6
- package/src/storage.ts +11 -9
- package/src/uid.test.ts +73 -0
- package/src/uid.ts +70 -0
- package/src/version.ts +1 -1
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
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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:
|
|
62
|
+
state = { id: uuidv7(now), last: now }
|
|
61
63
|
} else {
|
|
62
64
|
state.last = now
|
|
63
65
|
}
|
package/src/uid.test.ts
ADDED
|
@@ -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.
|
|
4
|
+
export const VERSION = '0.3.9'
|