@hanzo/event 0.3.0 → 0.3.2

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/src/index.ts CHANGED
@@ -8,6 +8,9 @@
8
8
  // React apps use the './react' entry for the provider + hooks + error boundary.
9
9
 
10
10
  export { Analytics, createAnalytics, VERSION, getCohort, getFirstTouch } from './core'
11
+ export { parseDsn, buildSentryEvent, buildEnvelope, framesFromStack } from './sentry'
12
+ export type { ErrorIdentity } from './sentry'
13
+ export { scrubText, redactSecrets, scrubPII } from './scrub'
11
14
  export { EVENTS, PAGEVIEW } from './events'
12
15
  export type { EventName } from './events'
13
16
  export { GOALS, COHORTS } from './goals'
@@ -22,9 +25,14 @@ export {
22
25
  export type {
23
26
  AnalyticsConfig,
24
27
  Attribution,
28
+ CaptureErrorOptions,
25
29
  Cohort,
30
+ Dsn,
26
31
  EventKind,
27
32
  Exception,
33
+ SentryEvent,
34
+ SentryFrame,
35
+ SentryLevel,
28
36
  Transport,
29
37
  WireEvent,
30
38
  } from './types'
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { redactSecrets, scrubPII, scrubText, MAX_SCRUB_LEN } from './scrub'
3
+
4
+ describe('redactSecrets (always applied)', () => {
5
+ it('redacts a hanzo key', () => {
6
+ expect(redactSecrets('key=hk-ABCDEFGHIJKLMNOP1234 tail')).toContain('[redacted]')
7
+ expect(redactSecrets('key=hk-ABCDEFGHIJKLMNOP1234 tail')).not.toContain('hk-ABCDEFGHIJKLMNOP1234')
8
+ })
9
+ it('redacts a JWT', () => {
10
+ const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abcdefghij'
11
+ expect(redactSecrets(`token ${jwt}`)).not.toContain(jwt)
12
+ })
13
+ it('redacts a bearer token', () => {
14
+ expect(redactSecrets('Authorization: Bearer abcdef0123456789ABCDEF')).toContain('[redacted]')
15
+ })
16
+ it('redacts openai/stripe/aws/github/google/slack shapes', () => {
17
+ expect(redactSecrets('sk-proj-ABCDEFGHIJKLMNOPQRST')).toContain('[redacted]')
18
+ expect(redactSecrets('sk_live_ABCDEFGHIJKLMNOP1234')).toContain('[redacted]')
19
+ expect(redactSecrets('AKIAABCDEFGHIJKLMNOP')).toContain('[redacted]')
20
+ expect(redactSecrets('ghp_ABCDEFGHIJKLMNOPQRSTUVWX')).toContain('[redacted]')
21
+ expect(redactSecrets('AIzaABCDEFGHIJKLMNOPQRSTUVWXYZ012345')).toContain('[redacted]')
22
+ expect(redactSecrets('xoxb-1111-2222-abcdefghij')).toContain('[redacted]')
23
+ })
24
+ it('redacts credentials embedded in a URL/DSN', () => {
25
+ expect(redactSecrets('postgres://user:s3cretpw@db.host:5432/x')).toContain('[redacted]')
26
+ expect(redactSecrets('postgres://user:s3cretpw@db.host:5432/x')).not.toContain('s3cretpw')
27
+ })
28
+ })
29
+
30
+ describe('scrubPII (default; opt-out via capturePII)', () => {
31
+ it('masks emails and IPs', () => {
32
+ expect(scrubPII('from alice@example.com at 192.168.1.7')).toBe('from [email] at [ip]')
33
+ })
34
+ it('scrubText masks by default and retains when capturePII=true', () => {
35
+ expect(scrubText('alice@example.com', false)).toBe('[email]')
36
+ expect(scrubText('alice@example.com', true)).toBe('alice@example.com')
37
+ })
38
+ it('scrubText still redacts secrets even when capturePII=true', () => {
39
+ expect(scrubText('hk-ABCDEFGHIJKLMNOP1234', true)).toContain('[redacted]')
40
+ })
41
+ it('is total on empty/undefined', () => {
42
+ expect(scrubText(undefined)).toBe('')
43
+ expect(scrubText('')).toBe('')
44
+ })
45
+ })
46
+
47
+ // ── denial of service ──────────────────────────────────────────────────────
48
+ //
49
+ // The scrubber runs SYNCHRONOUSLY on the main thread inside captureError, and its
50
+ // input is attacker-influenced: `throw new Error(await res.text())` against an
51
+ // HTML error page is a one-line way to hand it 128KB. The creds-in-URL pattern
52
+ // backtracked quadratically on colon-rich text with no terminating '@' — 4.9s at
53
+ // 32KB, >60s at 128KB. Both the input cap and the bounded pattern are load-bearing.
54
+
55
+ describe('input bounding', () => {
56
+ it('caps input length and says so', () => {
57
+ const out = scrubText('a'.repeat(MAX_SCRUB_LEN * 4))
58
+ expect(out.length).toBeLessThan(MAX_SCRUB_LEN + 64)
59
+ expect(out.endsWith('… [truncated]')).toBe(true)
60
+ })
61
+
62
+ it('scrubs pathological colon-rich input in bounded time', () => {
63
+ // The exact shape that blew up: many colons, no '@' to terminate the match.
64
+ const hostile = '<div class="a:b:c">'.repeat(8000) // ~150KB
65
+ const t0 = Date.now()
66
+ const out = scrubText(hostile)
67
+ const ms = Date.now() - t0
68
+ expect(out).toBeTruthy()
69
+ // Was >60s unbounded. Generous ceiling so the test is not flaky on slow CI.
70
+ expect(ms).toBeLessThan(1000)
71
+ })
72
+
73
+ it('still redacts real credentials in a URL', () => {
74
+ expect(scrubText('postgres://user:hunter2@db.internal/app')).toContain('[redacted]')
75
+ expect(scrubText('postgres://user:hunter2@db.internal/app')).not.toContain('hunter2')
76
+ })
77
+ })
78
+
79
+ // ── PAN false positives ────────────────────────────────────────────────────
80
+ //
81
+ // The bare digit-run rule redacted every millisecond epoch and order id it saw,
82
+ // destroying the readability of the messages this client exists to deliver. It is
83
+ // now gated on Luhn, which every real card satisfies — so the false positives go
84
+ // away without introducing a false negative.
85
+
86
+ describe('card numbers', () => {
87
+ it('redacts a real (Luhn-valid) card number', () => {
88
+ expect(scrubText('card 4111111111111111 declined')).toContain('[redacted]')
89
+ expect(scrubText('card 4111-1111-1111-1111 declined')).toContain('[redacted]')
90
+ })
91
+
92
+ it('leaves an epoch timestamp alone', () => {
93
+ const out = scrubText('request 1753468800000 timed out')
94
+ expect(out).toBe('request 1753468800000 timed out')
95
+ })
96
+ })
package/src/scrub.ts ADDED
@@ -0,0 +1,112 @@
1
+ // Client-side redaction for error text — a faithful port of the server's
2
+ // errortracking scrub (o11y pkg/modules/errortracking/implerrortracking/scrub.go),
3
+ // applied BEFORE anything leaves the browser. Two layers, default-secure:
4
+ //
5
+ // - Secret shapes are ALWAYS redacted (sk-… keys, bearer/JWT tokens, DB DSNs,
6
+ // PANs, cloud keys). There is no mode that ships a secret off-device.
7
+ // - PII (email/IP) is scrubbed UNLESS capturePII is explicitly enabled.
8
+ //
9
+ // The server scrubs again — this is defense in depth, not a substitute — but the
10
+ // point is that a Hanzo browser never emits a raw secret/email/IP in the first
11
+ // place. Pure, no I/O.
12
+
13
+ const REDACTED = '[redacted]'
14
+ const EMAIL_MARK = '[email]'
15
+ const IP_MARK = '[ip]'
16
+
17
+ // Secret patterns mirror scrub.go's secretPatterns. Order matters (broad DSN/PAN
18
+ // rules run last). All are applied unconditionally.
19
+ const SECRET_PATTERNS: RegExp[] = [
20
+ /-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----/g,
21
+ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, // JWT
22
+ /\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi, // bearer token
23
+ /\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g, // openai-style
24
+ /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g, // stripe
25
+ /\bhk-[A-Za-z0-9]{16,}/g, // hanzo key
26
+ /\bAKIA[0-9A-Z]{16}\b/g, // aws access key id
27
+ /\bASIA[0-9A-Z]{16}\b/g, // aws sts key id
28
+ /\bAIza[0-9A-Za-z_-]{20,}/g, // google api key
29
+ /\bgh[posru]_[A-Za-z0-9]{20,}/g, // github token
30
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g, // slack token
31
+ // Creds in a URL/DSN. The repetition is BOUNDED on purpose: the unbounded form
32
+ // ([^\s:@/]+:[^\s@/]+@) backtracks quadratically on colon-rich text with no
33
+ // terminating '@' — an HTML error page pasted into an error message took 4.9s
34
+ // at 32KB and >60s at 128KB, freezing the main thread from inside captureError.
35
+ // Real userinfo is far below these caps, so bounding costs nothing.
36
+ /[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g,
37
+ ]
38
+
39
+ /** Candidate card numbers. Gated by Luhn below — the bare digit-run pattern is a
40
+ * false-positive cannon that redacts every millisecond epoch, order id and
41
+ * phone number it sees ("request 1753468800000 timed out" became
42
+ * "request [redacted]timed out"), which destroys the readability of the very
43
+ * error messages this client exists to deliver. */
44
+ const RE_PAN = /\b(?:\d[ -]?){13,19}\b/g
45
+
46
+ /** luhn reports whether a digit string satisfies the Luhn checksum. Every real
47
+ * card number does, so gating redaction on it removes the false positives
48
+ * WITHOUT introducing a false negative — the safe direction for a redactor. */
49
+ function luhn(digits: string): boolean {
50
+ let sum = 0
51
+ let alt = false
52
+ for (let i = digits.length - 1; i >= 0; i--) {
53
+ let d = digits.charCodeAt(i) - 48
54
+ if (alt) {
55
+ d *= 2
56
+ if (d > 9) d -= 9
57
+ }
58
+ sum += d
59
+ alt = !alt
60
+ }
61
+ return sum % 10 === 0
62
+ }
63
+
64
+ /** redactPAN removes digit runs that actually check out as card numbers. */
65
+ function redactPAN(s: string): string {
66
+ return s.replace(RE_PAN, (m) => {
67
+ const digits = m.replace(/[ -]/g, '')
68
+ if (digits.length < 13 || digits.length > 19) return m
69
+ return luhn(digits) ? REDACTED : m
70
+ })
71
+ }
72
+
73
+ const RE_EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
74
+ const RE_IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g
75
+ const RE_IPV6 = /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g
76
+
77
+ /** redactSecrets removes known secret shapes. Always applied. */
78
+ export function redactSecrets(s: string): string {
79
+ for (const re of SECRET_PATTERNS) s = s.replace(re, REDACTED)
80
+ return redactPAN(s)
81
+ }
82
+
83
+ /** scrubPII masks emails and IPs. Applied unless PII capture is enabled. */
84
+ export function scrubPII(s: string): string {
85
+ s = s.replace(RE_EMAIL, EMAIL_MARK)
86
+ s = s.replace(RE_IPV6, IP_MARK)
87
+ s = s.replace(RE_IPV4, IP_MARK)
88
+ return s
89
+ }
90
+
91
+ /** Longest free-text field this module will scrub. Every pattern here is a regex
92
+ * run synchronously on the main thread, and the input is attacker-influenced —
93
+ * `throw new Error(await res.text())` against an HTML error page is a one-line
94
+ * way to hand us 128KB. Bounding the INPUT bounds the work regardless of which
95
+ * pattern is pathological. 8KB is far beyond any real error message. */
96
+ export const MAX_SCRUB_LEN = 8192
97
+
98
+ /** truncate caps a string at MAX_SCRUB_LEN, marking that it was cut so a reader
99
+ * never mistakes a truncated message for the whole one. */
100
+ export function truncate(s: string, max = MAX_SCRUB_LEN): string {
101
+ return s.length > max ? s.slice(0, max) + '… [truncated]' : s
102
+ }
103
+
104
+ /** scrubText applies the redaction policy to a free-text field. Input is capped
105
+ * first: unbounded text is a denial-of-service surface, not just a size problem. */
106
+ export function scrubText(s: string | undefined, capturePII = false): string {
107
+ if (!s) return s ?? ''
108
+ s = truncate(s)
109
+ s = redactSecrets(s)
110
+ if (!capturePII) s = scrubPII(s)
111
+ return s
112
+ }
@@ -0,0 +1,260 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createHash } from 'node:crypto'
3
+ import {
4
+ parseDsn,
5
+ framesFromStack,
6
+ buildSentryEvent,
7
+ buildEnvelope,
8
+ eventId,
9
+ normalizeError,
10
+ } from './sentry'
11
+ import type { SentryEvent, SentryFrame } from './types'
12
+ import { VERSION } from './version'
13
+
14
+ const DSN = 'https://1:deadbeefcafe@api.hanzo.ai/v1/sentry/00000000-0000-0000-0000-000000000000'
15
+
16
+ describe('parseDsn', () => {
17
+ it('keeps the version:hmac key intact (does NOT split on the colon) and derives the ingest url', () => {
18
+ const dsn = parseDsn(DSN)!
19
+ expect(dsn.publicKey).toBe('1:deadbeefcafe')
20
+ expect(dsn.origin).toBe('https://api.hanzo.ai')
21
+ expect(dsn.projectId).toBe('00000000-0000-0000-0000-000000000000')
22
+ expect(dsn.ingestUrl).toBe(
23
+ 'https://api.hanzo.ai/v1/sentry/00000000-0000-0000-0000-000000000000/envelope/?sentry_key=1%3Adeadbeefcafe',
24
+ )
25
+ })
26
+ it('returns null for malformed input (fail-safe)', () => {
27
+ expect(parseDsn(undefined)).toBeNull()
28
+ expect(parseDsn('')).toBeNull()
29
+ expect(parseDsn('not-a-dsn')).toBeNull()
30
+ expect(parseDsn('https://api.hanzo.ai/v1/sentry/x')).toBeNull() // no key
31
+ expect(parseDsn('https://1:k@api.hanzo.ai')).toBeNull() // no project
32
+ })
33
+ })
34
+
35
+ describe('framesFromStack', () => {
36
+ it('parses a V8 stack oldest-first with in_app marking', () => {
37
+ const stack = [
38
+ 'TypeError: x is not a function',
39
+ ' at inner (https://app.hanzo.ai/app.js:10:5)',
40
+ ' at outer (https://app.hanzo.ai/app.js:20:1)',
41
+ ' at eval (webpack-internal:///./node_modules/lib/index.js:3:2)',
42
+ ].join('\n')
43
+ const frames = framesFromStack(stack)
44
+ expect(frames).toHaveLength(3)
45
+ // oldest-first: the node_modules frame is the outermost caller (first),
46
+ // the crash site (inner) is last.
47
+ expect(frames[frames.length - 1].function).toBe('inner')
48
+ expect(frames[frames.length - 1].lineno).toBe(10)
49
+ expect(frames[frames.length - 1].in_app).toBe(true)
50
+ expect(frames[0].function).toBe('eval')
51
+ expect(frames[0].in_app).toBe(false) // node_modules
52
+ })
53
+ it('parses a Firefox/Safari stack (fn@file:li:co and bare @file)', () => {
54
+ const stack = ['boom@https://app.hanzo.ai/a.js:1:2', '@https://app.hanzo.ai/b.js:3:4'].join('\n')
55
+ const frames = framesFromStack(stack)
56
+ expect(frames).toHaveLength(2)
57
+ expect(frames[frames.length - 1].function).toBe('boom')
58
+ expect(frames[frames.length - 1].filename).toBe('https://app.hanzo.ai/a.js')
59
+ })
60
+ it('skips the header line and tolerates junk', () => {
61
+ expect(framesFromStack('Error: nope\n total garbage line')).toHaveLength(0)
62
+ expect(framesFromStack(undefined)).toHaveLength(0)
63
+ })
64
+ })
65
+
66
+ describe('normalizeError', () => {
67
+ it('coerces Error, string, and objects', () => {
68
+ expect(normalizeError(new RangeError('r')).name).toBe('RangeError')
69
+ expect(normalizeError('boom')).toEqual({ name: 'Error', message: 'boom' })
70
+ expect(normalizeError({ a: 1 }).message).toBe('{"a":1}')
71
+ })
72
+ })
73
+
74
+ describe('eventId', () => {
75
+ it('is 32 lowercase hex chars (the Sentry event_id shape)', () => {
76
+ expect(eventId()).toMatch(/^[0-9a-f]{32}$/)
77
+ expect(eventId()).not.toBe(eventId())
78
+ })
79
+ })
80
+
81
+ describe('buildSentryEvent', () => {
82
+ it('sets subject-only user, default error level, scrubbed message, product tags', () => {
83
+ const ev = buildSentryEvent({
84
+ error: new Error('boom for bob@corp.com'),
85
+ identity: { userId: 'sub-9', sessionId: 'sess-1', product: 'app', release: 'v1', environment: 'production' },
86
+ })
87
+ expect(ev.platform).toBe('javascript')
88
+ expect(ev.level).toBe('error')
89
+ expect(ev.user).toEqual({ id: 'sub-9' })
90
+ expect(ev.exception!.values[0].value).toBe('boom for [email]')
91
+ expect(ev.tags!.product).toBe('app')
92
+ expect(ev.tags!.session).toBe('sess-1')
93
+ expect(ev.tags!.handled).toBe('true')
94
+ expect(ev.release).toBe('v1')
95
+ expect(ev.environment).toBe('production')
96
+ expect(ev.sdk).toEqual({ name: '@hanzo/event', version: VERSION })
97
+ })
98
+ it('uncaught errors default to fatal level and handled=false', () => {
99
+ const ev = buildSentryEvent({ error: new Error('x'), options: { handled: false }, identity: {} })
100
+ expect(ev.level).toBe('fatal')
101
+ expect(ev.tags!.handled).toBe('false')
102
+ })
103
+ it('omits user entirely when there is no subject', () => {
104
+ const ev = buildSentryEvent({ error: new Error('x'), identity: {} })
105
+ expect(ev.user).toBeUndefined()
106
+ })
107
+ })
108
+
109
+ // ── round-trip proof: a byte-faithful TS port of the SERVER parser recovers the
110
+ // exact event from our envelope. Port of o11y
111
+ // pkg/modules/errortracking/implerrortracking/envelope.go parseEnvelope. ───────
112
+
113
+ function parseEnvelopeOracle(body: Uint8Array): SentryEvent[] {
114
+ let pos = 0
115
+ const dec = new TextDecoder()
116
+ const readLine = (): Uint8Array | null => {
117
+ if (pos >= body.length) return null
118
+ const nl = body.indexOf(0x0a, pos)
119
+ if (nl >= 0) {
120
+ const line = body.subarray(pos, nl)
121
+ pos = nl + 1
122
+ return line
123
+ }
124
+ const line = body.subarray(pos)
125
+ pos = body.length
126
+ return line
127
+ }
128
+ if (readLine() === null) throw new Error('empty envelope') // required header
129
+ const events: SentryEvent[] = []
130
+ while (pos < body.length) {
131
+ if (events.length >= 1000) break
132
+ const hdrLine = readLine()
133
+ if (hdrLine === null) break
134
+ const hdrStr = dec.decode(hdrLine).trim()
135
+ if (hdrStr.length === 0) continue
136
+ let ih: { type?: string; length?: number | null }
137
+ try {
138
+ ih = JSON.parse(hdrStr)
139
+ } catch {
140
+ break
141
+ }
142
+ let payload: Uint8Array | null
143
+ if (ih.length != null && ih.length >= 0 && ih.length <= body.length - pos) {
144
+ const end = pos + ih.length
145
+ payload = body.subarray(pos, end)
146
+ pos = end
147
+ if (pos < body.length && body[pos] === 0x0a) pos++
148
+ } else {
149
+ payload = readLine()
150
+ if (payload === null) break
151
+ }
152
+ if (ih.type === 'event') {
153
+ try {
154
+ events.push(JSON.parse(dec.decode(payload)) as SentryEvent)
155
+ } catch {
156
+ /* skip */
157
+ }
158
+ }
159
+ }
160
+ return events
161
+ }
162
+
163
+ describe('buildEnvelope round-trip (server parser recovers our event)', () => {
164
+ it('recovers exactly one event with matching id + exception', () => {
165
+ const dsn = parseDsn(DSN)!
166
+ const event = buildSentryEvent({
167
+ error: new TypeError('cannot read properties of undefined'),
168
+ identity: { userId: 'sub-1', sessionId: 'sess-1', product: 'app' },
169
+ })
170
+ const envelope = buildEnvelope(event, dsn)
171
+ const recovered = parseEnvelopeOracle(new TextEncoder().encode(envelope))
172
+ expect(recovered).toHaveLength(1)
173
+ expect(recovered[0].event_id).toBe(event.event_id)
174
+ expect(recovered[0].exception!.values[0].type).toBe('TypeError')
175
+ expect(recovered[0].platform).toBe('javascript')
176
+ })
177
+
178
+ it('byte-length framing is correct with multi-byte characters', () => {
179
+ const dsn = parseDsn(DSN)!
180
+ const event = buildSentryEvent({
181
+ error: new Error('naïve café €500 — 日本語 fails'),
182
+ identity: { userId: 'sub-1' },
183
+ })
184
+ const envelope = buildEnvelope(event, dsn)
185
+ const recovered = parseEnvelopeOracle(new TextEncoder().encode(envelope))
186
+ expect(recovered).toHaveLength(1)
187
+ expect(recovered[0].exception!.values[0].value).toContain('日本語')
188
+ })
189
+ })
190
+
191
+ // ── grouping proof: a compact port of the server fingerprint (fingerprint.go).
192
+ // Two errors with the same crash frame group together; a different type does not.
193
+
194
+ function normalizeFunction(fn: string): string {
195
+ return fn.replace(/0x[0-9a-fA-F]+/g, '').trim()
196
+ }
197
+ function normalizeFilename(name: string): string {
198
+ name = name.trim().replace(/\\/g, '/')
199
+ const i = name.search(/[?#]/)
200
+ if (i >= 0) name = name.slice(0, i)
201
+ let segs = name.replace(/^\/+|\/+$/g, '').split('/')
202
+ if (segs.length > 2) segs = segs.slice(segs.length - 2)
203
+ return segs.map((s) => s.replace(/\b[0-9a-fA-F]{8,}\b/g, '*').replace(/\b\d[\d.,_]*\b/g, '*')).join('/')
204
+ }
205
+ function normalizeFrame(f: SentryFrame): string {
206
+ const fn = normalizeFunction(f.function ?? '')
207
+ const loc = f.module || normalizeFilename(f.filename ?? '') || normalizeFilename(f.abs_path ?? '')
208
+ if (fn && loc) return `${fn}@${loc}`
209
+ return fn || loc
210
+ }
211
+ function pickCrashFrame(frames: SentryFrame[]): SentryFrame | null {
212
+ if (frames.length === 0) return null
213
+ for (let i = frames.length - 1; i >= 0; i--) if (frames[i].in_app) return frames[i]
214
+ return frames[frames.length - 1]
215
+ }
216
+ function fingerprintOf(ev: SentryEvent): string {
217
+ const val = ev.exception!.values[0]
218
+ const parts: string[] = []
219
+ if (val.type) parts.push('type:' + val.type)
220
+ const frame = pickCrashFrame(val.stacktrace?.frames ?? [])
221
+ if (frame) {
222
+ const sig = normalizeFrame(frame)
223
+ if (sig) parts.push('frame:' + sig)
224
+ }
225
+ const h = createHash('sha256')
226
+ parts.forEach((p, i) => {
227
+ if (i > 0) h.update(Buffer.from([0]))
228
+ h.update(Buffer.from(p, 'utf8'))
229
+ })
230
+ return h.digest('hex')
231
+ }
232
+
233
+ describe('fingerprint grouping (port of server model)', () => {
234
+ const stack = [
235
+ 'TypeError: user missing',
236
+ ' at loadUser (https://app.hanzo.ai/app.9f3a2b1c.js:42:11)',
237
+ ' at render (https://app.hanzo.ai/app.9f3a2b1c.js:88:3)',
238
+ ].join('\n')
239
+
240
+ it('groups two errors with the same crash frame despite different ids in the message', () => {
241
+ const e1 = new Error('user 111 missing')
242
+ e1.stack = stack.replace('user missing', 'user 111 missing')
243
+ const e2 = new Error('user 222 missing')
244
+ e2.stack = stack.replace('user missing', 'user 222 missing')
245
+ const f1 = fingerprintOf(buildSentryEvent({ error: e1, identity: {} }))
246
+ const f2 = fingerprintOf(buildSentryEvent({ error: e2, identity: {} }))
247
+ expect(f1).toMatch(/^[0-9a-f]{64}$/)
248
+ expect(f1).toBe(f2)
249
+ })
250
+
251
+ it('does NOT group different exception types', () => {
252
+ const e1 = new TypeError('boom')
253
+ e1.stack = stack
254
+ const e2 = new RangeError('boom')
255
+ e2.stack = stack.replace('TypeError', 'RangeError')
256
+ const f1 = fingerprintOf(buildSentryEvent({ error: e1, identity: {} }))
257
+ const f2 = fingerprintOf(buildSentryEvent({ error: e2, identity: {} }))
258
+ expect(f1).not.toBe(f2)
259
+ })
260
+ })