@hanzo/event 0.3.1 → 0.3.3
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 +81 -17
- package/dist/core-B1XEdWLd.d.cts +296 -0
- package/dist/core-B1XEdWLd.d.ts +296 -0
- package/dist/index.cjs +590 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +177 -6
- package/dist/index.d.ts +177 -6
- package/dist/index.mjs +581 -64
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +413 -25
- 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 +413 -25
- package/dist/react.mjs.map +1 -1
- package/package.json +2 -2
- package/src/core.test.ts +258 -5
- package/src/core.ts +237 -45
- package/src/events.ts +30 -1
- package/src/funnels.test.ts +92 -0
- package/src/funnels.ts +154 -0
- package/src/goals.ts +25 -11
- package/src/index.ts +10 -0
- package/src/scrub.test.ts +96 -0
- package/src/scrub.ts +117 -0
- package/src/sentry.test.ts +312 -0
- package/src/sentry.ts +309 -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
|
@@ -0,0 +1,312 @@
|
|
|
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
|
+
})
|
|
261
|
+
|
|
262
|
+
// ── hostile thrown objects ─────────────────────────────────────────────────
|
|
263
|
+
//
|
|
264
|
+
// `name`, `message` and `stack` are ordinary getters. A thrown object is free to
|
|
265
|
+
// define any of them to throw, and the thrown value is the least trustworthy
|
|
266
|
+
// input this library handles. normalizeError must be TOTAL — buildSentryEvent
|
|
267
|
+
// throwing here meant the crash report was lost on both planes.
|
|
268
|
+
|
|
269
|
+
describe('normalizeError survives hostile input', () => {
|
|
270
|
+
const bomb = (prop: string) => {
|
|
271
|
+
const e = new Error('real message')
|
|
272
|
+
Object.defineProperty(e, prop, {
|
|
273
|
+
get() {
|
|
274
|
+
throw new Error(prop + ' bomb')
|
|
275
|
+
},
|
|
276
|
+
configurable: true,
|
|
277
|
+
})
|
|
278
|
+
return e
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const prop of ['stack', 'message', 'name']) {
|
|
282
|
+
it(`a throwing ${prop} getter does not throw`, () => {
|
|
283
|
+
expect(() => normalizeError(bomb(prop))).not.toThrow()
|
|
284
|
+
const n = normalizeError(bomb(prop))
|
|
285
|
+
expect(typeof n.name).toBe('string')
|
|
286
|
+
expect(typeof n.message).toBe('string')
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
it('buildSentryEvent still produces a usable event from a getter bomb', () => {
|
|
291
|
+
const ev = buildSentryEvent({ error: bomb('stack'), identity: { product: 'test' } })
|
|
292
|
+
expect(ev.exception?.values[0].type).toBe('Error')
|
|
293
|
+
// The message survived because only `stack` was hostile.
|
|
294
|
+
expect(ev.exception?.values[0].value).toBe('real message')
|
|
295
|
+
expect(ev.exception?.values[0].stacktrace?.frames).toEqual([])
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('an object with a throwing toString is still reportable', () => {
|
|
299
|
+
const evil = {
|
|
300
|
+
toString() {
|
|
301
|
+
throw new Error('toString bomb')
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
expect(() => normalizeError(evil)).not.toThrow()
|
|
305
|
+
expect(() => buildSentryEvent({ error: evil, identity: {} })).not.toThrow()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('a frozen/null-prototype throwable is still reportable', () => {
|
|
309
|
+
const weird = Object.freeze(Object.create(null)) as unknown
|
|
310
|
+
expect(() => buildSentryEvent({ error: weird, identity: {} })).not.toThrow()
|
|
311
|
+
})
|
|
312
|
+
})
|
package/src/sentry.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// Pure builders for the error path: parse a Hanzo-minted DSN, turn a JS Error into
|
|
2
|
+
// a Sentry `event`, and frame it into a Sentry envelope. No I/O, no globals — the
|
|
3
|
+
// Analytics client (core.ts) wires these to identity + transport.
|
|
4
|
+
//
|
|
5
|
+
// The wire shapes are a from-scratch model of the PUBLIC, documented Sentry ingest
|
|
6
|
+
// protocol (develop.sentry.dev), verified against the Hanzo /v1/sentry ingest
|
|
7
|
+
// (o11y pkg/modules/errortracking/implerrortracking: parseEnvelope, SentryEvent,
|
|
8
|
+
// normalizeEvent, computeFingerprint). No upstream (FSL) code is used.
|
|
9
|
+
|
|
10
|
+
import { scrubText } from './scrub'
|
|
11
|
+
import { VERSION } from './version'
|
|
12
|
+
import type {
|
|
13
|
+
CaptureErrorOptions,
|
|
14
|
+
Dsn,
|
|
15
|
+
SentryEvent,
|
|
16
|
+
SentryFrame,
|
|
17
|
+
SentryLevel,
|
|
18
|
+
} from './types'
|
|
19
|
+
|
|
20
|
+
/** Max stack frames kept — well under the server's 250 cap, plenty for grouping. */
|
|
21
|
+
const MAX_FRAMES = 50
|
|
22
|
+
/** Max stack lines examined, and max length of a line worth examining. Guards the
|
|
23
|
+
* frame regexes against a hostile `stack` string (see framesFromStack). */
|
|
24
|
+
const MAX_LINES = 500
|
|
25
|
+
const MAX_LINE_LEN = 2048
|
|
26
|
+
/** Max tag string length, so one huge property can't bloat the envelope. */
|
|
27
|
+
const MAX_TAG_LEN = 1024
|
|
28
|
+
/** Max tags copied from properties. */
|
|
29
|
+
const MAX_TAGS = 50
|
|
30
|
+
|
|
31
|
+
/** eventId mints a 32-hex-char id (no dashes) — the Sentry event_id shape. */
|
|
32
|
+
export function eventId(): string {
|
|
33
|
+
const c = typeof crypto !== 'undefined' ? crypto : undefined
|
|
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
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** byteLen returns the UTF-8 byte length used for envelope item framing. */
|
|
41
|
+
function byteLen(s: string): number {
|
|
42
|
+
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(s).length
|
|
43
|
+
// Node fallback.
|
|
44
|
+
if (typeof Buffer !== 'undefined') return Buffer.byteLength(s, 'utf8')
|
|
45
|
+
return s.length
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* parseDsn parses "https://<version>:<hmac>@<host>/v1/sentry/<projectId>" into its
|
|
50
|
+
* public key + the derived envelope ingest URL. The key (which itself contains a
|
|
51
|
+
* ':') is taken verbatim as the userinfo — we do NOT split it as user:pass. The
|
|
52
|
+
* key rides ?sentry_key= (not the DSN in the body) because that is the credential
|
|
53
|
+
* channel the server trusts AND the only one sendBeacon can carry on unload.
|
|
54
|
+
* Returns null for anything malformed (fail-safe: the caller then stays inert).
|
|
55
|
+
*/
|
|
56
|
+
export function parseDsn(dsn: string | undefined | null): Dsn | null {
|
|
57
|
+
if (!dsn) return null
|
|
58
|
+
const m = /^(https?):\/\/([^@]+)@([^/]+)(\/.*)?$/.exec(dsn.trim())
|
|
59
|
+
if (!m) return null
|
|
60
|
+
const scheme = m[1]
|
|
61
|
+
const publicKey = m[2]
|
|
62
|
+
const host = m[3]
|
|
63
|
+
const path = m[4] ?? ''
|
|
64
|
+
if (!publicKey || !host) return null
|
|
65
|
+
const segs = path.split('/').filter(Boolean)
|
|
66
|
+
const projectId = segs.length > 0 ? segs[segs.length - 1] : ''
|
|
67
|
+
if (!projectId) return null
|
|
68
|
+
const origin = `${scheme}://${host}`
|
|
69
|
+
const ingestUrl =
|
|
70
|
+
`${origin}/v1/sentry/${encodeURIComponent(projectId)}/envelope/` +
|
|
71
|
+
`?sentry_key=${encodeURIComponent(publicKey)}`
|
|
72
|
+
return { publicKey, origin, projectId, ingestUrl }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const V8_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(?:(.+?):(\d+):(\d+)|([^)]+))\)?\s*$/
|
|
76
|
+
const MOZ_FRAME = /^\s*(?:(.*?)@)?(.+?):(\d+):(\d+)\s*$/
|
|
77
|
+
|
|
78
|
+
/** inApp marks a frame as the app's own code (vs vendor/runtime) for grouping. */
|
|
79
|
+
function inApp(file: string): boolean {
|
|
80
|
+
if (!file) return false
|
|
81
|
+
return !(
|
|
82
|
+
file.includes('node_modules') ||
|
|
83
|
+
file.startsWith('webpack-internal') ||
|
|
84
|
+
file.startsWith('webpack://') ||
|
|
85
|
+
file.startsWith('chrome-extension://') ||
|
|
86
|
+
file.startsWith('moz-extension://')
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* framesFromStack parses a browser Error.stack into Sentry frames, OLDEST-FIRST
|
|
92
|
+
* (Sentry orders caller->callee; the crash site is last — matching the server's
|
|
93
|
+
* pickCrashFrame). Handles both V8 ("at fn (file:li:co)") and
|
|
94
|
+
* Firefox/Safari ("fn@file:li:co"). Unparseable lines are skipped.
|
|
95
|
+
*/
|
|
96
|
+
export function framesFromStack(stack: string | undefined): SentryFrame[] {
|
|
97
|
+
if (!stack) return []
|
|
98
|
+
// Both frame regexes use lazy nested quantifiers, which backtrack badly on a
|
|
99
|
+
// long line that never matches. A stack is attacker-influenced (a thrown value
|
|
100
|
+
// can carry any `stack` string), so bound the work: skip absurd lines and stop
|
|
101
|
+
// after MAX_LINES. Only the innermost MAX_FRAMES are kept anyway.
|
|
102
|
+
const lines = stack.split('\n', MAX_LINES)
|
|
103
|
+
const frames: SentryFrame[] = []
|
|
104
|
+
for (const raw of lines) {
|
|
105
|
+
if (raw.length > MAX_LINE_LEN) continue
|
|
106
|
+
const line = raw.trimEnd()
|
|
107
|
+
if (!line) continue
|
|
108
|
+
// Header lines like "TypeError: x is not a function" match neither frame
|
|
109
|
+
// regex (no "at " prefix, no trailing :line:col) and are skipped naturally.
|
|
110
|
+
let fn: string | undefined
|
|
111
|
+
let file = ''
|
|
112
|
+
let lineno = 0
|
|
113
|
+
let colno = 0
|
|
114
|
+
const v = V8_FRAME.exec(line)
|
|
115
|
+
if (v) {
|
|
116
|
+
fn = v[1]
|
|
117
|
+
if (v[2]) {
|
|
118
|
+
file = v[2]
|
|
119
|
+
lineno = Number(v[3]) || 0
|
|
120
|
+
colno = Number(v[4]) || 0
|
|
121
|
+
} else {
|
|
122
|
+
file = (v[5] || '').trim()
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
const f = MOZ_FRAME.exec(line)
|
|
126
|
+
if (!f) continue
|
|
127
|
+
fn = f[1]
|
|
128
|
+
file = f[2]
|
|
129
|
+
lineno = Number(f[3]) || 0
|
|
130
|
+
colno = Number(f[4]) || 0
|
|
131
|
+
}
|
|
132
|
+
if (!file && !fn) continue
|
|
133
|
+
frames.push({
|
|
134
|
+
function: fn || '<anonymous>',
|
|
135
|
+
filename: file,
|
|
136
|
+
abs_path: file,
|
|
137
|
+
lineno,
|
|
138
|
+
colno,
|
|
139
|
+
in_app: inApp(file),
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
// Reverse to oldest-first and cap to the innermost MAX_FRAMES.
|
|
143
|
+
frames.reverse()
|
|
144
|
+
if (frames.length > MAX_FRAMES) return frames.slice(frames.length - MAX_FRAMES)
|
|
145
|
+
return frames
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** normalizeError coerces an unknown throwable into {name, message, stack}. */
|
|
149
|
+
export function normalizeError(err: unknown): { name: string; message: string; stack?: string } {
|
|
150
|
+
if (err instanceof Error) {
|
|
151
|
+
// `name`, `message` and `stack` are ordinary getters, and the thrown object is
|
|
152
|
+
// the least trustworthy input this library handles — any of them may throw.
|
|
153
|
+
// Losing the whole report to a hostile getter is not acceptable.
|
|
154
|
+
const name = read(err, 'name')
|
|
155
|
+
const message = read(err, 'message')
|
|
156
|
+
const stack = read(err, 'stack')
|
|
157
|
+
return {
|
|
158
|
+
name: typeof name === 'string' && name ? name : 'Error',
|
|
159
|
+
message: typeof message === 'string' && message ? message : str(err),
|
|
160
|
+
stack: typeof stack === 'string' ? stack : undefined,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (typeof err === 'string') return { name: 'Error', message: err }
|
|
164
|
+
try {
|
|
165
|
+
return { name: 'Error', message: JSON.stringify(err) ?? str(err) }
|
|
166
|
+
} catch {
|
|
167
|
+
return { name: 'Error', message: str(err) }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** read pulls a property off a possibly-hostile value without letting a throwing
|
|
172
|
+
* getter escape. */
|
|
173
|
+
function read(o: unknown, k: string): unknown {
|
|
174
|
+
try {
|
|
175
|
+
return (o as Record<string, unknown>)[k]
|
|
176
|
+
} catch {
|
|
177
|
+
return undefined
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** str coerces to a string without letting a throwing toString/Symbol.toPrimitive
|
|
182
|
+
* escape. */
|
|
183
|
+
function str(v: unknown): string {
|
|
184
|
+
try {
|
|
185
|
+
return String(v)
|
|
186
|
+
} catch {
|
|
187
|
+
return '[unstringifiable]'
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Identity carried onto every error event — the SAME ids analytics uses. */
|
|
192
|
+
export interface ErrorIdentity {
|
|
193
|
+
/** OIDC sub (post-identify) or anon id. NEVER email/PII. */
|
|
194
|
+
userId?: string
|
|
195
|
+
sessionId?: string
|
|
196
|
+
product?: string
|
|
197
|
+
release?: string
|
|
198
|
+
environment?: string
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface BuildEventInput {
|
|
202
|
+
error: unknown
|
|
203
|
+
options?: CaptureErrorOptions
|
|
204
|
+
identity: ErrorIdentity
|
|
205
|
+
capturePII?: boolean
|
|
206
|
+
/** Injectable for deterministic tests. */
|
|
207
|
+
now?: number
|
|
208
|
+
id?: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function coerceTag(v: unknown): string {
|
|
212
|
+
const s = typeof v === 'string' ? v : (() => {
|
|
213
|
+
try {
|
|
214
|
+
return JSON.stringify(v) ?? String(v)
|
|
215
|
+
} catch {
|
|
216
|
+
try {
|
|
217
|
+
return String(v) // circular — fall back to the primitive coercion
|
|
218
|
+
} catch {
|
|
219
|
+
return '[unstringifiable]' // ...which a throwing toString can also refuse
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
})()
|
|
223
|
+
return s.length > MAX_TAG_LEN ? s.slice(0, MAX_TAG_LEN) : s
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* buildSentryEvent turns a throwable + identity into a Sentry `event`. The message
|
|
228
|
+
* (the leak surface) is scrubbed client-side; the user is ONLY the stable subject
|
|
229
|
+
* id — never email/username/ip. Level defaults to error, or fatal for uncaught.
|
|
230
|
+
*/
|
|
231
|
+
export function buildSentryEvent(input: BuildEventInput): SentryEvent {
|
|
232
|
+
const { error, options = {}, identity, capturePII = false } = input
|
|
233
|
+
const norm = normalizeError(error)
|
|
234
|
+
const handled = options.handled !== false
|
|
235
|
+
const level: SentryLevel = options.level ?? (handled ? 'error' : 'fatal')
|
|
236
|
+
|
|
237
|
+
const tags: Record<string, string> = { handled: String(handled) }
|
|
238
|
+
if (identity.product) tags.product = identity.product
|
|
239
|
+
if (identity.sessionId) tags.session = identity.sessionId
|
|
240
|
+
// Tags come from arbitrary caller `properties`. Read each key in isolation:
|
|
241
|
+
// Object.entries() invokes every getter at once, so one throwing getter would
|
|
242
|
+
// cost us the entire envelope — losing the stack trace to save a tag. Keys are
|
|
243
|
+
// optional; the exception is not.
|
|
244
|
+
try {
|
|
245
|
+
const props = (options.properties ?? {}) as Record<string, unknown>
|
|
246
|
+
let n = 0
|
|
247
|
+
for (const k of Object.keys(props)) {
|
|
248
|
+
if (n >= MAX_TAGS) break
|
|
249
|
+
try {
|
|
250
|
+
const val = props[k]
|
|
251
|
+
if (val === undefined || val === null) continue
|
|
252
|
+
tags[k] = scrubText(coerceTag(val), capturePII)
|
|
253
|
+
n++
|
|
254
|
+
} catch {
|
|
255
|
+
continue // throwing getter or unstringifiable value — skip this key only
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
/* unenumerable/exotic properties object — ship the exception without tags */
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const event: SentryEvent = {
|
|
263
|
+
event_id: input.id ?? eventId(),
|
|
264
|
+
timestamp: (input.now ?? Date.now()) / 1000,
|
|
265
|
+
platform: 'javascript',
|
|
266
|
+
level,
|
|
267
|
+
logger: identity.product,
|
|
268
|
+
environment: identity.environment,
|
|
269
|
+
release: identity.release,
|
|
270
|
+
exception: {
|
|
271
|
+
values: [
|
|
272
|
+
{
|
|
273
|
+
type: norm.name,
|
|
274
|
+
value: scrubText(norm.message, capturePII),
|
|
275
|
+
stacktrace: { frames: framesFromStack(norm.stack) },
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
},
|
|
279
|
+
tags,
|
|
280
|
+
sdk: { name: '@hanzo/event', version: VERSION },
|
|
281
|
+
}
|
|
282
|
+
if (identity.userId) event.user = { id: identity.userId }
|
|
283
|
+
return event
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* buildEnvelope frames a Sentry event into a newline-delimited envelope:
|
|
288
|
+
*
|
|
289
|
+
* {"event_id","dsn","sent_at"}\n
|
|
290
|
+
* {"type":"event","content_type":"application/json","length":N}\n
|
|
291
|
+
* <event json>\n
|
|
292
|
+
*
|
|
293
|
+
* The item is length-delimited (N = UTF-8 byte length) — the framing the server's
|
|
294
|
+
* parseEnvelope reads first (falling back to newline-delimited otherwise).
|
|
295
|
+
*/
|
|
296
|
+
export function buildEnvelope(event: SentryEvent, dsn: Dsn, sentAt?: string): string {
|
|
297
|
+
const payload = JSON.stringify(event)
|
|
298
|
+
const header = JSON.stringify({
|
|
299
|
+
event_id: event.event_id,
|
|
300
|
+
dsn: `${dsn.origin}/v1/sentry/${dsn.projectId}`,
|
|
301
|
+
sent_at: sentAt ?? new Date().toISOString(),
|
|
302
|
+
})
|
|
303
|
+
const itemHeader = JSON.stringify({
|
|
304
|
+
type: 'event',
|
|
305
|
+
content_type: 'application/json',
|
|
306
|
+
length: byteLen(payload),
|
|
307
|
+
})
|
|
308
|
+
return `${header}\n${itemHeader}\n${payload}\n`
|
|
309
|
+
}
|