@hanzo/event 0.3.48 → 0.3.49

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