@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.
@@ -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
+ }