@hanzo/event 0.3.19 → 0.3.21

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,200 @@
1
+ // The `$exception` product-event shape — pure builders, no I/O.
2
+ //
3
+ // An exception reaches TWO destinations, and they read different shapes:
4
+ //
5
+ // 1. The ERROR PLANE — a Sentry envelope to the DSN host (sentry.ts). Untouched
6
+ // by this module.
7
+ // 2. ERROR TRACKING in Hanzo Insights — reads the PRODUCT-EVENT plane
8
+ // (`insights.events WHERE event = '$exception'`) and expects the PostHog
9
+ // exception vocabulary: `$exception_list` plus the denormalized `$exception_*`
10
+ // properties. This module builds that.
11
+ //
12
+ // WHY THE FULLY-DERIVED SHAPE. Upstream, a server stage (Cymbal) symbolicates
13
+ // frames and computes `$exception_fingerprint`, `$exception_types`, `_values`,
14
+ // `_sources`, `_functions`, then REPLACES the property bag. That stage is not in
15
+ // Hanzo's path: the door writes `event.fact` and a materialized view projects it
16
+ // into `insights.events`, so nothing between the client and the warehouse derives
17
+ // anything. Whatever the product reads, the client has to have sent. Two
18
+ // consequences are load-bearing rather than cosmetic:
19
+ //
20
+ // • The issue query drops any event whose `$exception_fingerprint` is null, so an
21
+ // event without one is invisible no matter how well-formed the rest is.
22
+ // • `stacktrace.type` must be the literal 'resolved'; on any other value the
23
+ // renderer falls through both match arms and draws nothing under the header.
24
+ //
25
+ // If that server stage is ever put in front of this plane it replaces these
26
+ // properties wholesale, so sending them stays correct either way.
27
+ //
28
+ // FRAME ORDER is bottom-up: frames[0] is the entry point and the LAST frame is the
29
+ // throw site. framesFromStack (sentry.ts) already returns that order, which is why
30
+ // this module reuses it rather than re-parsing.
31
+
32
+ import { framesFromStack, normalizeError } from './sentry'
33
+ import type {
34
+ ExceptionEntry,
35
+ ExceptionFrame,
36
+ ExceptionProperties,
37
+ SentryFrame,
38
+ SentryLevel,
39
+ } from './types'
40
+
41
+ /** Cap on the frames carried per exception, matching the Sentry path's budget. */
42
+ const MAX_FRAMES = 50
43
+ /** Cap on `value`, so one enormous message cannot dominate a batch. The product
44
+ * truncates for display anyway; this bounds the wire. */
45
+ const MAX_VALUE = 4096
46
+
47
+ /**
48
+ * digest is a stable 32-hex-char (128-bit) content hash, computed synchronously.
49
+ *
50
+ * Grouping keys are needed on the capture path, which is synchronous and may be
51
+ * running inside an unload handler — SubtleCrypto is async and unavailable on
52
+ * insecure origins, so it cannot be used here. This is FNV-1a run over four seeds
53
+ * and concatenated. It is a GROUPING key, never a security boundary: it is not
54
+ * collision-resistant against a chosen-input adversary, and nothing authorizes or
55
+ * authenticates on it. The product treats the value as opaque.
56
+ */
57
+ export function digest(s: string): string {
58
+ let out = ''
59
+ for (const seed of [0x811c9dc5, 0x01000193, 0x9e3779b9, 0x85ebca6b]) {
60
+ let h = seed >>> 0
61
+ for (let i = 0; i < s.length; i++) {
62
+ h ^= s.charCodeAt(i)
63
+ // FNV prime 16777619, via shifts to stay in 32-bit integer math.
64
+ h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0
65
+ }
66
+ out += h.toString(16).padStart(8, '0')
67
+ }
68
+ return out
69
+ }
70
+
71
+ /**
72
+ * frameOf converts one parsed stack frame into the shape the product renders.
73
+ *
74
+ * The product's frame vocabulary is the POST-symbolication one (`mangled_name`,
75
+ * `source`, `line`, `column`), so that is what is emitted. `resolved` is false and
76
+ * honest: a browser bundle is minified and no symbol set has been uploaded, so the
77
+ * original name and line are genuinely unknown. Claiming `resolved: true` would
78
+ * trade an accurate "upload your symbol sets" hint for a misleading "source code is
79
+ * not available" error.
80
+ */
81
+ function frameOf(f: SentryFrame): ExceptionFrame {
82
+ const source = f.filename ?? ''
83
+ const name = f.function ?? '<anonymous>'
84
+ const line = f.lineno ?? 0
85
+ const column = f.colno ?? 0
86
+ return {
87
+ // Stable per code location, so the same frame groups and re-renders under one
88
+ // key. The '/part' suffix is the product's format for one raw frame expanding
89
+ // into several resolved ones; a browser frame is always part 0.
90
+ raw_id: `${digest(`${source}|${name}|${line}|${column}`)}/0`,
91
+ mangled_name: name,
92
+ source,
93
+ line,
94
+ column,
95
+ in_app: f.in_app ?? false,
96
+ lang: 'javascript',
97
+ resolved: false,
98
+ resolve_failure: 'no symbol set uploaded for this release',
99
+ resolved_name: null,
100
+ module: null,
101
+ }
102
+ }
103
+
104
+ /** truncate bounds a free-text field without splitting a surrogate pair. */
105
+ function truncate(s: string, max: number): string {
106
+ if (s.length <= max) return s
107
+ let end = max
108
+ const c = s.charCodeAt(end - 1)
109
+ // A high surrogate at the cut point would leave a lone half.
110
+ if (c >= 0xd800 && c <= 0xdbff) end--
111
+ return s.slice(0, end)
112
+ }
113
+
114
+ /**
115
+ * exceptionEntry builds the single `$exception_list` entry for a throwable.
116
+ *
117
+ * One entry, not a chain: `Error.cause` chaining is a distinct fact with its own
118
+ * ordering rules, and emitting it wrongly is worse than not emitting it.
119
+ */
120
+ export function exceptionEntry(
121
+ err: unknown,
122
+ opts: { handled: boolean; id: string },
123
+ ): ExceptionEntry {
124
+ const n = normalizeError(err)
125
+ const frames = framesFromStack(n.stack).slice(-MAX_FRAMES)
126
+ const entry: ExceptionEntry = {
127
+ id: opts.id,
128
+ type: n.name,
129
+ value: truncate(n.message, MAX_VALUE),
130
+ mechanism: {
131
+ type: 'generic',
132
+ handled: opts.handled,
133
+ // An exception the app reported by hand was constructed, not thrown by the
134
+ // runtime at a real call site.
135
+ synthetic: !(err instanceof Error),
136
+ },
137
+ }
138
+ if (frames.length > 0) {
139
+ // 'resolved' names the SHAPE of the frame list, not the symbolication state of
140
+ // any frame — the renderer only walks frames on this exact value.
141
+ entry.stacktrace = { type: 'resolved', frames: frames.map(frameOf) }
142
+ }
143
+ return entry
144
+ }
145
+
146
+ /**
147
+ * fingerprint is the issue grouping key.
148
+ *
149
+ * Keyed on exception type plus each in-app frame's function and source — the same
150
+ * pieces the server-side grouper records ("Exception Type", "Resolved function
151
+ * name", "Source file name"). Deliberately NOT the message: `Loading chunk 3324
152
+ * failed` and `Loading chunk 998 failed` are one bug, and grouping on message is
153
+ * precisely the mistake that made every distinct error string its own event name.
154
+ *
155
+ * Falls back to the type alone when no in-app frame survived, which keeps
156
+ * stackless errors (`Script error.`, cross-origin) in one issue instead of
157
+ * scattering them.
158
+ */
159
+ export function fingerprint(entry: ExceptionEntry): string {
160
+ const frames = entry.stacktrace?.frames ?? []
161
+ const pieces = frames
162
+ .filter((f) => f.in_app)
163
+ .map((f) => `${f.mangled_name}@${f.source}`)
164
+ return digest([entry.type, ...pieces].join('\n'))
165
+ }
166
+
167
+ /**
168
+ * exceptionProperties builds the full `$exception_*` property bag for one captured
169
+ * throwable — everything Error Tracking reads off the event.
170
+ *
171
+ * The denormalized arrays are ordered like `frames`: last element is the throw
172
+ * site, which is the element the issue list indexes at -1 for its source/function
173
+ * columns.
174
+ */
175
+ export function exceptionProperties(
176
+ err: unknown,
177
+ opts: { handled: boolean; id: string; level?: SentryLevel },
178
+ ): ExceptionProperties {
179
+ const entry = exceptionEntry(err, opts)
180
+ const frames = entry.stacktrace?.frames ?? []
181
+ const fp = fingerprint(entry)
182
+ return {
183
+ $exception_list: [entry],
184
+ $exception_fingerprint: fp,
185
+ // Records WHY this fingerprint holds. 'manual' is the honest value: the client
186
+ // supplied the key rather than a server grouper deriving one.
187
+ $exception_fingerprint_record: [{ type: 'manual' }],
188
+ $exception_type: entry.type,
189
+ $exception_message: entry.value,
190
+ $exception_level: opts.level ?? 'error',
191
+ $exception_handled: opts.handled,
192
+ $exception_synthetic: entry.mechanism?.synthetic ?? false,
193
+ $exception_types: [entry.type],
194
+ $exception_values: [entry.value],
195
+ $exception_sources: frames.map((f) => f.source).filter((s): s is string => !!s),
196
+ $exception_functions: frames
197
+ .map((f) => f.resolved_name ?? f.mangled_name)
198
+ .filter((s): s is string => !!s),
199
+ }
200
+ }
package/src/hz.test.ts CHANGED
@@ -247,18 +247,12 @@ describe('hz.js', () => {
247
247
  expect(post.url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
248
248
  })
249
249
 
250
- it('falls to the baked hanzo key when no key is declared on the hanzo cloud', () => {
251
- // publishable_key for all: a bare tag on the hanzo cloud still emits
252
- // attributed, rather than dropping unkeyed to $public.
250
+ it('sends no credential when no key is declared no baked literal', () => {
251
+ // The key is the surface's own, stamped into the tag by its deploy from KMS;
252
+ // a bare tag carries nothing, so it is honestly keyless rather than borrowing
253
+ // a hardcoded org credential.
253
254
  run.api!.flush()
254
255
  const post = run.posts.at(-1)!
255
- expect(post.headers.authorization).toBe('Bearer pk-live-c88649f1085fb6ad441d8a0072933a9b')
256
- })
257
-
258
- it('sends no credential when no key is declared and the host is not the hanzo cloud', () => {
259
- const r = runSnippet({ attrs: { 'data-host': 'https://api.zoo.ngo' } })
260
- r.api!.flush()
261
- const post = r.posts.at(-1)!
262
256
  expect(post.headers.authorization).toBeUndefined()
263
257
  expect(post.url).not.toContain('ingest_key')
264
258
  })
package/src/index.ts CHANGED
@@ -13,7 +13,8 @@ export { uuidv7, uuidv7Time } from './uid'
13
13
  export { PRODUCT_PROJECT, dsnForProduct } from './dsn'
14
14
  export type { ErrorIdentity } from './sentry'
15
15
  export { scrubText, redactSecrets, scrubPII } from './scrub'
16
- export { EVENTS, PAGEVIEW } from './events'
16
+ export { EVENTS, EXCEPTION, PAGEVIEW } from './events'
17
+ export { exceptionEntry, exceptionProperties, fingerprint, digest } from './exception'
17
18
  export type { EventName } from './events'
18
19
  export { GOALS, COHORTS } from './goals'
19
20
  export type { GoalDef, CohortDef } from './goals'
@@ -34,6 +35,9 @@ export type {
34
35
  Dsn,
35
36
  EventKind,
36
37
  Exception,
38
+ ExceptionEntry,
39
+ ExceptionFrame,
40
+ ExceptionProperties,
37
41
  SentryEvent,
38
42
  SentryFrame,
39
43
  SentryLevel,
package/src/types.ts CHANGED
@@ -22,6 +22,59 @@ export interface Exception {
22
22
  handled?: boolean
23
23
  }
24
24
 
25
+ /** One stack frame as Error Tracking renders it. The key names are the product's
26
+ * POST-symbolication vocabulary (`mangled_name`/`source`/`line`/`column`), which
27
+ * is what the issue view reads straight off `$exception_list`. */
28
+ export interface ExceptionFrame {
29
+ /** "<hash>/<part>" — stable per code location; the frame's identity. */
30
+ raw_id: string
31
+ /** The function name as it appears in the shipped bundle. */
32
+ mangled_name: string
33
+ /** File/URL the frame is in. */
34
+ source: string
35
+ line: number
36
+ column: number
37
+ /** First-party code. Frames without this are hidden by default in the product. */
38
+ in_app: boolean
39
+ lang: string
40
+ /** Whether a symbol set mapped this frame back to original source. */
41
+ resolved: boolean
42
+ resolve_failure?: string
43
+ resolved_name?: string | null
44
+ module?: string | null
45
+ }
46
+
47
+ /** One exception in `$exception_list`. */
48
+ export interface ExceptionEntry {
49
+ id: string
50
+ type: string
51
+ value: string
52
+ mechanism?: {
53
+ type: 'generic'
54
+ handled: boolean
55
+ synthetic?: boolean
56
+ }
57
+ /** `type` MUST be 'resolved' — the renderer draws frames on no other value. */
58
+ stacktrace?: { type: 'resolved'; frames: ExceptionFrame[] }
59
+ }
60
+
61
+ /** The `$exception_*` property bag Error Tracking reads off a `$exception` event. */
62
+ export interface ExceptionProperties {
63
+ $exception_list: ExceptionEntry[]
64
+ /** Issue grouping key. An event without one is dropped by the issue query. */
65
+ $exception_fingerprint: string
66
+ $exception_fingerprint_record: { type: 'manual' }[]
67
+ $exception_type: string
68
+ $exception_message: string
69
+ $exception_level: SentryLevel
70
+ $exception_handled: boolean
71
+ $exception_synthetic: boolean
72
+ $exception_types: string[]
73
+ $exception_values: string[]
74
+ $exception_sources: string[]
75
+ $exception_functions: string[]
76
+ }
77
+
25
78
  /** First-touch marketing attribution, parsed once and persisted. */
26
79
  export interface Attribution {
27
80
  utm: {
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  // The library version, stamped on every event (`libraryVersion`) and on the
2
2
  // Sentry `sdk` block. It lives alone so `sentry.ts` can read it without importing
3
3
  // `core.ts` — core imports sentry, so the reverse would be an import cycle.
4
- export const VERSION = '0.3.19'
4
+ export const VERSION = '0.3.21'