Files
agency/.pnpm-store/v3/files/af/b926c9cee91134f9aca550d296903ad35ad06def3884fe630c3799eb85c5908b9b89254c16ed7061154ce132c8f2665bc919094352f4d23fa7674df84306d5
hanzo-agent 5af6960b87 run project
Run: sess_f37640604ae254cb4d11dc6217f7b721
2026-09-30 00:04:56 +00:00

312 lines
11 KiB
Plaintext

// Pure builders for the error path: parse a Hanzo-minted DSN, turn a JS Error into
// a Sentry `event`, and frame it into a Sentry envelope. No I/O, no globals — the
// Analytics client (core.ts) wires these to identity + transport.
//
// The wire shapes are a from-scratch model of the PUBLIC, documented Sentry ingest
// protocol (develop.sentry.dev), verified against the Hanzo /v1/event ingest
// (o11y pkg/modules/errortracking/implerrortracking: parseEnvelope, SentryEvent,
// normalizeEvent, computeFingerprint). No upstream (FSL) code is used.
import { scrubText } from './scrub'
import { uuidv7 } from './uid'
import { VERSION } from './version'
import type {
CaptureErrorOptions,
Dsn,
SentryEvent,
SentryFrame,
SentryLevel,
} from './types'
/** Max stack frames kept — well under the server's 250 cap, plenty for grouping. */
const MAX_FRAMES = 50
/** Max stack lines examined, and max length of a line worth examining. Guards the
* frame regexes against a hostile `stack` string (see framesFromStack). */
const MAX_LINES = 500
const MAX_LINE_LEN = 2048
/** Max tag string length, so one huge property can't bloat the envelope. */
const MAX_TAG_LEN = 1024
/** Max tags copied from properties. */
const MAX_TAGS = 50
/** eventId mints a 32-hex-char id (no dashes) — the Sentry event_id shape. Same
* minter as everything else, just formatted for Sentry's wire. */
export function eventId(): string {
return uuidv7().replace(/-/g, '')
}
/** byteLen returns the UTF-8 byte length used for envelope item framing. */
function byteLen(s: string): number {
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(s).length
// Node fallback.
if (typeof Buffer !== 'undefined') return Buffer.byteLength(s, 'utf8')
return s.length
}
/**
* parseDsn parses "https://<version>:<hmac>@<host>/v1/event/<projectId>" into its
* public key + the derived envelope ingest URL. The key (which itself contains a
* ':') is taken verbatim as the userinfo — we do NOT split it as user:pass. The
* key rides ?sentry_key= (not the DSN in the body) because that is the credential
* channel the server trusts AND the only one sendBeacon can carry on unload.
* Returns null for anything malformed (fail-safe: the caller then stays inert).
*/
export function parseDsn(dsn: string | undefined | null): Dsn | null {
if (!dsn) return null
const m = /^(https?):\/\/([^@]+)@([^/]+)(\/.*)?$/.exec(dsn.trim())
if (!m) return null
const scheme = m[1]
const publicKey = m[2]
const host = m[3]
const path = m[4] ?? ''
if (!publicKey || !host) return null
const segs = path.split('/').filter(Boolean)
const projectId = segs.length > 0 ? segs[segs.length - 1] : ''
if (!projectId) return null
const origin = `${scheme}://${host}`
// THE DSN'S OWN PATH IS THE ADDRESS — derived here, never restated. It used to
// be rebuilt as a literal, which threw away the path the DSN carried and wrote
// the ingest address a second time; buildEnvelope wrote it a third. Three copies
// of one fact. dsnForProduct (dsn.ts) is the ONE place it is spelled; this
// follows it.
const base = `${origin}/${segs.map(encodeURIComponent).join('/')}`
const ingestUrl = `${base}/envelope/?sentry_key=${encodeURIComponent(publicKey)}`
return { publicKey, origin, projectId, base, ingestUrl }
}
const V8_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(?:(.+?):(\d+):(\d+)|([^)]+))\)?\s*$/
const MOZ_FRAME = /^\s*(?:(.*?)@)?(.+?):(\d+):(\d+)\s*$/
/** inApp marks a frame as the app's own code (vs vendor/runtime) for grouping. */
function inApp(file: string): boolean {
if (!file) return false
return !(
file.includes('node_modules') ||
file.startsWith('webpack-internal') ||
file.startsWith('webpack://') ||
file.startsWith('chrome-extension://') ||
file.startsWith('moz-extension://')
)
}
/**
* framesFromStack parses a browser Error.stack into Sentry frames, OLDEST-FIRST
* (Sentry orders caller->callee; the crash site is last — matching the server's
* pickCrashFrame). Handles both V8 ("at fn (file:li:co)") and
* Firefox/Safari ("fn@file:li:co"). Unparseable lines are skipped.
*/
export function framesFromStack(stack: string | undefined): SentryFrame[] {
if (!stack) return []
// Both frame regexes use lazy nested quantifiers, which backtrack badly on a
// long line that never matches. A stack is attacker-influenced (a thrown value
// can carry any `stack` string), so bound the work: skip absurd lines and stop
// after MAX_LINES. Only the innermost MAX_FRAMES are kept anyway.
const lines = stack.split('\n', MAX_LINES)
const frames: SentryFrame[] = []
for (const raw of lines) {
if (raw.length > MAX_LINE_LEN) continue
const line = raw.trimEnd()
if (!line) continue
// Header lines like "TypeError: x is not a function" match neither frame
// regex (no "at " prefix, no trailing :line:col) and are skipped naturally.
let fn: string | undefined
let file = ''
let lineno = 0
let colno = 0
const v = V8_FRAME.exec(line)
if (v) {
fn = v[1]
if (v[2]) {
file = v[2]
lineno = Number(v[3]) || 0
colno = Number(v[4]) || 0
} else {
file = (v[5] || '').trim()
}
} else {
const f = MOZ_FRAME.exec(line)
if (!f) continue
fn = f[1]
file = f[2]
lineno = Number(f[3]) || 0
colno = Number(f[4]) || 0
}
if (!file && !fn) continue
frames.push({
function: fn || '<anonymous>',
filename: file,
abs_path: file,
lineno,
colno,
in_app: inApp(file),
})
}
// Reverse to oldest-first and cap to the innermost MAX_FRAMES.
frames.reverse()
if (frames.length > MAX_FRAMES) return frames.slice(frames.length - MAX_FRAMES)
return frames
}
/** normalizeError coerces an unknown throwable into {name, message, stack}. */
export function normalizeError(err: unknown): { name: string; message: string; stack?: string } {
if (err instanceof Error) {
// `name`, `message` and `stack` are ordinary getters, and the thrown object is
// the least trustworthy input this library handles — any of them may throw.
// Losing the whole report to a hostile getter is not acceptable.
const name = read(err, 'name')
const message = read(err, 'message')
const stack = read(err, 'stack')
return {
name: typeof name === 'string' && name ? name : 'Error',
message: typeof message === 'string' && message ? message : str(err),
stack: typeof stack === 'string' ? stack : undefined,
}
}
if (typeof err === 'string') return { name: 'Error', message: err }
try {
return { name: 'Error', message: JSON.stringify(err) ?? str(err) }
} catch {
return { name: 'Error', message: str(err) }
}
}
/** read pulls a property off a possibly-hostile value without letting a throwing
* getter escape. */
function read(o: unknown, k: string): unknown {
try {
return (o as Record<string, unknown>)[k]
} catch {
return undefined
}
}
/** str coerces to a string without letting a throwing toString/Symbol.toPrimitive
* escape. */
function str(v: unknown): string {
try {
return String(v)
} catch {
return '[unstringifiable]'
}
}
/** Identity carried onto every error event — the SAME ids analytics uses. */
export interface ErrorIdentity {
/** OIDC sub (post-identify) or anon id. NEVER email/PII. */
userId?: string
sessionId?: string
product?: string
release?: string
environment?: string
}
export interface BuildEventInput {
error: unknown
options?: CaptureErrorOptions
identity: ErrorIdentity
capturePII?: boolean
/** Injectable for deterministic tests. */
now?: number
id?: string
}
function coerceTag(v: unknown): string {
const s = typeof v === 'string' ? v : (() => {
try {
return JSON.stringify(v) ?? String(v)
} catch {
try {
return String(v) // circular — fall back to the primitive coercion
} catch {
return '[unstringifiable]' // ...which a throwing toString can also refuse
}
}
})()
return s.length > MAX_TAG_LEN ? s.slice(0, MAX_TAG_LEN) : s
}
/**
* buildSentryEvent turns a throwable + identity into a Sentry `event`. The message
* (the leak surface) is scrubbed client-side; the user is ONLY the stable subject
* id — never email/username/ip. Level defaults to error, or fatal for uncaught.
*/
export function buildSentryEvent(input: BuildEventInput): SentryEvent {
const { error, options = {}, identity, capturePII = false } = input
const norm = normalizeError(error)
const handled = options.handled !== false
const level: SentryLevel = options.level ?? (handled ? 'error' : 'fatal')
const tags: Record<string, string> = { handled: String(handled) }
if (identity.product) tags.product = identity.product
if (identity.sessionId) tags.session = identity.sessionId
// Tags come from arbitrary caller `properties`. Read each key in isolation:
// Object.entries() invokes every getter at once, so one throwing getter would
// cost us the entire envelope — losing the stack trace to save a tag. Keys are
// optional; the exception is not.
try {
const props = (options.properties ?? {}) as Record<string, unknown>
let n = 0
for (const k of Object.keys(props)) {
if (n >= MAX_TAGS) break
try {
const val = props[k]
if (val === undefined || val === null) continue
tags[k] = scrubText(coerceTag(val), capturePII)
n++
} catch {
continue // throwing getter or unstringifiable value — skip this key only
}
}
} catch {
/* unenumerable/exotic properties object — ship the exception without tags */
}
const event: SentryEvent = {
event_id: input.id ?? eventId(),
timestamp: (input.now ?? Date.now()) / 1000,
platform: 'javascript',
level,
logger: identity.product,
environment: identity.environment,
release: identity.release,
exception: {
values: [
{
type: norm.name,
value: scrubText(norm.message, capturePII),
stacktrace: { frames: framesFromStack(norm.stack) },
},
],
},
tags,
sdk: { name: '@hanzo/event', version: VERSION },
}
if (identity.userId) event.user = { id: identity.userId }
return event
}
/**
* buildEnvelope frames a Sentry event into a newline-delimited envelope:
*
* {"event_id","dsn","sent_at"}\n
* {"type":"event","content_type":"application/json","length":N}\n
* <event json>\n
*
* The item is length-delimited (N = UTF-8 byte length) — the framing the server's
* parseEnvelope reads first (falling back to newline-delimited otherwise).
*/
export function buildEnvelope(event: SentryEvent, dsn: Dsn, sentAt?: string): string {
const payload = JSON.stringify(event)
const header = JSON.stringify({
event_id: event.event_id,
dsn: dsn.base,
sent_at: sentAt ?? new Date().toISOString(),
})
const itemHeader = JSON.stringify({
type: 'event',
content_type: 'application/json',
length: byteLen(payload),
})
return `${header}\n${itemHeader}\n${payload}\n`
}