@hanzo/event 0.3.19 → 0.3.20
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-D4PaPlOB.d.cts → core-CHLpJy51.d.cts} +56 -1
- package/dist/{core-D4PaPlOB.d.ts → core-CHLpJy51.d.ts} +56 -1
- package/dist/index.cjs +108 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +57 -4
- package/dist/index.d.ts +57 -4
- package/dist/index.mjs +104 -4
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +103 -3
- 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 +103 -3
- package/dist/react.mjs.map +1 -1
- package/hz.js +1 -1
- package/package.json +10 -10
- package/src/core.test.ts +45 -12
- package/src/core.ts +30 -3
- package/src/events.ts +5 -0
- package/src/exception.test.ts +188 -0
- package/src/exception.ts +200 -0
- package/src/index.ts +5 -1
- package/src/types.ts +53 -0
- package/src/version.ts +1 -1
- package/LICENSE.md +0 -21
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Hanzo Event
|
|
3
|
+
"version": "0.3.20",
|
|
4
|
+
"description": "Hanzo Event \u2014 the ONE telemetry client. Emits pageview/event/identify/group to the Hanzo Cloud event stream (POST /v1/event), AND reports errors to Sentry as real Sentry envelopes \u2014 the error plane needs a DSN, without one nothing reaches Sentry. First-touch attribution, beacon-on-unload, auto error capture, client-side secret/PII scrubbing, a shared event + goal vocabulary. Subsumes @sentry.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org/",
|
|
7
7
|
"access": "public",
|
|
@@ -37,6 +37,13 @@
|
|
|
37
37
|
"funnel",
|
|
38
38
|
"hanzo"
|
|
39
39
|
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsup",
|
|
42
|
+
"dev": "tsup --watch",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"clean": "rm -rf dist"
|
|
46
|
+
},
|
|
40
47
|
"exports": {
|
|
41
48
|
".": {
|
|
42
49
|
"import": {
|
|
@@ -74,12 +81,5 @@
|
|
|
74
81
|
"tsup": "^8.5.1",
|
|
75
82
|
"typescript": "^5.9.3",
|
|
76
83
|
"vitest": "^4.1.0"
|
|
77
|
-
},
|
|
78
|
-
"scripts": {
|
|
79
|
-
"build": "tsup",
|
|
80
|
-
"dev": "tsup --watch",
|
|
81
|
-
"test": "vitest run",
|
|
82
|
-
"typecheck": "tsc --noEmit",
|
|
83
|
-
"clean": "rm -rf dist"
|
|
84
84
|
}
|
|
85
|
-
}
|
|
85
|
+
}
|
package/src/core.test.ts
CHANGED
|
@@ -483,45 +483,78 @@ describe('Analytics capture', () => {
|
|
|
483
483
|
})
|
|
484
484
|
|
|
485
485
|
describe('Event error capture', () => {
|
|
486
|
-
it('
|
|
486
|
+
it('emits under the RESERVED $exception name, never under the message', () => {
|
|
487
487
|
const a = mk()
|
|
488
488
|
a.captureError(new TypeError('boom'))
|
|
489
489
|
// captureError flushes promptly — no explicit flush() needed.
|
|
490
490
|
expect(tx.sent).toHaveLength(1)
|
|
491
491
|
const e = tx.all[0]
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
//
|
|
492
|
+
// THE NAME. Until 0.3.20 this was `ex.message`, which made every distinct
|
|
493
|
+
// error string a permanent entry in the event taxonomy and left Error
|
|
494
|
+
// Tracking — which reads this exact name — at zero rows.
|
|
495
|
+
expect(e.event).toBe('$exception')
|
|
496
|
+
// THE TYPE. `type` alone picks the storage plane: 'error' routes to the error
|
|
497
|
+
// plane, which the product-event projection does not read, so an exception
|
|
498
|
+
// filed there cannot reach Error Tracking. The full error record still goes to
|
|
499
|
+
// the error plane as a Sentry envelope.
|
|
500
|
+
expect(e.type).toBe('event')
|
|
501
|
+
// The exception STILL rides the TOP-LEVEL `error` field, which is what cloud's
|
|
502
|
+
// foldException reads to stamp properties.$exception (scrubbing on the way).
|
|
497
503
|
expect(e.error?.type).toBe('TypeError')
|
|
498
504
|
expect(e.error?.message).toBe('boom')
|
|
499
505
|
expect(e.error?.stack).toBeTruthy()
|
|
500
506
|
expect(e.error?.handled).toBe(true) // a caught, manually-reported error
|
|
501
|
-
|
|
507
|
+
// The client does not stamp $exception itself — the server fold owns that key.
|
|
508
|
+
expect(e.properties ?? {}).not.toHaveProperty('$exception')
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it('carries the $exception_* bag Error Tracking reads', () => {
|
|
512
|
+
const a = mk()
|
|
513
|
+
a.captureError(new TypeError('boom'))
|
|
514
|
+
const p = tx.all[0].properties as Record<string, unknown>
|
|
515
|
+
expect(Array.isArray(p.$exception_list)).toBe(true)
|
|
516
|
+
// Without a fingerprint the issue query drops the event outright.
|
|
517
|
+
expect(p.$exception_fingerprint).toMatch(/^[0-9a-f]{32}$/)
|
|
518
|
+
expect(p.$exception_type).toBe('TypeError')
|
|
519
|
+
expect(p.$exception_handled).toBe(true)
|
|
502
520
|
})
|
|
503
521
|
|
|
504
522
|
it('normalizes a thrown string into an exception', () => {
|
|
505
523
|
const a = mk()
|
|
506
524
|
a.captureError('plain failure')
|
|
507
525
|
const e = tx.all[0]
|
|
508
|
-
expect(e.
|
|
526
|
+
expect(e.event).toBe('$exception')
|
|
527
|
+
expect(e.type).toBe('event')
|
|
509
528
|
expect(e.error?.message).toBe('plain failure')
|
|
510
529
|
})
|
|
511
530
|
|
|
512
|
-
it('marks handled=false for unhandled/global errors and carries properties', () => {
|
|
531
|
+
it('marks handled=false for unhandled/global errors and carries caller properties', () => {
|
|
513
532
|
const a = mk()
|
|
514
533
|
a.captureError(new Error('unhandled'), { handled: false, properties: { source: 'onerror' } })
|
|
515
534
|
const e = tx.all[0]
|
|
516
535
|
expect(e.error?.handled).toBe(false)
|
|
517
|
-
|
|
536
|
+
const p = e.properties as Record<string, unknown>
|
|
537
|
+
// The caller's own properties survive alongside the exception bag.
|
|
538
|
+
expect(p.source).toBe('onerror')
|
|
539
|
+
expect(p.$exception_handled).toBe(false)
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it("a caller property cannot overwrite the exception bag it shares a name with", () => {
|
|
543
|
+
const a = mk()
|
|
544
|
+
a.captureError(new Error('x'), {
|
|
545
|
+
handled: false,
|
|
546
|
+
properties: { $exception_fingerprint: 'forged' },
|
|
547
|
+
})
|
|
548
|
+
const p = tx.all[0].properties as Record<string, unknown>
|
|
549
|
+
expect(p.$exception_fingerprint).not.toBe('forged')
|
|
518
550
|
})
|
|
519
551
|
|
|
520
552
|
it('captureException is an alias of captureError', () => {
|
|
521
553
|
const a = mk()
|
|
522
554
|
a.captureException(new Error('via alias'))
|
|
523
555
|
const e = tx.all[0]
|
|
524
|
-
expect(e.
|
|
556
|
+
expect(e.event).toBe('$exception')
|
|
557
|
+
expect(e.type).toBe('event')
|
|
525
558
|
expect(e.error?.message).toBe('via alias')
|
|
526
559
|
})
|
|
527
560
|
|
|
@@ -563,7 +596,7 @@ describe('error plane', () => {
|
|
|
563
596
|
expect(tx.envelopes).toHaveLength(0)
|
|
564
597
|
// fail-safe: the event stream still carries the error, analytics untouched.
|
|
565
598
|
expect(tx.streams).toHaveLength(1)
|
|
566
|
-
expect(tx.all[0].
|
|
599
|
+
expect(tx.all[0].event).toBe('$exception')
|
|
567
600
|
})
|
|
568
601
|
|
|
569
602
|
it('with a DSN, an error POSTs a Sentry envelope to the DERIVED ingest URL', () => {
|
package/src/core.ts
CHANGED
|
@@ -44,7 +44,8 @@ import {
|
|
|
44
44
|
deriveChannel,
|
|
45
45
|
} from './attribution'
|
|
46
46
|
import { dsnForProduct, defaultPublishableKey } from './dsn'
|
|
47
|
-
import { PAGEVIEW } from './events'
|
|
47
|
+
import { EXCEPTION, PAGEVIEW } from './events'
|
|
48
|
+
import { exceptionProperties } from './exception'
|
|
48
49
|
import { scrubText } from './scrub'
|
|
49
50
|
import {
|
|
50
51
|
buildEnvelope,
|
|
@@ -389,9 +390,35 @@ export class Analytics {
|
|
|
389
390
|
}
|
|
390
391
|
|
|
391
392
|
try {
|
|
393
|
+
const handled = context?.handled ?? true
|
|
392
394
|
const ex = normalizeError(err)
|
|
393
|
-
ex.handled =
|
|
394
|
-
|
|
395
|
+
ex.handled = handled
|
|
396
|
+
// NAME: the reserved '$exception', never the message. The message was the
|
|
397
|
+
// name until 0.3.20, which put every distinct error string — one per failed
|
|
398
|
+
// chunk id, per ResizeObserver notification — permanently into the event
|
|
399
|
+
// taxonomy, and left Error Tracking (which reads this exact name) at zero.
|
|
400
|
+
//
|
|
401
|
+
// TYPE 'event', not 'error'. `type` alone picks the storage plane: 'error'
|
|
402
|
+
// routes to the error plane, which the product-event projection does not
|
|
403
|
+
// read, so an exception filed there is invisible to Error Tracking however
|
|
404
|
+
// well-formed it is. The full error record still reaches the error plane as
|
|
405
|
+
// a Sentry envelope above — this row is the product-analytics breadcrumb,
|
|
406
|
+
// which is what keeps a crash correlated with the session's pageviews.
|
|
407
|
+
//
|
|
408
|
+
// `error` is still carried: the server folds it into properties.$exception
|
|
409
|
+
// (scrubbing message and stack on the way), which is the shape existing
|
|
410
|
+
// readers bind to.
|
|
411
|
+
this.enqueue('event', EXCEPTION, {
|
|
412
|
+
error: ex,
|
|
413
|
+
properties: {
|
|
414
|
+
...context?.properties,
|
|
415
|
+
...exceptionProperties(err, {
|
|
416
|
+
handled,
|
|
417
|
+
id: uuidv7(),
|
|
418
|
+
level: context?.level,
|
|
419
|
+
}),
|
|
420
|
+
},
|
|
421
|
+
})
|
|
395
422
|
this.flush()
|
|
396
423
|
} catch {
|
|
397
424
|
/* nor the reverse */
|
package/src/events.ts
CHANGED
|
@@ -69,3 +69,8 @@ export type EventName = (typeof EVENTS)[keyof typeof EVENTS]
|
|
|
69
69
|
|
|
70
70
|
/** The reserved event name a pageview is stored under (server + read lens). */
|
|
71
71
|
export const PAGEVIEW = '$pageview'
|
|
72
|
+
|
|
73
|
+
/** The reserved name every captured exception is emitted under. Error Tracking
|
|
74
|
+
* reads exactly this name; an exception emitted under its own message instead
|
|
75
|
+
* makes every distinct message a permanent entry in the event taxonomy. */
|
|
76
|
+
export const EXCEPTION = '$exception'
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { digest, exceptionEntry, exceptionProperties, fingerprint } from './exception'
|
|
3
|
+
|
|
4
|
+
/** A throwable with a realistic V8 stack, innermost call first (as V8 emits). */
|
|
5
|
+
function boom(msg = 'Cannot read properties of undefined'): Error {
|
|
6
|
+
const e = new TypeError(msg)
|
|
7
|
+
e.stack = [
|
|
8
|
+
`TypeError: ${msg}`,
|
|
9
|
+
' at loadIssue (https://hanzo.ai/_next/static/chunks/app.js:42:9)',
|
|
10
|
+
' at render (https://hanzo.ai/_next/static/chunks/app.js:17:3)',
|
|
11
|
+
' at vendorLoad (https://hanzo.ai/node_modules/react-dom/index.js:12:3)',
|
|
12
|
+
].join('\n')
|
|
13
|
+
return e
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('frame ordering — the product reads frames bottom-up', () => {
|
|
17
|
+
it('puts the entry point first and the throw site last', () => {
|
|
18
|
+
const e = exceptionEntry(boom(), { handled: false, id: 'x' })
|
|
19
|
+
const frames = e.stacktrace!.frames
|
|
20
|
+
// V8 emits innermost-first; the product wants the reverse.
|
|
21
|
+
expect(frames[0].mangled_name).toBe('vendorLoad')
|
|
22
|
+
expect(frames[frames.length - 1].mangled_name).toBe('loadIssue')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('indexes the throw site at -1, which is where the issue list reads it', () => {
|
|
26
|
+
const p = exceptionProperties(boom(), { handled: false, id: 'x' })
|
|
27
|
+
expect(p.$exception_functions.at(-1)).toBe('loadIssue')
|
|
28
|
+
expect(p.$exception_sources.at(-1)).toBe('https://hanzo.ai/_next/static/chunks/app.js')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('stacktrace.type — the renderer draws nothing on any other value', () => {
|
|
33
|
+
it("is the literal 'resolved'", () => {
|
|
34
|
+
const e = exceptionEntry(boom(), { handled: false, id: 'x' })
|
|
35
|
+
expect(e.stacktrace!.type).toBe('resolved')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('is omitted entirely when there is no stack, rather than sent empty', () => {
|
|
39
|
+
const e = exceptionEntry('Script error.', { handled: false, id: 'x' })
|
|
40
|
+
expect(e.stacktrace).toBeUndefined()
|
|
41
|
+
expect(e.type).toBe('Error')
|
|
42
|
+
expect(e.value).toBe('Script error.')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('in_app — frames without it are hidden by default', () => {
|
|
47
|
+
it('marks first-party code in_app and vendor code not', () => {
|
|
48
|
+
const frames = exceptionEntry(boom(), { handled: false, id: 'x' }).stacktrace!.frames
|
|
49
|
+
const byName = Object.fromEntries(frames.map((f) => [f.mangled_name, f.in_app]))
|
|
50
|
+
expect(byName.loadIssue).toBe(true)
|
|
51
|
+
expect(byName.render).toBe(true)
|
|
52
|
+
expect(byName.vendorLoad).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('keeps only in_app frames out of the fingerprint', () => {
|
|
56
|
+
const p = exceptionProperties(boom(), { handled: false, id: 'x' })
|
|
57
|
+
// vendorLoad is the only non-in_app frame; it must not move the group key.
|
|
58
|
+
const e = exceptionEntry(boom(), { handled: false, id: 'y' })
|
|
59
|
+
e.stacktrace!.frames = e.stacktrace!.frames.filter((f) => f.in_app)
|
|
60
|
+
expect(fingerprint(e)).toBe(p.$exception_fingerprint)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('fingerprint — the issue grouping key', () => {
|
|
65
|
+
it('is present, since the issue query drops events without one', () => {
|
|
66
|
+
const p = exceptionProperties(boom(), { handled: false, id: 'x' })
|
|
67
|
+
expect(p.$exception_fingerprint).toMatch(/^[0-9a-f]{32}$/)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('groups the SAME bug whose message varies — the whole point', () => {
|
|
71
|
+
// The real-world case: one failed-chunk bug produced a distinct event name per
|
|
72
|
+
// chunk id. These must be one issue.
|
|
73
|
+
const a = new Error('Loading chunk 3324 failed.')
|
|
74
|
+
const b = new Error('Loading chunk 998 failed.')
|
|
75
|
+
const stack = ' at load (https://hanzo.ai/app.js:1:1)'
|
|
76
|
+
a.stack = `Error: x\n${stack}`
|
|
77
|
+
b.stack = `Error: y\n${stack}`
|
|
78
|
+
const fa = exceptionProperties(a, { handled: false, id: '1' }).$exception_fingerprint
|
|
79
|
+
const fb = exceptionProperties(b, { handled: false, id: '2' }).$exception_fingerprint
|
|
80
|
+
expect(fa).toBe(fb)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('separates genuinely different bugs', () => {
|
|
84
|
+
const other = new RangeError('nope')
|
|
85
|
+
other.stack = 'RangeError: nope\n at somewhereElse (https://hanzo.ai/other.js:5:5)'
|
|
86
|
+
const fa = exceptionProperties(boom(), { handled: false, id: '1' }).$exception_fingerprint
|
|
87
|
+
const fb = exceptionProperties(other, { handled: false, id: '2' }).$exception_fingerprint
|
|
88
|
+
expect(fa).not.toBe(fb)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('still groups stackless errors by type instead of scattering them', () => {
|
|
92
|
+
const f1 = exceptionProperties('Script error.', { handled: false, id: '1' })
|
|
93
|
+
const f2 = exceptionProperties('Script error.', { handled: false, id: '2' })
|
|
94
|
+
expect(f1.$exception_fingerprint).toBe(f2.$exception_fingerprint)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('raw_id — frame identity', () => {
|
|
99
|
+
it('carries the "<hash>/<part>" shape the product expects', () => {
|
|
100
|
+
const frames = exceptionEntry(boom(), { handled: false, id: 'x' }).stacktrace!.frames
|
|
101
|
+
for (const f of frames) expect(f.raw_id).toMatch(/^[0-9a-f]{32}\/0$/)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('is stable for the same code location across captures', () => {
|
|
105
|
+
const a = exceptionEntry(boom(), { handled: false, id: '1' }).stacktrace!.frames
|
|
106
|
+
const b = exceptionEntry(boom('different message'), { handled: false, id: '2' })
|
|
107
|
+
.stacktrace!.frames
|
|
108
|
+
expect(a.map((f) => f.raw_id)).toEqual(b.map((f) => f.raw_id))
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('mechanism + level', () => {
|
|
113
|
+
it('reports an uncaught error as unhandled', () => {
|
|
114
|
+
const e = exceptionEntry(boom(), { handled: false, id: 'x' })
|
|
115
|
+
expect(e.mechanism).toEqual({ type: 'generic', handled: false, synthetic: false })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('marks a non-Error throwable synthetic', () => {
|
|
119
|
+
const e = exceptionEntry('just a string', { handled: true, id: 'x' })
|
|
120
|
+
expect(e.mechanism?.synthetic).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('defaults level to error and honours an override', () => {
|
|
124
|
+
expect(exceptionProperties(boom(), { handled: true, id: 'x' }).$exception_level).toBe('error')
|
|
125
|
+
expect(
|
|
126
|
+
exceptionProperties(boom(), { handled: true, id: 'x', level: 'warning' }).$exception_level,
|
|
127
|
+
).toBe('warning')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('denormalized properties (nothing derives these server-side here)', () => {
|
|
132
|
+
it('sends the search + issue-column arrays the product reads', () => {
|
|
133
|
+
const p = exceptionProperties(boom(), { handled: false, id: 'x' })
|
|
134
|
+
expect(p.$exception_types).toEqual(['TypeError'])
|
|
135
|
+
expect(p.$exception_values).toEqual(['Cannot read properties of undefined'])
|
|
136
|
+
expect(p.$exception_type).toBe('TypeError')
|
|
137
|
+
expect(p.$exception_handled).toBe(false)
|
|
138
|
+
expect(p.$exception_fingerprint_record).toEqual([{ type: 'manual' }])
|
|
139
|
+
expect(p.$exception_list).toHaveLength(1)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('hostile input never escapes', () => {
|
|
144
|
+
it('survives a throwable whose getters throw', () => {
|
|
145
|
+
const hostile = {
|
|
146
|
+
get name() {
|
|
147
|
+
throw new Error('nope')
|
|
148
|
+
},
|
|
149
|
+
get message() {
|
|
150
|
+
throw new Error('nope')
|
|
151
|
+
},
|
|
152
|
+
get stack() {
|
|
153
|
+
throw new Error('nope')
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
expect(() => exceptionProperties(hostile, { handled: true, id: 'x' })).not.toThrow()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('bounds an enormous message', () => {
|
|
160
|
+
const e = exceptionEntry(new Error('x'.repeat(100_000)), { handled: true, id: 'x' })
|
|
161
|
+
expect(e.value.length).toBeLessThanOrEqual(4096)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('bounds frame count', () => {
|
|
165
|
+
const many = new Error('deep')
|
|
166
|
+
many.stack =
|
|
167
|
+
'Error: deep\n' +
|
|
168
|
+
Array.from({ length: 500 }, (_, i) => ` at f${i} (https://hanzo.ai/a.js:${i}:1)`).join(
|
|
169
|
+
'\n',
|
|
170
|
+
)
|
|
171
|
+
expect(exceptionEntry(many, { handled: true, id: 'x' }).stacktrace!.frames.length).toBe(50)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('digest', () => {
|
|
176
|
+
it('is stable and 32 hex chars', () => {
|
|
177
|
+
expect(digest('abc')).toBe(digest('abc'))
|
|
178
|
+
expect(digest('abc')).toMatch(/^[0-9a-f]{32}$/)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('separates different inputs', () => {
|
|
182
|
+
expect(digest('abc')).not.toBe(digest('abd'))
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('handles an empty string', () => {
|
|
186
|
+
expect(digest('')).toMatch(/^[0-9a-f]{32}$/)
|
|
187
|
+
})
|
|
188
|
+
})
|
package/src/exception.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// The `$exception` product-event shape — pure builders, no I/O.
|
|
2
|
+
//
|
|
3
|
+
// An exception reaches TWO destinations, and they read different shapes:
|
|
4
|
+
//
|
|
5
|
+
// 1. The ERROR PLANE — a Sentry envelope to the DSN host (sentry.ts). Untouched
|
|
6
|
+
// by this module.
|
|
7
|
+
// 2. ERROR TRACKING in Hanzo Insights — reads the PRODUCT-EVENT plane
|
|
8
|
+
// (`insights.events WHERE event = '$exception'`) and expects the PostHog
|
|
9
|
+
// exception vocabulary: `$exception_list` plus the denormalized `$exception_*`
|
|
10
|
+
// properties. This module builds that.
|
|
11
|
+
//
|
|
12
|
+
// WHY THE FULLY-DERIVED SHAPE. Upstream, a server stage (Cymbal) symbolicates
|
|
13
|
+
// frames and computes `$exception_fingerprint`, `$exception_types`, `_values`,
|
|
14
|
+
// `_sources`, `_functions`, then REPLACES the property bag. That stage is not in
|
|
15
|
+
// Hanzo's path: the door writes `event.fact` and a materialized view projects it
|
|
16
|
+
// into `insights.events`, so nothing between the client and the warehouse derives
|
|
17
|
+
// anything. Whatever the product reads, the client has to have sent. Two
|
|
18
|
+
// consequences are load-bearing rather than cosmetic:
|
|
19
|
+
//
|
|
20
|
+
// • The issue query drops any event whose `$exception_fingerprint` is null, so an
|
|
21
|
+
// event without one is invisible no matter how well-formed the rest is.
|
|
22
|
+
// • `stacktrace.type` must be the literal 'resolved'; on any other value the
|
|
23
|
+
// renderer falls through both match arms and draws nothing under the header.
|
|
24
|
+
//
|
|
25
|
+
// If that server stage is ever put in front of this plane it replaces these
|
|
26
|
+
// properties wholesale, so sending them stays correct either way.
|
|
27
|
+
//
|
|
28
|
+
// FRAME ORDER is bottom-up: frames[0] is the entry point and the LAST frame is the
|
|
29
|
+
// throw site. framesFromStack (sentry.ts) already returns that order, which is why
|
|
30
|
+
// this module reuses it rather than re-parsing.
|
|
31
|
+
|
|
32
|
+
import { framesFromStack, normalizeError } from './sentry'
|
|
33
|
+
import type {
|
|
34
|
+
ExceptionEntry,
|
|
35
|
+
ExceptionFrame,
|
|
36
|
+
ExceptionProperties,
|
|
37
|
+
SentryFrame,
|
|
38
|
+
SentryLevel,
|
|
39
|
+
} from './types'
|
|
40
|
+
|
|
41
|
+
/** Cap on the frames carried per exception, matching the Sentry path's budget. */
|
|
42
|
+
const MAX_FRAMES = 50
|
|
43
|
+
/** Cap on `value`, so one enormous message cannot dominate a batch. The product
|
|
44
|
+
* truncates for display anyway; this bounds the wire. */
|
|
45
|
+
const MAX_VALUE = 4096
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* digest is a stable 32-hex-char (128-bit) content hash, computed synchronously.
|
|
49
|
+
*
|
|
50
|
+
* Grouping keys are needed on the capture path, which is synchronous and may be
|
|
51
|
+
* running inside an unload handler — SubtleCrypto is async and unavailable on
|
|
52
|
+
* insecure origins, so it cannot be used here. This is FNV-1a run over four seeds
|
|
53
|
+
* and concatenated. It is a GROUPING key, never a security boundary: it is not
|
|
54
|
+
* collision-resistant against a chosen-input adversary, and nothing authorizes or
|
|
55
|
+
* authenticates on it. The product treats the value as opaque.
|
|
56
|
+
*/
|
|
57
|
+
export function digest(s: string): string {
|
|
58
|
+
let out = ''
|
|
59
|
+
for (const seed of [0x811c9dc5, 0x01000193, 0x9e3779b9, 0x85ebca6b]) {
|
|
60
|
+
let h = seed >>> 0
|
|
61
|
+
for (let i = 0; i < s.length; i++) {
|
|
62
|
+
h ^= s.charCodeAt(i)
|
|
63
|
+
// FNV prime 16777619, via shifts to stay in 32-bit integer math.
|
|
64
|
+
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0
|
|
65
|
+
}
|
|
66
|
+
out += h.toString(16).padStart(8, '0')
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* frameOf converts one parsed stack frame into the shape the product renders.
|
|
73
|
+
*
|
|
74
|
+
* The product's frame vocabulary is the POST-symbolication one (`mangled_name`,
|
|
75
|
+
* `source`, `line`, `column`), so that is what is emitted. `resolved` is false and
|
|
76
|
+
* honest: a browser bundle is minified and no symbol set has been uploaded, so the
|
|
77
|
+
* original name and line are genuinely unknown. Claiming `resolved: true` would
|
|
78
|
+
* trade an accurate "upload your symbol sets" hint for a misleading "source code is
|
|
79
|
+
* not available" error.
|
|
80
|
+
*/
|
|
81
|
+
function frameOf(f: SentryFrame): ExceptionFrame {
|
|
82
|
+
const source = f.filename ?? ''
|
|
83
|
+
const name = f.function ?? '<anonymous>'
|
|
84
|
+
const line = f.lineno ?? 0
|
|
85
|
+
const column = f.colno ?? 0
|
|
86
|
+
return {
|
|
87
|
+
// Stable per code location, so the same frame groups and re-renders under one
|
|
88
|
+
// key. The '/part' suffix is the product's format for one raw frame expanding
|
|
89
|
+
// into several resolved ones; a browser frame is always part 0.
|
|
90
|
+
raw_id: `${digest(`${source}|${name}|${line}|${column}`)}/0`,
|
|
91
|
+
mangled_name: name,
|
|
92
|
+
source,
|
|
93
|
+
line,
|
|
94
|
+
column,
|
|
95
|
+
in_app: f.in_app ?? false,
|
|
96
|
+
lang: 'javascript',
|
|
97
|
+
resolved: false,
|
|
98
|
+
resolve_failure: 'no symbol set uploaded for this release',
|
|
99
|
+
resolved_name: null,
|
|
100
|
+
module: null,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** truncate bounds a free-text field without splitting a surrogate pair. */
|
|
105
|
+
function truncate(s: string, max: number): string {
|
|
106
|
+
if (s.length <= max) return s
|
|
107
|
+
let end = max
|
|
108
|
+
const c = s.charCodeAt(end - 1)
|
|
109
|
+
// A high surrogate at the cut point would leave a lone half.
|
|
110
|
+
if (c >= 0xd800 && c <= 0xdbff) end--
|
|
111
|
+
return s.slice(0, end)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* exceptionEntry builds the single `$exception_list` entry for a throwable.
|
|
116
|
+
*
|
|
117
|
+
* One entry, not a chain: `Error.cause` chaining is a distinct fact with its own
|
|
118
|
+
* ordering rules, and emitting it wrongly is worse than not emitting it.
|
|
119
|
+
*/
|
|
120
|
+
export function exceptionEntry(
|
|
121
|
+
err: unknown,
|
|
122
|
+
opts: { handled: boolean; id: string },
|
|
123
|
+
): ExceptionEntry {
|
|
124
|
+
const n = normalizeError(err)
|
|
125
|
+
const frames = framesFromStack(n.stack).slice(-MAX_FRAMES)
|
|
126
|
+
const entry: ExceptionEntry = {
|
|
127
|
+
id: opts.id,
|
|
128
|
+
type: n.name,
|
|
129
|
+
value: truncate(n.message, MAX_VALUE),
|
|
130
|
+
mechanism: {
|
|
131
|
+
type: 'generic',
|
|
132
|
+
handled: opts.handled,
|
|
133
|
+
// An exception the app reported by hand was constructed, not thrown by the
|
|
134
|
+
// runtime at a real call site.
|
|
135
|
+
synthetic: !(err instanceof Error),
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
if (frames.length > 0) {
|
|
139
|
+
// 'resolved' names the SHAPE of the frame list, not the symbolication state of
|
|
140
|
+
// any frame — the renderer only walks frames on this exact value.
|
|
141
|
+
entry.stacktrace = { type: 'resolved', frames: frames.map(frameOf) }
|
|
142
|
+
}
|
|
143
|
+
return entry
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* fingerprint is the issue grouping key.
|
|
148
|
+
*
|
|
149
|
+
* Keyed on exception type plus each in-app frame's function and source — the same
|
|
150
|
+
* pieces the server-side grouper records ("Exception Type", "Resolved function
|
|
151
|
+
* name", "Source file name"). Deliberately NOT the message: `Loading chunk 3324
|
|
152
|
+
* failed` and `Loading chunk 998 failed` are one bug, and grouping on message is
|
|
153
|
+
* precisely the mistake that made every distinct error string its own event name.
|
|
154
|
+
*
|
|
155
|
+
* Falls back to the type alone when no in-app frame survived, which keeps
|
|
156
|
+
* stackless errors (`Script error.`, cross-origin) in one issue instead of
|
|
157
|
+
* scattering them.
|
|
158
|
+
*/
|
|
159
|
+
export function fingerprint(entry: ExceptionEntry): string {
|
|
160
|
+
const frames = entry.stacktrace?.frames ?? []
|
|
161
|
+
const pieces = frames
|
|
162
|
+
.filter((f) => f.in_app)
|
|
163
|
+
.map((f) => `${f.mangled_name}@${f.source}`)
|
|
164
|
+
return digest([entry.type, ...pieces].join('\n'))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* exceptionProperties builds the full `$exception_*` property bag for one captured
|
|
169
|
+
* throwable — everything Error Tracking reads off the event.
|
|
170
|
+
*
|
|
171
|
+
* The denormalized arrays are ordered like `frames`: last element is the throw
|
|
172
|
+
* site, which is the element the issue list indexes at -1 for its source/function
|
|
173
|
+
* columns.
|
|
174
|
+
*/
|
|
175
|
+
export function exceptionProperties(
|
|
176
|
+
err: unknown,
|
|
177
|
+
opts: { handled: boolean; id: string; level?: SentryLevel },
|
|
178
|
+
): ExceptionProperties {
|
|
179
|
+
const entry = exceptionEntry(err, opts)
|
|
180
|
+
const frames = entry.stacktrace?.frames ?? []
|
|
181
|
+
const fp = fingerprint(entry)
|
|
182
|
+
return {
|
|
183
|
+
$exception_list: [entry],
|
|
184
|
+
$exception_fingerprint: fp,
|
|
185
|
+
// Records WHY this fingerprint holds. 'manual' is the honest value: the client
|
|
186
|
+
// supplied the key rather than a server grouper deriving one.
|
|
187
|
+
$exception_fingerprint_record: [{ type: 'manual' }],
|
|
188
|
+
$exception_type: entry.type,
|
|
189
|
+
$exception_message: entry.value,
|
|
190
|
+
$exception_level: opts.level ?? 'error',
|
|
191
|
+
$exception_handled: opts.handled,
|
|
192
|
+
$exception_synthetic: entry.mechanism?.synthetic ?? false,
|
|
193
|
+
$exception_types: [entry.type],
|
|
194
|
+
$exception_values: [entry.value],
|
|
195
|
+
$exception_sources: frames.map((f) => f.source).filter((s): s is string => !!s),
|
|
196
|
+
$exception_functions: frames
|
|
197
|
+
.map((f) => f.resolved_name ?? f.mangled_name)
|
|
198
|
+
.filter((s): s is string => !!s),
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,8 @@ export { uuidv7, uuidv7Time } from './uid'
|
|
|
13
13
|
export { PRODUCT_PROJECT, dsnForProduct } from './dsn'
|
|
14
14
|
export type { ErrorIdentity } from './sentry'
|
|
15
15
|
export { scrubText, redactSecrets, scrubPII } from './scrub'
|
|
16
|
-
export { EVENTS, PAGEVIEW } from './events'
|
|
16
|
+
export { EVENTS, EXCEPTION, PAGEVIEW } from './events'
|
|
17
|
+
export { exceptionEntry, exceptionProperties, fingerprint, digest } from './exception'
|
|
17
18
|
export type { EventName } from './events'
|
|
18
19
|
export { GOALS, COHORTS } from './goals'
|
|
19
20
|
export type { GoalDef, CohortDef } from './goals'
|
|
@@ -34,6 +35,9 @@ export type {
|
|
|
34
35
|
Dsn,
|
|
35
36
|
EventKind,
|
|
36
37
|
Exception,
|
|
38
|
+
ExceptionEntry,
|
|
39
|
+
ExceptionFrame,
|
|
40
|
+
ExceptionProperties,
|
|
37
41
|
SentryEvent,
|
|
38
42
|
SentryFrame,
|
|
39
43
|
SentryLevel,
|
package/src/types.ts
CHANGED
|
@@ -22,6 +22,59 @@ export interface Exception {
|
|
|
22
22
|
handled?: boolean
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** One stack frame as Error Tracking renders it. The key names are the product's
|
|
26
|
+
* POST-symbolication vocabulary (`mangled_name`/`source`/`line`/`column`), which
|
|
27
|
+
* is what the issue view reads straight off `$exception_list`. */
|
|
28
|
+
export interface ExceptionFrame {
|
|
29
|
+
/** "<hash>/<part>" — stable per code location; the frame's identity. */
|
|
30
|
+
raw_id: string
|
|
31
|
+
/** The function name as it appears in the shipped bundle. */
|
|
32
|
+
mangled_name: string
|
|
33
|
+
/** File/URL the frame is in. */
|
|
34
|
+
source: string
|
|
35
|
+
line: number
|
|
36
|
+
column: number
|
|
37
|
+
/** First-party code. Frames without this are hidden by default in the product. */
|
|
38
|
+
in_app: boolean
|
|
39
|
+
lang: string
|
|
40
|
+
/** Whether a symbol set mapped this frame back to original source. */
|
|
41
|
+
resolved: boolean
|
|
42
|
+
resolve_failure?: string
|
|
43
|
+
resolved_name?: string | null
|
|
44
|
+
module?: string | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One exception in `$exception_list`. */
|
|
48
|
+
export interface ExceptionEntry {
|
|
49
|
+
id: string
|
|
50
|
+
type: string
|
|
51
|
+
value: string
|
|
52
|
+
mechanism?: {
|
|
53
|
+
type: 'generic'
|
|
54
|
+
handled: boolean
|
|
55
|
+
synthetic?: boolean
|
|
56
|
+
}
|
|
57
|
+
/** `type` MUST be 'resolved' — the renderer draws frames on no other value. */
|
|
58
|
+
stacktrace?: { type: 'resolved'; frames: ExceptionFrame[] }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `$exception_*` property bag Error Tracking reads off a `$exception` event. */
|
|
62
|
+
export interface ExceptionProperties {
|
|
63
|
+
$exception_list: ExceptionEntry[]
|
|
64
|
+
/** Issue grouping key. An event without one is dropped by the issue query. */
|
|
65
|
+
$exception_fingerprint: string
|
|
66
|
+
$exception_fingerprint_record: { type: 'manual' }[]
|
|
67
|
+
$exception_type: string
|
|
68
|
+
$exception_message: string
|
|
69
|
+
$exception_level: SentryLevel
|
|
70
|
+
$exception_handled: boolean
|
|
71
|
+
$exception_synthetic: boolean
|
|
72
|
+
$exception_types: string[]
|
|
73
|
+
$exception_values: string[]
|
|
74
|
+
$exception_sources: string[]
|
|
75
|
+
$exception_functions: string[]
|
|
76
|
+
}
|
|
77
|
+
|
|
25
78
|
/** First-touch marketing attribution, parsed once and persisted. */
|
|
26
79
|
export interface Attribution {
|
|
27
80
|
utm: {
|
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.20'
|