@hanzo/event 0.3.18 → 0.3.20

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hanzo/event",
3
- "version": "0.3.18",
4
- "description": "Hanzo Event the ONE telemetry client. Emits pageview/event/identify/group to the Hanzo Cloud event stream (POST /v1/event), AND reports errors to Sentry as real Sentry envelopes the error plane needs a DSN, without one nothing reaches Sentry. First-touch attribution, beacon-on-unload, auto error capture, client-side secret/PII scrubbing, a shared event + goal vocabulary. Subsumes @sentry.",
3
+ "version": "0.3.20",
4
+ "description": "Hanzo Event \u2014 the ONE telemetry client. Emits pageview/event/identify/group to the Hanzo Cloud event stream (POST /v1/event), AND reports errors to Sentry as real Sentry envelopes \u2014 the error plane needs a DSN, without one nothing reaches Sentry. First-touch attribution, beacon-on-unload, auto error capture, client-side secret/PII scrubbing, a shared event + goal vocabulary. Subsumes @sentry.",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/",
7
7
  "access": "public",
@@ -37,6 +37,13 @@
37
37
  "funnel",
38
38
  "hanzo"
39
39
  ],
40
+ "scripts": {
41
+ "build": "tsup",
42
+ "dev": "tsup --watch",
43
+ "test": "vitest run",
44
+ "typecheck": "tsc --noEmit",
45
+ "clean": "rm -rf dist"
46
+ },
40
47
  "exports": {
41
48
  ".": {
42
49
  "import": {
@@ -74,12 +81,5 @@
74
81
  "tsup": "^8.5.1",
75
82
  "typescript": "^5.9.3",
76
83
  "vitest": "^4.1.0"
77
- },
78
- "scripts": {
79
- "build": "tsup",
80
- "dev": "tsup --watch",
81
- "test": "vitest run",
82
- "typecheck": "tsc --noEmit",
83
- "clean": "rm -rf dist"
84
84
  }
85
- }
85
+ }
package/src/core.test.ts CHANGED
@@ -483,45 +483,78 @@ describe('Analytics capture', () => {
483
483
  })
484
484
 
