@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.
package/src/core.test.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest'
2
2
  import { Analytics, VERSION } from './core'
3
- import { HANZO_PUBLISHABLE_KEY } from './dsn'
4
3
  import { EVENTS, PAGEVIEW } from './events'
5
4
  import type { Transport, WireEvent } from './types'
6
5
  import pkg from '../package.json' with { type: 'json' }
@@ -384,48 +383,15 @@ describe('Analytics capture', () => {
384
383
  }
385
384
  })
386
385
 
387
- it('stays keyless when neither config nor env names a key, off the hanzo cloud', () => {
388
- // mk() uses host:'' a same-origin app, deliberately NOT defaulted.
389
- const a = mk()
390
- a.capture('x')
391
- a.flush(true)
392
- expect(tx.sent[0].ingestKey).toBeUndefined()
393
- })
394
-
395
- it('bakes the hanzo publishable key as the default ON the hanzo cloud', () => {
396
- // publishable_key for all: a hanzo surface that passes no key and inlines no
397
- // env still emits attributed, the same way declaring `product` is enough for
398
- // the DSN. No wiring, no $public, no silent drop of track/identify/group.
386
+ it('stays keyless when neither config nor env names a key no baked literal', () => {
387
+ // The key comes from ONE live source (config or the KMS-sourced env); nothing
388
+ // is hardcoded, so a surface that provides neither is honestly keyless.
399
389
  const a = mk({ host: 'https://api.hanzo.ai' })
400
390
  a.capture('x')
401
391
  a.flush(true)
402
- expect(tx.sent[0].ingestKey).toBe(HANZO_PUBLISHABLE_KEY)
403
- })
404
-
405
- it('does NOT bake the default for a white-label (non-hanzo) host', () => {
406
- const a = mk({ host: 'https://api.zoo.ngo' })
407
- a.capture('x')
408
- a.flush(true)
409
392
  expect(tx.sent[0].ingestKey).toBeUndefined()
410
393
  })
411
394
 
412
- it('lets an explicit key and the build env each override the baked default', () => {
413
- const explicit = mk({ host: 'https://api.hanzo.ai', ingestKey: 'pk-explicit' })
414
- explicit.capture('x')
415
- explicit.flush(true)
416
- expect(tx.sent[0].ingestKey).toBe('pk-explicit')
417
-
418
- process.env.NEXT_PUBLIC_PUBLISHABLE_KEY = 'pk-from-env'
419
- try {
420
- const env = mk({ host: 'https://api.hanzo.ai' }) // mk() reassigns `tx`
421
- env.capture('y')
422
- env.flush(true)
423
- expect(tx.sent[0].ingestKey).toBe('pk-from-env')
424
- } finally {
425
- delete process.env.NEXT_PUBLIC_PUBLISHABLE_KEY
426
- }
427
- })
428
-
429
395
  it('a signed-in bearer WINS over a key from the build env', () => {
430
396
  // The leak this closes: one console bundle is served to several brands, and a
431
397
  // pk- names ONE org. If an env-sourced key displaced the bearer, every
@@ -483,45 +449,78 @@ describe('Analytics capture', () => {
483
449
  })
484
450
 
485
451
  describe('Event error capture', () => {
486
- it('captureError emits a type:error event carrying a TOP-LEVEL exception, flushed at once', () => {
452
+ it('emits under the RESERVED $exception name, never under the message', () => {
487
453
  const a = mk()
488
454
  a.captureError(new TypeError('boom'))
489
455
  // captureError flushes promptly — no explicit flush() needed.
490
456
  expect(tx.sent).toHaveLength(1)
491
457
  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.
458
+ // THE NAME. Until 0.3.20 this was `ex.message`, which made every distinct
459
+ // error string a permanent entry in the event taxonomy and left Error
460
+ // Tracking — which reads this exact name — at zero rows.
461
+ expect(e.event).toBe('$exception')
462
+ // THE TYPE. `type` alone picks the storage plane: 'error' routes to the error
463
+ // plane, which the product-event projection does not read, so an exception
464
+ // filed there cannot reach Error Tracking. The full error record still goes to
465
+ // the error plane as a Sentry envelope.
466
+ expect(e.type).toBe('event')
467
+ // The exception STILL rides the TOP-LEVEL `error` field, which is what cloud's
468
+ // foldException reads to stamp properties.$exception (scrubbing on the way).
497
469
  expect(e.error?.type).toBe('TypeError')
498
470
  expect(e.error?.message).toBe('boom')
499
471
  expect(e.error?.stack).toBeTruthy()
500
472
  expect(e.error?.handled).toBe(true) // a caught, manually-reported error
501
- expect((e.properties ?? {})).not.toHaveProperty('$exception')
473
+ // The client does not stamp $exception itself — the server fold owns that key.
474
+ expect(e.properties ?? {}).not.toHaveProperty('$exception')
475
+ })
476
+
477
+ it('carries the $exception_* bag Error Tracking reads', () => {
478
+ const a = mk()
479
+ a.captureError(new TypeError('boom'))
480
+ const p = tx.all[0].properties as Record<string, unknown>
481
+ expect(Array.isArray(p.$exception_list)).toBe(true)
482
+ // Without a fingerprint the issue query drops the event outright.
483
+ expect(p.$exception_fingerprint).toMatch(/^[0-9a-f]{32}$/)
484
+ expect(p.$exception_type).toBe('TypeError')
485
+ expect(p.$exception_handled).toBe(true)
502
486
  })
503
487
 
504
488
  it('normalizes a thrown string into an exception', () => {
505
489
  const a = mk()
506
490
  a.captureError('plain failure')
507
491
  const e = tx.all[0]
508
- expect(e.type).toBe('error')
492
+ expect(e.event).toBe('$exception')
493
+ expect(e.type).toBe('event')
509
494
  expect(e.error?.message).toBe('plain failure')
510
495
  })
511
496
 
512
- it('marks handled=false for unhandled/global errors and carries properties', () => {
497
+ it('marks handled=false for unhandled/global errors and carries caller properties', () => {
513
498
  const a = mk()
514
499
  a.captureError(new Error('unhandled'), { handled: false, properties: { source: 'onerror' } })
515
500
  const e = tx.all[0]
516
501
  expect(e.error?.handled).toBe(false)
517
- expect(e.properties).toEqual({ source: 'onerror' })
502
+ const p = e.properties as Record<string, unknown>
503
+ // The caller's own properties survive alongside the exception bag.
504
+ expect(p.source).toBe('onerror')
505
+ expect(p.$exception_handled).toBe(false)
506
+ })
507
+
508
+ it("a caller property cannot overwrite the exception bag it shares a name with", () => {
509
+ const a = mk()
510
+ a.captureError(new Error('x'), {
511
+ handled: false,
512
+ properties: { $exception_fingerprint: 'forged' },
513
+ })
514
+ const p = tx.all[0].properties as Record<string, unknown>
515
+ expect(p.$exception_fingerprint).not.toBe('forged')
518
516
  })
519
517
 
520
518
  it('captureException is an alias of captureError', () => {
521
519
  const a = mk()
522
520
  a.captureException(new Error('via alias'))
523
521
  const e = tx.all[0]
524
- expect(e.type).toBe('error')
522
+ expect(e.event).toBe('$exception')
523
+ expect(e.type).toBe('event')
525
524
  expect(e.error?.message).toBe('via alias')
526
525
  })
527
526
 
@@ -563,7 +562,7 @@ describe('error plane', () => {
563
562
  expect(tx.envelopes).toHaveLength(0)
564
563
  // fail-safe: the event stream still carries the error, analytics untouched.
565
564
  expect(tx.streams).toHaveLength(1)
566
- expect(tx.all[0].type).toBe('error')
565
+ expect(tx.all[0].event).toBe('$exception')
567
566
  })
568
567
 
569
568
  it('with a DSN, an error POSTs a Sentry envelope to the DERIVED ingest URL', () => {
package/src/core.ts CHANGED
@@ -43,8 +43,9 @@ import {
43
43
  hasAttribution,
44
44
  deriveChannel,
45
45
  } from './attribution'
46
- import { dsnForProduct, defaultPublishableKey } from './dsn'
47
- import { PAGEVIEW } from './events'
46
+ import { dsnForProduct } from './dsn'
47
+ import { EXCEPTION, PAGEVIEW } from './events'
48
+ import { exceptionProperties } from './exception'
48
49
  import { scrubText } from './scrub'
49
50
  import {
50
51
  buildEnvelope,
@@ -238,35 +239,29 @@ export class Analytics {
238
239
  enabled: true,
239
240
  captureErrors: true,
240
241
  ...config,
241
- // The publishable key resolves the SAME way the DSN below does, and now
242
- // ends the SAME way too — in a baked default, so declaring nothing is
243
- // enough. Most specific first:
242
+ // The publishable key resolves from ONE live source, never a literal baked
243
+ // beside the code. Most specific first:
244
244
  // 1. an explicit `ingestKey` in config;
245
245
  // 2. NEXT_PUBLIC_PUBLISHABLE_KEY, the inlined build-time env the fleet
246
- // carries end to end (KMS `deploy/PUBLISHABLE_KEY` -> the PUBLISHABLE_KEY
247
- // build-arg -> the NEXT_PUBLIC_ prefix Next inlines);
248
- // 3. the hanzo org key baked in `dsn.ts`, on the hanzo cloud only.
246
+ // carries end to end KMS `deploy/PUBLISHABLE_KEY` -> the PUBLISHABLE_KEY
247
+ // build-arg -> the NEXT_PUBLIC_ prefix Next inlines.
249
248
  //
250
- // (3) is why this is `publishable_key for all`: a hanzo surface that passes
251
- // no key in code and inlines no env still emits attributed, exactly as
252
- // declaring `product` is enough for the DSN. Without it that surface sent
253
- // its beacons to `$public` which drops every track/identify/group and
254
- // still answers 200 silent in the page and invisible until you read the
255
- // warehouse and find the host missing entirely. A white-label surface on
256
- // its own cloud (a non-default host) gets no default, so the org this
257
- // attributes to is never the wrong one.
258
- ingestKey:
259
- config.ingestKey ??
260
- readEnv('NEXT_PUBLIC_PUBLISHABLE_KEY') ??
261
- defaultPublishableKey(config.host ?? DEFAULT_HOST),
249
+ // A surface that provides neither has no key, and its anonymous traffic
250
+ // files under `$public` (which drops track/identify/group and answers 200);
251
+ // the fix is to give it the KMS-sourced env, not to hardcode the org key
252
+ // here. The key is a credential-class value: its home is KMS, and the ONE
253
+ // build reads it from there.
254
+ ingestKey: config.ingestKey ?? readEnv('NEXT_PUBLIC_PUBLISHABLE_KEY'),
262
255
  }
263
256
  this.transport = config.transport ?? new DefaultTransport()
264
257
  // Error plane, most specific source first: an explicit DSN wins, then the
265
- // inlined build-time env (a per-deploy override), then the product registry
266
- // so declaring `product` is enough to report errors and no surface needs
267
- // build-argument plumbing. Malformed or absent => null => inert, never
268
- // throwing into the host app.
269
- this.dsn = parseDsn(config.dsn ?? readEnvDsn() ?? dsnForProduct(this.cfg.product))
258
+ // inlined build-time env (a per-deploy override), then the product registry
259
+ // whose DSN carries the SAME resolved key (not a baked one), so declaring
260
+ // `product` + providing the key is enough to report errors. Malformed or
261
+ // absent => null => inert, never throwing into the host app.
262
+ this.dsn = parseDsn(
263
+ config.dsn ?? readEnvDsn() ?? dsnForProduct(this.cfg.product, this.cfg.ingestKey)
264
+ )
270
265
  }
271
266
 
272
267
  /** errorPlaneEnabled reports whether captured exceptions can actually reach the
@@ -389,9 +384,35 @@ export class Analytics {
389
384
  }
390
385
 
391
386
  try {
387
+ const handled = context?.handled ?? true
392
388
  const ex = normalizeError(err)
393
- ex.handled = context?.handled ?? true
394
- this.enqueue('error', ex.message, { error: ex, properties: context?.properties })
389
+ ex.handled = handled
390
+ // NAME: the reserved '$exception', never the message. The message was the
391
+ // name until 0.3.20, which put every distinct error string — one per failed
392
+ // chunk id, per ResizeObserver notification — permanently into the event
393
+ // taxonomy, and left Error Tracking (which reads this exact name) at zero.
394
+ //
395
+ // TYPE 'event', not 'error'. `type` alone picks the storage plane: 'error'
396
+ // routes to the error plane, which the product-event projection does not
397
+ // read, so an exception filed there is invisible to Error Tracking however
398
+ // well-formed it is. The full error record still reaches the error plane as
399
+ // a Sentry envelope above — this row is the product-analytics breadcrumb,
400
+ // which is what keeps a crash correlated with the session's pageviews.
401
+ //
402
+ // `error` is still carried: the server folds it into properties.$exception
403
+ // (scrubbing message and stack on the way), which is the shape existing
404
+ // readers bind to.
405
+ this.enqueue('event', EXCEPTION, {
406
+ error: ex,
407
+ properties: {
408
+ ...context?.properties,
409
+ ...exceptionProperties(err, {
410
+ handled,
411
+ id: uuidv7(),
412
+ level: context?.level,
413
+ }),
414
+ },
415
+ })
395
416
  this.flush()
396
417
  } catch {
397
418
  /* nor the reverse */
package/src/dsn.test.ts CHANGED
@@ -1,64 +1,74 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest'
2
2
 
3
3
  import { createAnalytics } from './core'
4
- import { PRODUCT_PROJECT, dsnForProduct, HANZO_PUBLISHABLE_KEY } from './dsn'
4
+ import { PRODUCT_PROJECT, dsnForProduct } from './dsn'
5
5
  import { parseDsn } from './sentry'
6
6
 
7
7
  const ENV = 'NEXT_PUBLIC_HANZO_EVENT_DSN'
8
8
  const OVERRIDE = 'https://1:aaaa@api.hanzo.ai/v1/sentry/env-project'
9
9
  const EXPLICIT = 'https://1:bbbb@api.hanzo.ai/v1/sentry/explicit-project'
10
+ // A stand-in for the surface's own resolved key — the DSN carries whatever key
11
+ // the caller resolved (config or the KMS-sourced env), never a literal.
12
+ const KEY = 'pk-test-resolved-key'
10
13
 
11
14
  afterEach(() => {
12
15
  delete process.env[ENV]
13
16
  })
14
17
 
15
18
  describe('the product registry', () => {
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
+ it('builds a product DSN from the CALLER key + the project — nothing baked', () => {
20
+ expect(dsnForProduct('console', KEY)).toBe(
21
+ `https://${KEY}@api.hanzo.ai/v1/sentry/${PRODUCT_PROJECT.console}`
19
22
  )
20
- expect(dsnForProduct('site')).toBe(
21
- `https://${HANZO_PUBLISHABLE_KEY}@api.hanzo.ai/v1/sentry/${PRODUCT_PROJECT.site}`
23
+ expect(dsnForProduct('site', KEY)).toBe(
24
+ `https://${KEY}@api.hanzo.ai/v1/sentry/${PRODUCT_PROJECT.site}`
22
25
  )
23
26
  })
24
27
 
25
- it('returns undefined for an unregistered or missing product, rather than guessing', () => {
26
- expect(dsnForProduct('not-a-product')).toBeUndefined()
27
- expect(dsnForProduct(undefined)).toBeUndefined()
28
- expect(dsnForProduct('')).toBeUndefined()
28
+ it('returns undefined without a key or without a project, rather than guessing', () => {
29
+ expect(dsnForProduct('console', undefined)).toBeUndefined() // no key -> inert
30
+ expect(dsnForProduct('not-a-product', KEY)).toBeUndefined()
31
+ expect(dsnForProduct(undefined, KEY)).toBeUndefined()
32
+ expect(dsnForProduct('', KEY)).toBeUndefined()
29
33
  })
30
34
 
31
- it('every product DSN parses to the org key + its own project id', () => {
35
+ it('every product DSN parses to the caller key + its own project id', () => {
32
36
  for (const [product, projectId] of Object.entries(PRODUCT_PROJECT)) {
33
- const parsed = parseDsn(dsnForProduct(product))
37
+ const parsed = parseDsn(dsnForProduct(product, KEY))
34
38
  expect(parsed, `${product} DSN must parse`).not.toBeNull()
35
39
  expect(parsed!.projectId, `${product} project id`).toBe(projectId)
36
- expect(parsed!.publicKey, `${product} carries the org key`).toBe(HANZO_PUBLISHABLE_KEY)
40
+ expect(parsed!.publicKey, `${product} carries the resolved key`).toBe(KEY)
37
41
  }
38
42
  })
39
43
  })
40
44
 
41
45
  describe('DSN precedence — most specific source wins', () => {
42
- it('lights up the error plane from `product` alone, with no dsn and no env', () => {
43
- const a = createAnalytics({ product: 'console', enabled: false })
46
+ it('lights up the error plane from `product` + a resolved key, with no explicit dsn', () => {
47
+ const a = createAnalytics({ product: 'console', ingestKey: KEY, enabled: false })
44
48
  expect(a.errorPlaneEnabled).toBe(true)
45
49
  expect(a.errorIngestUrl).toContain(PRODUCT_PROJECT.console)
46
50
  })
47
51
 
48
52
  it('prefers an explicit dsn over both the env and the registry', () => {
49
53
  process.env[ENV] = OVERRIDE
50
- const a = createAnalytics({ product: 'console', dsn: EXPLICIT, enabled: false })
54
+ const a = createAnalytics({ product: 'console', ingestKey: KEY, dsn: EXPLICIT, enabled: false })
51
55
  expect(a.errorIngestUrl).toContain('explicit-project')
52
56
  })
53
57
 
54
58
  it('prefers the env override over the registry, so a deploy can repoint a surface', () => {
55
59
  process.env[ENV] = OVERRIDE
56
- const a = createAnalytics({ product: 'console', enabled: false })
60
+ const a = createAnalytics({ product: 'console', ingestKey: KEY, enabled: false })
57
61
  expect(a.errorIngestUrl).toContain('env-project')
58
62
  })
59
63
 
60
64
  it('stays inert for an unregistered product — never posts one surface into another project', () => {
61
- const a = createAnalytics({ product: 'not-a-product', enabled: false })
65
+ const a = createAnalytics({ product: 'not-a-product', ingestKey: KEY, enabled: false })
66
+ expect(a.errorPlaneEnabled).toBe(false)
67
+ expect(a.errorIngestUrl).toBeUndefined()
68
+ })
69
+
70
+ it('stays inert with a product but no key — the error plane needs the live key', () => {
71
+ const a = createAnalytics({ product: 'console', enabled: false })
62
72
  expect(a.errorPlaneEnabled).toBe(false)
63
73
  expect(a.errorIngestUrl).toBeUndefined()
64
74
  })
package/src/dsn.ts CHANGED
@@ -37,40 +37,20 @@ export const PRODUCT_PROJECT: Readonly<Record<string, string>> = Object.freeze({
37
37
  site: '019f9b1e-5785-7359-ad0b-f75db8e58c99', // hanzo.ai (marketing; product `site`)
38
38
  })
39
39
 
40
- /** dsnForProduct builds the product's Sentry DSN the ONE org publishable key at
41
- * its project's envelope endpoint or 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. */
45
- export function dsnForProduct(product: string | undefined): string | undefined {
46
- if (!product) return undefined
40
+ /** dsnForProduct builds the product's Sentry DSN from the caller's resolved
41
+ * publishable `key` and the product's projectthe SAME key the event stream
42
+ * carries, at the product's envelope endpoint. Returns undefined when there is no
43
+ * key or no project for the product, leaving the error plane inert rather than
44
+ * posting into the wrong one. The key is NOT baked here: it is the value the
45
+ * surface resolved (an explicit `ingestKey`, or the KMS-sourced
46
+ * NEXT_PUBLIC_PUBLISHABLE_KEY the build inlines) the ONE live source, never a
47
+ * literal committed beside the code. */
48
+ export function dsnForProduct(
49
+ product: string | undefined,
50
+ key: string | undefined
51
+ ): string | undefined {
52
+ if (!product || !key) return undefined
47
53
  const projectId = PRODUCT_PROJECT[product]
48
54
  if (!projectId) return undefined
49
- return `https://${HANZO_PUBLISHABLE_KEY}@api.hanzo.ai/v1/sentry/${projectId}`
50
- }
51
-
52
- /** The hanzo org's publishable ingest key. Like the DSNs above it is PUBLIC by
53
- * construction: write-only — it attributes a write and mints no reading
54
- * principal — so it ships in the bundle and is readable in devtools, exactly as
55
- * hanzo.id already inlines it. It is the value KMS holds at `deploy/PUBLISHABLE_KEY`
56
- * and every fleet Dockerfile passes as the `PUBLISHABLE_KEY` build-arg; baking it
57
- * as the default is the SAME move `dsnForProduct` makes for errors — a hanzo
58
- * surface on the hanzo cloud emits attributed with zero wiring, and no build can
59
- * ship unkeyed (the failure that files anonymous traffic under `$public`, which
60
- * drops every track/identify/group and answers 200). */
61
- export const HANZO_PUBLISHABLE_KEY =
62
- 'pk-live-c88649f1085fb6ad441d8a0072933a9b'
63
-
64
- /** defaultPublishableKey resolves the baked hanzo key, but ONLY for the explicit
65
- * hanzo cloud host (api.hanzo.ai) — the host the public, anonymous-traffic
66
- * surfaces use. It is deliberately NOT applied to a same-origin `''` host: that
67
- * is the shape a cookie/session app uses (it attributes signed-in users through
68
- * the session, so it needs no anonymous key), AND it is the one host a
69
- * white-label surface shares with hanzo, so defaulting it could attribute the
70
- * wrong org. A custom host gets undefined. An explicit `ingestKey`, or an
71
- * inlined NEXT_PUBLIC_PUBLISHABLE_KEY, still wins over this. */
72
- export function defaultPublishableKey(host: string | undefined): string | undefined {
73
- return host === undefined || host === 'https://api.hanzo.ai'
74
- ? HANZO_PUBLISHABLE_KEY
75
- : undefined
55
+ return `https://${key}@api.hanzo.ai/v1/sentry/${projectId}`
76
56
  }
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
+ })