@hanzo/event 0.3.8 → 0.3.10
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/{core-B1XEdWLd.d.cts → core-BCEdNp8H.d.cts} +20 -8
- package/dist/{core-B1XEdWLd.d.ts → core-BCEdNp8H.d.ts} +20 -8
- package/dist/index.cjs +42 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -4
- package/dist/index.d.ts +14 -4
- package/dist/index.mjs +41 -21
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +37 -20
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.mjs +37 -20
- package/dist/react.mjs.map +1 -1
- package/hz.js +26 -5
- package/package.json +2 -2
- package/src/core.test.ts +34 -0
- package/src/core.ts +11 -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/types.ts +20 -8
- package/src/uid.test.ts +73 -0
- package/src/uid.ts +70 -0
- package/src/version.ts +1 -1
package/src/core.test.ts
CHANGED
|
@@ -356,6 +356,40 @@ describe('Analytics capture', () => {
|
|
|
356
356
|
expect(tx.sent[0].url).toBe('/v1/event')
|
|
357
357
|
})
|
|
358
358
|
|
|
359
|
+
it('reads the ingest key from the build env when config omits it', () => {
|
|
360
|
+
// The failure this closes is silent: a surface with no key attributes nothing
|
|
361
|
+
// for a logged-out visitor, the door refuses the write, and the page shows no
|
|
362
|
+
// sign of it. The key must resolve from the env exactly as the DSN does.
|
|
363
|
+
process.env.NEXT_PUBLIC_HANZO_EVENT_KEY = 'pk-live-from-env'
|
|
364
|
+
try {
|
|
365
|
+
const a = mk() // no key in config
|
|
366
|
+
a.capture('x')
|
|
367
|
+
a.flush(true)
|
|
368
|
+
expect(tx.sent[0].ingestKey).toBe('pk-live-from-env')
|
|
369
|
+
} finally {
|
|
370
|
+
delete process.env.NEXT_PUBLIC_HANZO_EVENT_KEY
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('prefers an explicit ingest key over the build env', () => {
|
|
375
|
+
process.env.NEXT_PUBLIC_HANZO_EVENT_KEY = 'pk-live-from-env'
|
|
376
|
+
try {
|
|
377
|
+
const a = mk({ ingestKey: 'pk-live-explicit' })
|
|
378
|
+
a.capture('x')
|
|
379
|
+
a.flush(true)
|
|
380
|
+
expect(tx.sent[0].ingestKey).toBe('pk-live-explicit')
|
|
381
|
+
} finally {
|
|
382
|
+
delete process.env.NEXT_PUBLIC_HANZO_EVENT_KEY
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('stays keyless when neither config nor env names a key', () => {
|
|
387
|
+
const a = mk()
|
|
388
|
+
a.capture('x')
|
|
389
|
+
a.flush(true)
|
|
390
|
+
expect(tx.sent[0].ingestKey).toBeUndefined()
|
|
391
|
+
})
|
|
392
|
+
|
|
359
393
|
it('setCohort rides subsequent events', () => {
|
|
360
394
|
const a = mk()
|
|
361
395
|
a.setCohort({ signupWeek: '2026-W29', channel: 'paid', refCode: 'REF9' })
|
package/src/core.ts
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
getCohort,
|
|
62
62
|
mergeCohort,
|
|
63
63
|
} from './storage'
|
|
64
|
+
import { uuidv7 } from './uid'
|
|
64
65
|
import type {
|
|
65
66
|
AnalyticsConfig,
|
|
66
67
|
Attribution,
|
|
@@ -111,12 +112,6 @@ function appendQuery(url: string, key: string, value: string): string {
|
|
|
111
112
|
return url + (url.includes('?') ? '&' : '?') + key + '=' + encodeURIComponent(value)
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
function uid(): string {
|
|
115
|
-
const c = typeof crypto !== 'undefined' ? crypto : undefined
|
|
116
|
-
if (c && 'randomUUID' in c) return c.randomUUID()
|
|
117
|
-
return 'm-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
115
|
/** Normalize anything thrown (Error | string | unknown) into an Exception. */
|
|
121
116
|
/** normalizeError adapts the shared, hostile-input-safe normalizer (sentry.ts) to
|
|
122
117
|
* the event stream's Exception shape. ONE normalizer serves both planes: a thrown
|
|
@@ -236,6 +231,15 @@ export class Analytics {
|
|
|
236
231
|
enabled: true,
|
|
237
232
|
captureErrors: true,
|
|
238
233
|
...config,
|
|
234
|
+
// The publishable key resolves the SAME way the DSN below does: an explicit
|
|
235
|
+
// config wins, else the inlined build-time env. Without this the key was the
|
|
236
|
+
// one piece of wiring a surface could not declare the way it declares every
|
|
237
|
+
// other piece, so every surface that shipped without passing it in code sent
|
|
238
|
+
// its beacons unattributed — and an unattributed write is refused (401
|
|
239
|
+
// ingest_key_required), which is silent in the page and invisible until you
|
|
240
|
+
// read the warehouse and find the host missing entirely.
|
|
241
|
+
ingestKey:
|
|
242
|
+
config.ingestKey ?? readEnv('NEXT_PUBLIC_HANZO_EVENT_KEY') ?? readEnv('HANZO_EVENT_KEY'),
|
|
239
243
|
}
|
|
240
244
|
this.transport = config.transport ?? new DefaultTransport()
|
|
241
245
|
// Error plane, most specific source first: an explicit DSN wins, then the
|
|
@@ -472,7 +476,7 @@ export class Analytics {
|
|
|
472
476
|
private build(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): WireEvent {
|
|
473
477
|
const anon = anonId()
|
|
474
478
|
const wire: WireEvent = {
|
|
475
|
-
messageId:
|
|
479
|
+
messageId: uuidv7(),
|
|
476
480
|
type: kind,
|
|
477
481
|
event,
|
|
478
482
|
timestamp: new Date().toISOString(),
|
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/types.ts
CHANGED
|
@@ -112,14 +112,26 @@ export interface AnalyticsConfig {
|
|
|
112
112
|
/** Bearer token provider for token-auth apps. Omit for cookie/session apps
|
|
113
113
|
* (the client then relies on same-origin credentials). */
|
|
114
114
|
getToken?: () => string | undefined | null
|
|
115
|
-
/** Publishable ingest key (
|
|
116
|
-
* front door `/v1/event` with this key instead of a bearer/cookie: it rides
|
|
117
|
-
* Authorization: Bearer
|
|
118
|
-
* page-unload beacon, so
|
|
119
|
-
* without a bearer. The key is write-only
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
115
|
+
/** Publishable ingest key (pk-…). When set, the client attributes writes to the
|
|
116
|
+
* ONE front door `/v1/event` with this key instead of a bearer/cookie: it rides
|
|
117
|
+
* Authorization: Bearer pk-… on fetch and ?ingest_key=pk-… on a headerless
|
|
118
|
+
* page-unload beacon, so ANONYMOUS traffic is attributed and unload beacons work
|
|
119
|
+
* without a bearer. The key is write-only — it attributes a write and never mints
|
|
120
|
+
* a reading principal — so it is safe to ship in a bundle. Mint one per org with
|
|
121
|
+
* POST /v1/keys {"type":"publishable"}.
|
|
122
|
+
*
|
|
123
|
+
* Omit it and the client reads NEXT_PUBLIC_HANZO_EVENT_KEY, then HANZO_EVENT_KEY,
|
|
124
|
+
* from the inlined build env — the same resolution `dsn` uses, so a surface
|
|
125
|
+
* declares BOTH planes the same way and neither needs code to switch on.
|
|
126
|
+
*
|
|
127
|
+
* A surface with no key at all still reports for whoever is SIGNED IN (the
|
|
128
|
+
* session credential attributes them), and drops every logged-out visitor: the
|
|
129
|
+
* door refuses an unattributable write rather than filing it where its owner
|
|
130
|
+
* cannot read it. That failure is invisible from the page, which is why the key
|
|
131
|
+
* belongs in the env next to the DSN and not in a checklist.
|
|
132
|
+
*
|
|
133
|
+
* This attributes the EVENT STREAM only — the error plane authenticates
|
|
134
|
+
* independently with `dsn`, and one does not stand in for the other. */
|
|
123
135
|
ingestKey?: string
|
|
124
136
|
/** Max events buffered before an automatic flush. */
|
|
125
137
|
batchSize?: number
|
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'
|