485
485
  describe('Event error capture', () => {
486
- it('captureError emits a type:error event carrying a TOP-LEVEL exception, flushed at once', () => {
486
+ it('emits under the RESERVED $exception name, never under the message', () => {
487
487
  const a = mk()
488
488
  a.captureError(new TypeError('boom'))
489
489
  // captureError flushes promptly — no explicit flush() needed.
490
490
  expect(tx.sent).toHaveLength(1)
491
491
  const e = tx.all[0]
492
- // type:'error' is the field Cloud folds to event_type='error' for the warehouse.
493
- expect(e.type).toBe('error')
494
- expect(e.event).toBe('boom')
495
- // The exception rides the TOP-LEVEL `error` field (what cloud foldException
496
- // reads), NOT properties a properties-only exception would not be folded.
492
+ // THE NAME. Until 0.3.20 this was `ex.message`, which made every distinct
493
+ // error string a permanent entry in the event taxonomy and left Error
494
+ // Tracking — which reads this exact name — at zero rows.
495
+ expect(e.event).toBe('$exception')
496
+ // THE TYPE. `type` alone picks the storage plane: 'error' routes to the error
497
+ // plane, which the product-event projection does not read, so an exception
498
+ // filed there cannot reach Error Tracking. The full error record still goes to
499
+ // the error plane as a Sentry envelope.
500
+ expect(e.type).toBe('event')
501
+ // The exception STILL rides the TOP-LEVEL `error` field, which is what cloud's
502
+ // foldException reads to stamp properties.$exception (scrubbing on the way).
497
503
  expect(e.error?.type).toBe('TypeError')
498
504
  expect(e.error?.message).toBe('boom')
499
505
  expect(e.error?.stack).toBeTruthy()
500
506
  expect(e.error?.handled).toBe(true) // a caught, manually-reported error
501
- expect((e.properties ?? {})).not.toHaveProperty('$exception')
507
+ // The client does not stamp $exception itself — the server fold owns that key.
508
+ expect(e.properties ?? {}).not.toHaveProperty('$exception')
509
+ })
510
+
511
+ it('carries the $exception_* bag Error Tracking reads', () => {
512
+ const a = mk()
513
+ a.captureError(new TypeError('boom'))
514
+ const p = tx.all[0].properties as Record<string, unknown>
515
+ expect(Array.isArray(p.$exception_list)).toBe(true)
516
+ // Without a fingerprint the issue query drops the event outright.
517
+ expect(p.$exception_fingerprint).toMatch(/^[0-9a-f]{32}$/)
518
+ expect(p.$exception_type).toBe('TypeError')
519
+ expect(p.$exception_handled).toBe(true)
502
520
  })
503
521
 
504
522
  it('normalizes a thrown string into an exception', () => {
505
523
  const a = mk()
506
524
  a.captureError('plain failure')
507
525
  const e = tx.all[0]
508
- expect(e.type).toBe('error')
526
+ expect(e.event).toBe('$exception')
527
+ expect(e.type).toBe('event')
509
528
  expect(e.error?.message).toBe('plain failure')
510
529
  })
511
530
 
512
- it('marks handled=false for unhandled/global errors and carries properties', () => {
531
+ it('marks handled=false for unhandled/global errors and carries caller properties', () => {
513
532
  const a = mk()
514
533
  a.captureError(new Error('unhandled'), { handled: false, properties: { source: 'onerror' } })
515
534
  const e = tx.all[0]
516
535
  expect(e.error?.handled).toBe(false)
517
- expect(e.properties).toEqual({ source: 'onerror' })
536
+ const p = e.properties as Record<string, unknown>
537
+ // The caller's own properties survive alongside the exception bag.
538
+ expect(p.source).toBe('onerror')
539
+ expect(p.$exception_handled).toBe(false)
540
+ })
541
+
542
+ it("a caller property cannot overwrite the exception bag it shares a name with", () => {
543
+ const a = mk()
544
+ a.captureError(new Error('x'), {
545
+ handled: false,
546
+ properties: { $exception_fingerprint: 'forged' },
547
+ })
548
+ const p = tx.all[0].properties as Record<string, unknown>
549
+ expect(p.$exception_fingerprint).not.toBe('forged')
518
550
  })
519
551
 
520
552
  it('captureException is an alias of captureError', () => {
521
553
  const a = mk()
522
554
  a.captureException(new Error('via alias'))
523
555
  const e = tx.all[0]
524
- expect(e.type).toBe('error')
556
+ expect(e.event).toBe('$exception')
557
+ expect(e.type).toBe('event')
525
558
  expect(e.error?.message).toBe('via alias')
526
559
  })
527
560
 
@@ -563,7 +596,7 @@ describe('error plane', () => {
563
596
  expect(tx.envelopes).toHaveLength(0)
564
597
  // fail-safe: the event stream still carries the error, analytics untouched.
565
598
  expect(tx.streams).toHaveLength(1)
566
- expect(tx.all[0].type).toBe('error')
599
+ expect(tx.all[0].event).toBe('$exception')
567
600
  })
568
601
 
569
602
  it('with a DSN, an error POSTs a Sentry envelope to the DERIVED ingest URL', () => {
package/src/core.ts CHANGED
@@ -44,7 +44,8 @@ import {
44
44
  deriveChannel,
45
45
  } from './attribution'
46
46
  import { dsnForProduct, defaultPublishableKey } from './dsn'
47
- import { PAGEVIEW } from './events'
47
+ import { EXCEPTION, PAGEVIEW } from './events'
48
+ import { exceptionProperties } from './exception'
48
49
  import { scrubText } from './scrub'
49
50
  import {
50
51
  buildEnvelope,
@@ -389,9 +390,35 @@ export class Analytics {
389
390
  }
390
391
 
391
392
  try {
393
+ const handled = context?.handled ?? true
392
394
  const ex = normalizeError(err)
393
- ex.handled = context?.handled ?? true
394
- this.enqueue('error', ex.message, { error: ex, properties: context?.properties })
395
+ ex.handled = handled
396
+ // NAME: the reserved '$exception', never the message. The message was the
397
+ // name until 0.3.20, which put every distinct error string — one per failed
398
+ // chunk id, per ResizeObserver notification — permanently into the event
399
+ // taxonomy, and left Error Tracking (which reads this exact name) at zero.
400
+ //
401
+ // TYPE 'event', not 'error'. `type` alone picks the storage plane: 'error'
402
+ // routes to the error plane, which the product-event projection does not
403
+ // read, so an exception filed there is invisible to Error Tracking however
404
+ // well-formed it is. The full error record still reaches the error plane as
405
+ // a Sentry envelope above — this row is the product-analytics breadcrumb,
406
+ // which is what keeps a crash correlated with the session's pageviews.
407
+ //
408
+ // `error` is still carried: the server folds it into properties.$exception
409
+ // (scrubbing message and stack on the way), which is the shape existing
410
+ // readers bind to.
411
+ this.enqueue('event', EXCEPTION, {
412
+ error: ex,
413
+ properties: {
414
+ ...context?.properties,
415
+ ...exceptionProperties(err, {
416
+ handled,
417
+ id: uuidv7(),
418
+ level: context?.level,
419
+ }),
420
+ },
421
+ })
395
422
  this.flush()
396
423
  } catch {
397
424
  /* nor the reverse */
package/src/dsn.test.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest'
2
2
 
3
3
  import { createAnalytics } from './core'
4
- import { PRODUCT_DSN, dsnForProduct } from './dsn'
4
+ import { PRODUCT_PROJECT, dsnForProduct, HANZO_PUBLISHABLE_KEY } from './dsn'
5
5
  import { parseDsn } from './sentry'
6
6
 
7
7
  const ENV = 'NEXT_PUBLIC_HANZO_EVENT_DSN'
@@ -13,10 +13,13 @@ afterEach(() => {
13
13
  })
14
14
 
15
15
  describe('the product registry', () => {
16
- it('resolves a registered product to its DSN', () => {
17
- expect(dsnForProduct('console')).toBe(PRODUCT_DSN.console)
18
- expect(dsnForProduct('app')).toBe(PRODUCT_DSN.app)
19
- expect(dsnForProduct('site')).toBe(PRODUCT_DSN.site)
16
+ it('builds a product DSN — the org publishable key at its project envelope', () => {
17
+ expect(dsnForProduct('console')).toBe(
18
+ `https://${HANZO_PUBLISHABLE_KEY}@api.hanzo.ai/v1/sentry/${PRODUCT_PROJECT.console}`
19
+ )
20
+ expect(dsnForProduct('site')).toBe(
21
+ `https://${HANZO_PUBLISHABLE_KEY}@api.hanzo.ai/v1/sentry/${PRODUCT_PROJECT.site}`
22
+ )
20
23
  })
21
24
 
22
25
  it('returns undefined for an unregistered or missing product, rather than guessing', () => {
@@ -25,12 +28,12 @@ describe('the product registry', () => {
25
28
  expect(dsnForProduct('')).toBeUndefined()
26
29
  })
27
30
 
28
- it('registers only DSNs that actually parse a typo here would silently disable a surface', () => {
29
- for (const [product, dsn] of Object.entries(PRODUCT_DSN)) {
30
- const parsed = parseDsn(dsn)
31
+ it('every product DSN parses to the org key + its own project id', () => {
32
+ for (const [product, projectId] of Object.entries(PRODUCT_PROJECT)) {
33
+ const parsed = parseDsn(dsnForProduct(product))
31
34
  expect(parsed, `${product} DSN must parse`).not.toBeNull()
32
- expect(parsed!.projectId, `${product} needs a project id`).toBeTruthy()
33
- expect(parsed!.publicKey, `${product} needs a public key`).toBeTruthy()
35
+ expect(parsed!.projectId, `${product} project id`).toBe(projectId)
36
+ expect(parsed!.publicKey, `${product} carries the org key`).toBe(HANZO_PUBLISHABLE_KEY)
34
37
  }
35
38
  })
36
39
  })
@@ -39,7 +42,7 @@ describe('DSN precedence — most specific source wins', () => {
39
42
  it('lights up the error plane from `product` alone, with no dsn and no env', () => {
40
43
  const a = createAnalytics({ product: 'console', enabled: false })
41
44
  expect(a.errorPlaneEnabled).toBe(true)
42
- expect(a.errorIngestUrl).toContain(parseDsn(PRODUCT_DSN.console)!.projectId)
45
+ expect(a.errorIngestUrl).toContain(PRODUCT_PROJECT.console)
43
46
  })
44
47
 
45
48
  it('prefers an explicit dsn over both the env and the registry', () => {
package/src/dsn.ts CHANGED
@@ -24,24 +24,29 @@
24
24
  * product name the app passes to `createAnalytics`.
25
25
  */
26
26
 
27
- /** PRODUCT_DSN maps a `product` to the DSN its errors are submitted to. */
28
- export const PRODUCT_DSN: Readonly<Record<string, string>> = Object.freeze({
29
- // hanzo-console console.hanzo.ai (also served embedded by the cloud binary)
30
- console:
31
- 'https://1:0c8054dbde157f4f420c56b58660052b2ad782293c4de1d606ef8fbc46a0bf34@api.hanzo.ai/v1/sentry/019fa40b-94ae-7f1d-8f7b-e92f123fad42',
32
- // hanzo-app hanzo.app
33
- app: 'https://1:b3e1173125568c80f91ef4b1fabbbd2d7e22341de02b33ce7e22ef4fc16a196e@api.hanzo.ai/v1/sentry/019f9b1e-57eb-7171-9d92-72c0b85e4b4b',
34
- // hanzo-ai hanzo.ai (the marketing site; `site` is the product name it declares)
35
- site: 'https://1:d9cbfb844958bd7ef2a455600f00fbf237fbd71b75c1504f137773096d6aa53f@api.hanzo.ai/v1/sentry/019f9b1e-5785-7359-ad0b-f75db8e58c99',
27
+ /** PRODUCT_PROJECT maps a `product` to its Sentry project id. The DSN's KEY is no
28
+ * longer a per-project secret it is the ONE org publishable key (below), so a
29
+ * surface's errors ride the SAME key its events do. The id only names WHICH
30
+ * project the errors group under, and cloud auto-provisions that project on first
31
+ * keyed ingest, so a new id needs nothing minted. `site` lives in the `hanzo-ai`
32
+ * projectan explicit map, because the projects predate this and do not derive
33
+ * cleanly from the product name. */
34
+ export const PRODUCT_PROJECT: Readonly<Record<string, string>> = Object.freeze({
35
+ console: '019fa40b-94ae-7f1d-8f7b-e92f123fad42', // console.hanzo.ai
36
+ app: '019f9b1e-57eb-7171-9d92-72c0b85e4b4b', // hanzo.app
37
+ site: '019f9b1e-5785-7359-ad0b-f75db8e58c99', // hanzo.ai (marketing; product `site`)
36
38
  })
37
39
 
38
- /** dsnForProduct resolves the registered DSN for a product, or undefined when the
39
- * product has no project yet which leaves the error plane inert rather than
40
- * guessing a destination and silently posting a surface's errors into the wrong
41
- * project. */
40
+ /** dsnForProduct builds the product's Sentry DSN the ONE org publishable key at
41
+ * its project's envelope endpointor undefined when the product has no project
42
+ * yet, which leaves the error plane inert rather than posting into the wrong one.
43
+ * Same key as the event stream: cloud resolves it to the org and attributes the
44
+ * errors there. */
42
45
  export function dsnForProduct(product: string | undefined): string | undefined {
43
46
  if (!product) return undefined
44
- return PRODUCT_DSN[product]
47
+ const projectId = PRODUCT_PROJECT[product]
48
+ if (!projectId) return undefined
49
+ return `https://${HANZO_PUBLISHABLE_KEY}@api.hanzo.ai/v1/sentry/${projectId}`
45
50
  }
46
51
 
47
52
  /** The hanzo org's publishable ingest key. Like the DSNs above it is PUBLIC by
package/src/events.ts CHANGED
@@ -69,3 +69,8 @@ export type EventName = (typeof EVENTS)[keyof typeof EVENTS]
69
69
 
70
70
  /** The reserved event name a pageview is stored under (server + read lens). */
71
71
  export const PAGEVIEW = '$pageview'
72
+
73
+ /** The reserved name every captured exception is emitted under. Error Tracking
74
+ * reads exactly this name; an exception emitted under its own message instead
75
+ * makes every distinct message a permanent entry in the event taxonomy. */
76
+ export const EXCEPTION = '$exception'
@@ -0,0 +1,188 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { digest, exceptionEntry, exceptionProperties, fingerprint } from './exception'
3
+
4
+ /** A throwable with a realistic V8 stack, innermost call first (as V8 emits). */
5
+ function boom(msg = 'Cannot read properties of undefined'): Error {
6
+ const e = new TypeError(msg)
7
+ e.stack = [
8
+ `TypeError: ${msg}`,
9
+ ' at loadIssue (https://hanzo.ai/_next/static/chunks/app.js:42:9)',
10
+ ' at render (https://hanzo.ai/_next/static/chunks/app.js:17:3)',
11
+ ' at vendorLoad (https://hanzo.ai/node_modules/react-dom/index.js:12:3)',
12
+ ].join('\n')
13
+ return e
14
+ }
15
+
16
+ describe('frame ordering — the product reads frames bottom-up', () => {
17
+ it('puts the entry point first and the throw site last', () => {
18
+ const e = exceptionEntry(boom(), { handled: false, id: 'x' })
19
+ const frames = e.stacktrace!.frames
20
+ // V8 emits innermost-first; the product wants the reverse.
21
+ expect(frames[0].mangled_name).toBe('vendorLoad')
22
+ expect(frames[frames.length - 1].mangled_name).toBe('loadIssue')
23
+ })
24
+
25
+ it('indexes the throw site at -1, which is where the issue list reads it', () => {
26
+ const p = exceptionProperties(boom(), { handled: false, id: 'x' })
27
+ expect(p.$exception_functions.at(-1)).toBe('loadIssue')
28
+ expect(p.$exception_sources.at(-1)).toBe('https://hanzo.ai/_next/static/chunks/app.js')
29
+ })
30
+ })
31
+
32
+ describe('stacktrace.type — the renderer draws nothing on any other value', () => {
33
+ it("is the literal 'resolved'", () => {
34
+ const e = exceptionEntry(boom(), { handled: false, id: 'x' })
35
+ expect(e.stacktrace!.type).toBe('resolved')
36
+ })
37
+
38
+ it('is omitted entirely when there is no stack, rather than sent empty', () => {
39
+ const e = exceptionEntry('Script error.', { handled: false, id: 'x' })
40
+ expect(e.stacktrace).toBeUndefined()
41
+ expect(e.type).toBe('Error')
42
+ expect(e.value).toBe('Script error.')
43
+ })
44
+ })
45
+
46
+ describe('in_app — frames without it are hidden by default', () => {
47
+ it('marks first-party code in_app and vendor code not', () => {
48
+ const frames = exceptionEntry(boom(), { handled: false, id: 'x' }).stacktrace!.frames
49
+ const byName = Object.fromEntries(frames.map((f) => [f.mangled_name, f.in_app]))
50
+ expect(byName.loadIssue).toBe(true)
51
+ expect(byName.render).toBe(true)
52
+ expect(byName.vendorLoad).toBe(false)
53
+ })
54
+
55
+ it('keeps only in_app frames out of the fingerprint', () => {
56
+ const p = exceptionProperties(boom(), { handled: false, id: 'x' })
57
+ // vendorLoad is the only non-in_app frame; it must not move the group key.
58
+ const e = exceptionEntry(boom(), { handled: false, id: 'y' })
59
+ e.stacktrace!.frames = e.stacktrace!.frames.filter((f) => f.in_app)
60
+ expect(fingerprint(e)).toBe(p.$exception_fingerprint)
61
+ })
62
+ })
63
+
64
+ describe('fingerprint — the issue grouping key', () => {
65
+ it('is present, since the issue query drops events without one', () => {
66
+ const p = exceptionProperties(boom(), { handled: false, id: 'x' })
67
+ expect(p.$exception_fingerprint).toMatch(/^[0-9a-f]{32}$/)
68
+ })
69
+
70
+ it('groups the SAME bug whose message varies — the whole point', () => {
71
+ // The real-world case: one failed-chunk bug produced a distinct event name per
72
+ // chunk id. These must be one issue.
73
+ const a = new Error('Loading chunk 3324 failed.')
74
+ const b = new Error('Loading chunk 998 failed.')
75
+ const stack = ' at load (https://hanzo.ai/app.js:1:1)'
76
+ a.stack = `Error: x\n${stack}`
77
+ b.stack = `Error: y\n${stack}`
78
+ const fa = exceptionProperties(a, { handled: false, id: '1' }).$exception_fingerprint
79
+ const fb = exceptionProperties(b, { handled: false, id: '2' }).$exception_fingerprint
80
+ expect(fa).toBe(fb)
81
+ })
82
+
83
+ it('separates genuinely different bugs', () => {
84
+ const other = new RangeError('nope')
85
+ other.stack = 'RangeError: nope\n at somewhereElse (https://hanzo.ai/other.js:5:5)'
86
+ const fa = exceptionProperties(boom(), { handled: false, id: '1' }).$exception_fingerprint
87
+ const fb = exceptionProperties(other, { handled: false, id: '2' }).$exception_fingerprint
88
+ expect(fa).not.toBe(fb)
89
+ })
90
+
91
+ it('still groups stackless errors by type instead of scattering them', () => {
92
+ const f1 = exceptionProperties('Script error.', { handled: false, id: '1' })
93
+ const f2 = exceptionProperties('Script error.', { handled: false, id: '2' })
94
+ expect(f1.$exception_fingerprint).toBe(f2.$exception_fingerprint)
95
+ })
96
+ })
97
+
98
+ describe('raw_id — frame identity', () => {
99
+ it('carries the "<hash>/<part>" shape the product expects', () => {
100
+ const frames = exceptionEntry(boom(), { handled: false, id: 'x' }).stacktrace!.frames
101
+ for (const f of frames) expect(f.raw_id).toMatch(/^[0-9a-f]{32}\/0$/)
102
+ })
103
+
104
+ it('is stable for the same code location across captures', () => {
105
+ const a = exceptionEntry(boom(), { handled: false, id: '1' }).stacktrace!.frames
106
+ const b = exceptionEntry(boom('different message'), { handled: false, id: '2' })
107
+ .stacktrace!.frames
108
+ expect(a.map((f) => f.raw_id)).toEqual(b.map((f) => f.raw_id))
109
+ })
110
+ })
111
+
112
+ describe('mechanism + level', () => {
113
+ it('reports an uncaught error as unhandled', () => {
114
+ const e = exceptionEntry(boom(), { handled: false, id: 'x' })
115
+ expect(e.mechanism).toEqual({ type: 'generic', handled: false, synthetic: false })
116
+ })
117
+
118
+ it('marks a non-Error throwable synthetic', () => {
119
+ const e = exceptionEntry('just a string', { handled: true, id: 'x' })
120
+ expect(e.mechanism?.synthetic).toBe(true)
121
+ })
122
+
123
+ it('defaults level to error and honours an override', () => {
124
+ expect(exceptionProperties(boom(), { handled: true, id: 'x' }).$exception_level).toBe('error')
125
+ expect(
126
+ exceptionProperties(boom(), { handled: true, id: 'x', level: 'warning' }).$exception_level,
127
+ ).toBe('warning')
128
+ })
129
+ })
130
+
131
+ describe('denormalized properties (nothing derives these server-side here)', () => {
132
+ it('sends the search + issue-column arrays the product reads', () => {
133
+ const p = exceptionProperties(boom(), { handled: false, id: 'x' })
134
+ expect(p.$exception_types).toEqual(['TypeError'])
135
+ expect(p.$exception_values).toEqual(['Cannot read properties of undefined'])
136
+ expect(p.$exception_type).toBe('TypeError')
137
+ expect(p.$exception_handled).toBe(false)
138
+ expect(p.$exception_fingerprint_record).toEqual([{ type: 'manual' }])
139
+ expect(p.$exception_list).toHaveLength(1)
140
+ })
141
+ })
142
+
143
+ describe('hostile input never escapes', () => {
144
+ it('survives a throwable whose getters throw', () => {
145
+ const hostile = {
146
+ get name() {
147
+ throw new Error('nope')
148
+ },
149
+ get message() {
150
+ throw new Error('nope')
151
+ },
152
+ get stack() {
153
+ throw new Error('nope')
154
+ },
155
+ }
156
+ expect(() => exceptionProperties(hostile, { handled: true, id: 'x' })).not.toThrow()
157
+ })
158
+
159
+ it('bounds an enormous message', () => {
160
+ const e = exceptionEntry(new Error('x'.repeat(100_000)), { handled: true, id: 'x' })
161
+ expect(e.value.length).toBeLessThanOrEqual(4096)
162
+ })
163
+
164
+ it('bounds frame count', () => {
165
+ const many = new Error('deep')
166
+ many.stack =
167
+ 'Error: deep\n' +
168
+ Array.from({ length: 500 }, (_, i) => ` at f${i} (https://hanzo.ai/a.js:${i}:1)`).join(
169
+ '\n',
170
+ )
171
+ expect(exceptionEntry(many, { handled: true, id: 'x' }).stacktrace!.frames.length).toBe(50)
172
+ })
173
+ })
174
+
175
+ describe('digest', () => {
176
+ it('is stable and 32 hex chars', () => {
177
+ expect(digest('abc')).toBe(digest('abc'))
178
+ expect(digest('abc')).toMatch(/^[0-9a-f]{32}$/)
179
+ })
180
+
181
+ it('separates different inputs', () => {
182
+ expect(digest('abc')).not.toBe(digest('abd'))
183
+ })
184
+
185
+ it('handles an empty string', () => {
186
+ expect(digest('')).toMatch(/^[0-9a-f]{32}$/)
187
+ })
188
+ })