@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/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 +124 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -10
- package/dist/index.d.ts +66 -10
- package/dist/index.mjs +120 -27
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +119 -26
- 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 +119 -26
- package/dist/react.mjs.map +1 -1
- package/hz.js +7 -8
- package/package.json +1 -1
- package/src/core.test.ts +48 -49
- package/src/core.ts +48 -27
- package/src/dsn.test.ts +28 -18
- package/src/dsn.ts +14 -34
- package/src/events.ts +5 -0
- package/src/exception.test.ts +188 -0
- package/src/exception.ts +200 -0
- package/src/hz.test.ts +4 -10
- package/src/index.ts +5 -1
- package/src/types.ts +53 -0
- package/src/version.ts +1 -1
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
|
|
388
|
-
//
|
|
389
|
-
|
|
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('
|
|
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
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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].
|
|
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
|
|
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
|
|
242
|
-
//
|
|
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
|
|
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
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
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
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// throwing into the host app.
|
|
269
|
-
this.dsn = parseDsn(
|
|
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 =
|
|
394
|
-
|
|
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
|
|
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
|
|
17
|
-
expect(dsnForProduct('console')).toBe(
|
|
18
|
-
`https://${
|
|
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://${
|
|
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
|
|
26
|
-
expect(dsnForProduct('
|
|
27
|
-
expect(dsnForProduct(
|
|
28
|
-
expect(dsnForProduct(
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
/** dsnForProduct builds the product's Sentry DSN from the caller's resolved
|
|
41
|
+
* publishable `key` and the product's project — the 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://${
|
|
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
|
+
})
|