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

201 lines
8.0 KiB
Plaintext

// The `$exception` product-event shape — pure builders, no I/O.
//
// An exception reaches TWO destinations, and they read different shapes:
//
// 1. The ERROR PLANE — a Sentry envelope to the DSN host (sentry.ts). Untouched
// by this module.
// 2. ERROR TRACKING in Hanzo Insights — reads the PRODUCT-EVENT plane
// (`insights.events WHERE event = '$exception'`) and expects the PostHog
// exception vocabulary: `$exception_list` plus the denormalized `$exception_*`
// properties. This module builds that.
//
// WHY THE FULLY-DERIVED SHAPE. Upstream, a server stage (Cymbal) symbolicates
// frames and computes `$exception_fingerprint`, `$exception_types`, `_values`,
// `_sources`, `_functions`, then REPLACES the property bag. That stage is not in
// Hanzo's path: the ingest endpoint writes `event.fact` and a materialized view projects it
// into `insights.events`, so nothing between the client and the warehouse derives
// anything. Whatever the product reads, the client has to have sent. Two
// consequences are load-bearing rather than cosmetic:
//
// • The issue query drops any event whose `$exception_fingerprint` is null, so an
// event without one is invisible no matter how well-formed the rest is.
// • `stacktrace.type` must be the literal 'resolved'; on any other value the
// renderer falls through both match arms and draws nothing under the header.
//
// If that server stage is ever put in front of this plane it replaces these
// properties wholesale, so sending them stays correct either way.
//
// FRAME ORDER is bottom-up: frames[0] is the entry point and the LAST frame is the
// throw site. framesFromStack (sentry.ts) already returns that order, which is why
// this module reuses it rather than re-parsing.
import { framesFromStack, normalizeError } from './sentry'
import type {
ExceptionEntry,
ExceptionFrame,
ExceptionProperties,
SentryFrame,
SentryLevel,
} from './types'
/** Cap on the frames carried per exception, matching the Sentry path's budget. */
const MAX_FRAMES = 50
/** Cap on `value`, so one enormous message cannot dominate a batch. The product
* truncates for display anyway; this bounds the wire. */
const MAX_VALUE = 4096
/**
* digest is a stable 32-hex-char (128-bit) content hash, computed synchronously.
*
* Grouping keys are needed on the capture path, which is synchronous and may be
* running inside an unload handler — SubtleCrypto is async and unavailable on
* insecure origins, so it cannot be used here. This is FNV-1a run over four seeds
* and concatenated. It is a GROUPING key, never a security boundary: it is not
* collision-resistant against a chosen-input adversary, and nothing authorizes or
* authenticates on it. The product treats the value as opaque.
*/
export function digest(s: string): string {
let out = ''
for (const seed of [0x811c9dc5, 0x01000193, 0x9e3779b9, 0x85ebca6b]) {
let h = seed >>> 0
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i)
// FNV prime 16777619, via shifts to stay in 32-bit integer math.
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0
}
out += h.toString(16).padStart(8, '0')
}
return out
}
/**
* frameOf converts one parsed stack frame into the shape the product renders.
*
* The product's frame vocabulary is the POST-symbolication one (`mangled_name`,
* `source`, `line`, `column`), so that is what is emitted. `resolved` is false and
* honest: a browser bundle is minified and no symbol set has been uploaded, so the
* original name and line are genuinely unknown. Claiming `resolved: true` would
* trade an accurate "upload your symbol sets" hint for a misleading "source code is
* not available" error.
*/
function frameOf(f: SentryFrame): ExceptionFrame {
const source = f.filename ?? ''
const name = f.function ?? '<anonymous>'
const line = f.lineno ?? 0
const column = f.colno ?? 0
return {
// Stable per code location, so the same frame groups and re-renders under one
// key. The '/part' suffix is the product's format for one raw frame expanding
// into several resolved ones; a browser frame is always part 0.
raw_id: `${digest(`${source}|${name}|${line}|${column}`)}/0`,
mangled_name: name,
source,
line,
column,
in_app: f.in_app ?? false,
lang: 'javascript',
resolved: false,
resolve_failure: 'no symbol set uploaded for this release',
resolved_name: null,
module: null,
}
}
/** truncate bounds a free-text field without splitting a surrogate pair. */
function truncate(s: string, max: number): string {
if (s.length <= max) return s
let end = max
const c = s.charCodeAt(end - 1)
// A high surrogate at the cut point would leave a lone half.
if (c >= 0xd800 && c <= 0xdbff) end--
return s.slice(0, end)
}
/**
* exceptionEntry builds the single `$exception_list` entry for a throwable.
*
* One entry, not a chain: `Error.cause` chaining is a distinct fact with its own
* ordering rules, and emitting it wrongly is worse than not emitting it.
*/
export function exceptionEntry(
err: unknown,
opts: { handled: boolean; id: string },
): ExceptionEntry {
const n = normalizeError(err)
const frames = framesFromStack(n.stack).slice(-MAX_FRAMES)
const entry: ExceptionEntry = {
id: opts.id,
type: n.name,
value: truncate(n.message, MAX_VALUE),
mechanism: {
type: 'generic',
handled: opts.handled,
// An exception the app reported by hand was constructed, not thrown by the
// runtime at a real call site.
synthetic: !(err instanceof Error),
},
}
if (frames.length > 0) {
// 'resolved' names the SHAPE of the frame list, not the symbolication state of
// any frame — the renderer only walks frames on this exact value.
entry.stacktrace = { type: 'resolved', frames: frames.map(frameOf) }
}
return entry
}
/**
* fingerprint is the issue grouping key.
*
* Keyed on exception type plus each in-app frame's function and source — the same
* pieces the server-side grouper records ("Exception Type", "Resolved function
* name", "Source file name"). Deliberately NOT the message: `Loading chunk 3324
* failed` and `Loading chunk 998 failed` are one bug, and grouping on message is
* precisely the mistake that made every distinct error string its own event name.
*
* Falls back to the type alone when no in-app frame survived, which keeps
* stackless errors (`Script error.`, cross-origin) in one issue instead of
* scattering them.
*/
export function fingerprint(entry: ExceptionEntry): string {
const frames = entry.stacktrace?.frames ?? []
const pieces = frames
.filter((f) => f.in_app)
.map((f) => `${f.mangled_name}@${f.source}`)
return digest([entry.type, ...pieces].join('\n'))
}
/**
* exceptionProperties builds the full `$exception_*` property bag for one captured
* throwable — everything Error Tracking reads off the event.
*
* The denormalized arrays are ordered like `frames`: last element is the throw
* site, which is the element the issue list indexes at -1 for its source/function
* columns.
*/
export function exceptionProperties(
err: unknown,
opts: { handled: boolean; id: string; level?: SentryLevel },
): ExceptionProperties {
const entry = exceptionEntry(err, opts)
const frames = entry.stacktrace?.frames ?? []
const fp = fingerprint(entry)
return {
$exception_list: [entry],
$exception_fingerprint: fp,
// Records WHY this fingerprint holds. 'manual' is the honest value: the client
// supplied the key rather than a server grouper deriving one.
$exception_fingerprint_record: [{ type: 'manual' }],
$exception_type: entry.type,
$exception_message: entry.value,
$exception_level: opts.level ?? 'error',
$exception_handled: opts.handled,
$exception_synthetic: entry.mechanism?.synthetic ?? false,
$exception_types: [entry.type],
$exception_values: [entry.value],
$exception_sources: frames.map((f) => f.source).filter((s): s is string => !!s),
$exception_functions: frames
.map((f) => f.resolved_name ?? f.mangled_name)
.filter((s): s is string => !!s),
}
}