@hanzo/event 0.3.1 → 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.
@@ -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
+ })
package/src/sentry.ts ADDED
@@ -0,0 +1,279 @@
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
+ return { name: err.name || 'Error', message: err.message || String(err), stack: err.stack }
152
+ }
153
+ if (typeof err === 'string') return { name: 'Error', message: err }
154
+ try {
155
+ return { name: 'Error', message: JSON.stringify(err) }
156
+ } catch {
157
+ return { name: 'Error', message: String(err) }
158
+ }
159
+ }
160
+
161
+ /** Identity carried onto every error event — the SAME ids analytics uses. */
162
+ export interface ErrorIdentity {
163
+ /** OIDC sub (post-identify) or anon id. NEVER email/PII. */
164
+ userId?: string
165
+ sessionId?: string
166
+ product?: string
167
+ release?: string
168
+ environment?: string
169
+ }
170
+
171
+ export interface BuildEventInput {
172
+ error: unknown
173
+ options?: CaptureErrorOptions
174
+ identity: ErrorIdentity
175
+ capturePII?: boolean
176
+ /** Injectable for deterministic tests. */
177
+ now?: number
178
+ id?: string
179
+ }
180
+
181
+ function coerceTag(v: unknown): string {
182
+ const s = typeof v === 'string' ? v : (() => {
183
+ try {
184
+ return JSON.stringify(v) ?? String(v)
185
+ } catch {
186
+ try {
187
+ return String(v) // circular — fall back to the primitive coercion
188
+ } catch {
189
+ return '[unstringifiable]' // ...which a throwing toString can also refuse
190
+ }
191
+ }
192
+ })()
193
+ return s.length > MAX_TAG_LEN ? s.slice(0, MAX_TAG_LEN) : s
194
+ }
195
+
196
+ /**
197
+ * buildSentryEvent turns a throwable + identity into a Sentry `event`. The message
198
+ * (the leak surface) is scrubbed client-side; the user is ONLY the stable subject
199
+ * id — never email/username/ip. Level defaults to error, or fatal for uncaught.
200
+ */
201
+ export function buildSentryEvent(input: BuildEventInput): SentryEvent {
202
+ const { error, options = {}, identity, capturePII = false } = input
203
+ const norm = normalizeError(error)
204
+ const handled = options.handled !== false
205
+ const level: SentryLevel = options.level ?? (handled ? 'error' : 'fatal')
206
+
207
+ const tags: Record<string, string> = { handled: String(handled) }
208
+ if (identity.product) tags.product = identity.product
209
+ if (identity.sessionId) tags.session = identity.sessionId
210
+ // Tags come from arbitrary caller `properties`. Read each key in isolation:
211
+ // Object.entries() invokes every getter at once, so one throwing getter would
212
+ // cost us the entire envelope — losing the stack trace to save a tag. Keys are
213
+ // optional; the exception is not.
214
+ try {
215
+ const props = (options.properties ?? {}) as Record<string, unknown>
216
+ let n = 0
217
+ for (const k of Object.keys(props)) {
218
+ if (n >= MAX_TAGS) break
219
+ try {
220
+ const val = props[k]
221
+ if (val === undefined || val === null) continue
222
+ tags[k] = scrubText(coerceTag(val), capturePII)
223
+ n++
224
+ } catch {
225
+ continue // throwing getter or unstringifiable value — skip this key only
226
+ }
227
+ }
228
+ } catch {
229
+ /* unenumerable/exotic properties object — ship the exception without tags */
230
+ }
231
+
232
+ const event: SentryEvent = {
233
+ event_id: input.id ?? eventId(),
234
+ timestamp: (input.now ?? Date.now()) / 1000,
235
+ platform: 'javascript',
236
+ level,
237
+ logger: identity.product,
238
+ environment: identity.environment,
239
+ release: identity.release,
240
+ exception: {
241
+ values: [
242
+ {
243
+ type: norm.name,
244
+ value: scrubText(norm.message, capturePII),
245
+ stacktrace: { frames: framesFromStack(norm.stack) },
246
+ },
247
+ ],
248
+ },
249
+ tags,
250
+ sdk: { name: '@hanzo/event', version: VERSION },
251
+ }
252
+ if (identity.userId) event.user = { id: identity.userId }
253
+ return event
254
+ }
255
+
256
+ /**
257
+ * buildEnvelope frames a Sentry event into a newline-delimited envelope:
258
+ *
259
+ * {"event_id","dsn","sent_at"}\n
260
+ * {"type":"event","content_type":"application/json","length":N}\n
261
+ * <event json>\n
262
+ *
263
+ * The item is length-delimited (N = UTF-8 byte length) — the framing the server's
264
+ * parseEnvelope reads first (falling back to newline-delimited otherwise).
265
+ */
266
+ export function buildEnvelope(event: SentryEvent, dsn: Dsn, sentAt?: string): string {
267
+ const payload = JSON.stringify(event)
268
+ const header = JSON.stringify({
269
+ event_id: event.event_id,
270
+ dsn: `${dsn.origin}/v1/sentry/${dsn.projectId}`,
271
+ sent_at: sentAt ?? new Date().toISOString(),
272
+ })
273
+ const itemHeader = JSON.stringify({
274
+ type: 'event',
275
+ content_type: 'application/json',
276
+ length: byteLen(payload),
277
+ })
278
+ return `${header}\n${itemHeader}\n${payload}\n`
279
+ }