@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/dist/{core-D4PaPlOB.d.cts → core-CHLpJy51.d.cts} +56 -1
- package/dist/{core-D4PaPlOB.d.ts → core-CHLpJy51.d.ts} +56 -1
- package/dist/index.cjs +119 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +70 -10
- package/dist/index.d.ts +70 -10
- package/dist/index.mjs +114 -12
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +113 -11
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.mjs +113 -11
- package/dist/react.mjs.map +1 -1
- package/hz.js +1 -1
- package/package.json +10 -10
- package/src/core.test.ts +45 -12
- package/src/core.ts +30 -3
- package/src/dsn.test.ts +14 -11
- package/src/dsn.ts +19 -14
- package/src/events.ts +5 -0
- package/src/exception.test.ts +188 -0
- package/src/exception.ts +200 -0
- package/src/index.ts +6 -2
- package/src/types.ts +53 -0
- package/src/version.ts +1 -1
- package/LICENSE.md +0 -21
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Hanzo Event
|
|
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('
|
|
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
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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].
|
|
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 =
|
|
394
|
-
|
|
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 {
|
|
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('
|
|
17
|
-
expect(dsnForProduct('console')).toBe(
|
|
18
|
-
|
|
19
|
-
|
|
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('
|
|
29
|
-
for (const [product,
|
|
30
|
-
const parsed = parseDsn(
|
|
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}
|
|
33
|
-
expect(parsed!.publicKey, `${product}
|
|
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(
|
|
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
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
* project — an 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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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. */
|
|
42
45
|
export function dsnForProduct(product: string | undefined): string | undefined {
|
|
43
46
|
if (!product) return undefined
|
|
44
|
-
|
|
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
|
+
})
|