@hanzo/event 0.3.6 → 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/README.md +22 -4
- package/dist/index.cjs +40 -22
- 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 +39 -23
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +35 -22
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +35 -22
- package/dist/react.mjs.map +1 -1
- package/hz.js +328 -0
- package/package.json +2 -1
- package/src/core.test.ts +102 -0
- package/src/core.ts +35 -10
- 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/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'
|