@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/README.md +61 -17
- package/dist/core-B1XEdWLd.d.cts +296 -0
- package/dist/core-B1XEdWLd.d.ts +296 -0
- package/dist/{index.js → index.cjs} +401 -20
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +65 -3
- package/dist/index.d.ts +65 -3
- package/dist/index.mjs +393 -19
- package/dist/index.mjs.map +1 -1
- package/dist/{react.js → react.cjs} +394 -20
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.mjs +392 -18
- package/dist/react.mjs.map +1 -1
- package/package.json +19 -9
- package/src/core.test.ts +258 -5
- package/src/core.ts +225 -37
- package/src/index.ts +8 -0
- package/src/scrub.test.ts +96 -0
- package/src/scrub.ts +112 -0
- package/src/sentry.test.ts +260 -0
- package/src/sentry.ts +279 -0
- package/src/types.ts +117 -15
- package/src/version.ts +4 -0
- package/dist/core-CrbiAQhN.d.cts +0 -186
- package/dist/core-CrbiAQhN.d.ts +0 -186
- package/dist/index.js.map +0 -1
- package/dist/react.js.map +0 -1
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
|
+
})
|