@hanzo/event 0.3.27 → 0.3.29

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/hz.js CHANGED
@@ -55,7 +55,7 @@
55
55
  }
56
56
 
57
57
  var LIB = 'hz.js'
58
- var VERSION = '0.3.27'
58
+ var VERSION = '0.3.29'
59
59
  var host = (s.getAttribute('data-host') || 'https://api.hanzo.ai').replace(/\/+$/, '')
60
60
  var product = s.getAttribute('data-product') || location.hostname
61
61
  var capture = s.getAttribute('data-capture') !== '0'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/event",
3
- "version": "0.3.27",
3
+ "version": "0.3.29",
4
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.",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/",
package/src/core.test.ts CHANGED
@@ -552,7 +552,7 @@ describe('Event error capture', () => {
552
552
  const TEST_DSN =
553
553
  'https://1:' +
554
554
  '0'.repeat(64) +
555
- '@sentry.hanzo.ai/v1/sentry/019f9b1e-5785-7359-ad0b-f75db8e58c99'
555
+ '@sentry.hanzo.ai/v1/event/019f9b1e-5785-7359-ad0b-f75db8e58c99'
556
556
 
557
557
  describe('error plane', () => {
558
558
  it('is INERT without a DSN — nothing sent to sentry, event stream unaffected', () => {
@@ -574,7 +574,7 @@ describe('error plane', () => {
574
574
  const env = tx.envelopes[0]
575
575
  // The exact route the o11y ingest registers, key on the query string.
576
576
  expect(env.url).toBe(
577
- 'https://sentry.hanzo.ai/v1/sentry/019f9b1e-5785-7359-ad0b-f75db8e58c99/envelope/' +
577
+ 'https://sentry.hanzo.ai/v1/event/019f9b1e-5785-7359-ad0b-f75db8e58c99/envelope/' +
578
578
  '?sentry_key=1%3A' + '0'.repeat(64),
579
579
  )
580
580
  expect(env.contentType).toBe('application/x-sentry-envelope')
@@ -600,7 +600,7 @@ describe('error plane', () => {
600
600
  // byte length, not the JS string length.
601
601
  expect(item.length).toBe(new TextEncoder().encode(payload).length)
602
602
  expect(header.dsn).toBe(
603
- 'https://sentry.hanzo.ai/v1/sentry/019f9b1e-5785-7359-ad0b-f75db8e58c99',
603
+ 'https://sentry.hanzo.ai/v1/event/019f9b1e-5785-7359-ad0b-f75db8e58c99',
604
604
  )
605
605
  const ev = JSON.parse(payload) as {
606
606
  event_id: string
package/src/core.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // 2. ERROR PLANE — every captured exception is ALSO framed as a real Sentry
11
11
  // envelope and POSTed to the error host named by the DSN:
12
- // POST {dsn.origin}/v1/sentry/{projectId}/envelope/?sentry_key=…
12
+ // POST {dsn.base}/envelope/?sentry_key=… ({base} = /v1/event/{projectId})
13
13
  // This is what reaches sentry.hanzo.ai (issues, grouping, stack frames).
14
14
  //
15
15
  // These are NOT the same pipe and one does NOT feed the other. The event stream
@@ -699,7 +699,14 @@ function registry(): Map<string, Analytics> {
699
699
  * This is the ONE way to get a client: `new Analytics` bypasses the registry
700
700
  * and is for tests and for a deliberately separate instance. */
701
701
  export function createAnalytics(config: AnalyticsConfig): Analytics {
702
- if (!isBrowser()) return new Analytics(config)
702
+ // A disabled client emits nothing, so it can double nothing and does not take
703
+ // the stream. Callers reach their answer at different moments — a provider
704
+ // whose ownership resolves in an effect renders once saying `false` before it
705
+ // has decided anything — and the client built in that moment must not become
706
+ // the one the page keeps. Registering it would silence the page, and adopting
707
+ // `enabled` from a later caller would let one surface overrule another's
708
+ // reading of consent. Neither: it is simply not the stream's client.
709
+ if (!isBrowser() || config.enabled === false) return new Analytics(config)
703
710
  const key = (config.host ?? DEFAULT_HOST) + '\0' + config.product
704
711
  const clients = registry()
705
712
  const existing = clients.get(key)
package/src/dsn.test.ts CHANGED
@@ -5,8 +5,8 @@ import { PRODUCT_PROJECT, dsnForProduct } from './dsn'
5
5
  import { parseDsn } from './sentry'
6
6
 
7
7
  const ENV = 'NEXT_PUBLIC_HANZO_EVENT_DSN'
8
- const OVERRIDE = 'https://1:aaaa@api.hanzo.ai/v1/sentry/env-project'
9
- const EXPLICIT = 'https://1:bbbb@api.hanzo.ai/v1/sentry/explicit-project'
8
+ const OVERRIDE = 'https://1:aaaa@api.hanzo.ai/v1/event/env-project'
9
+ const EXPLICIT = 'https://1:bbbb@api.hanzo.ai/v1/event/explicit-project'
10
10
  // A stand-in for the surface's own resolved key — the DSN carries whatever key
11
11
  // the caller resolved (config or the KMS-sourced env), never a literal.
12
12
  const KEY = 'pk-test-resolved-key'
@@ -18,10 +18,10 @@ afterEach(() => {
18
18
  describe('the product registry', () => {
19
19
  it('builds a product DSN from the CALLER key + the project — nothing baked', () => {
20
20
  expect(dsnForProduct('console', KEY)).toBe(
21
- `https://${KEY}@api.hanzo.ai/v1/sentry/${PRODUCT_PROJECT.console}`
21
+ `https://${KEY}@api.hanzo.ai/v1/event/${PRODUCT_PROJECT.console}`
22
22
  )
23
23
  expect(dsnForProduct('site', KEY)).toBe(
24
- `https://${KEY}@api.hanzo.ai/v1/sentry/${PRODUCT_PROJECT.site}`
24
+ `https://${KEY}@api.hanzo.ai/v1/event/${PRODUCT_PROJECT.site}`
25
25
  )
26
26
  })
27
27
 
package/src/dsn.ts CHANGED
@@ -52,5 +52,5 @@ export function dsnForProduct(
52
52
  if (!product || !key) return undefined
53
53
  const projectId = PRODUCT_PROJECT[product]
54
54
  if (!projectId) return undefined
55
- return `https://${key}@api.hanzo.ai/v1/sentry/${projectId}`
55
+ return `https://${key}@api.hanzo.ai/v1/event/${projectId}`
56
56
  }
@@ -11,7 +11,7 @@ import {
11
11
  import type { SentryEvent, SentryFrame } from './types'
12
12
  import { VERSION } from './version'
13
13
 
14
- const DSN = 'https://1:deadbeefcafe@api.hanzo.ai/v1/sentry/00000000-0000-0000-0000-000000000000'
14
+ const DSN = 'https://1:deadbeefcafe@api.hanzo.ai/v1/event/00000000-0000-0000-0000-000000000000'
15
15
 
16
16
  describe('parseDsn', () => {
17
17
  it('keeps the version:hmac key intact (does NOT split on the colon) and derives the ingest url', () => {
@@ -20,14 +20,14 @@ describe('parseDsn', () => {
20
20
  expect(dsn.origin).toBe('https://api.hanzo.ai')
21
21
  expect(dsn.projectId).toBe('00000000-0000-0000-0000-000000000000')
22
22
  expect(dsn.ingestUrl).toBe(
23
- 'https://api.hanzo.ai/v1/sentry/00000000-0000-0000-0000-000000000000/envelope/?sentry_key=1%3Adeadbeefcafe',
23
+ 'https://api.hanzo.ai/v1/event/00000000-0000-0000-0000-000000000000/envelope/?sentry_key=1%3Adeadbeefcafe',
24
24
  )
25
25
  })
26
26
  it('returns null for malformed input (fail-safe)', () => {
27
27
  expect(parseDsn(undefined)).toBeNull()
28
28
  expect(parseDsn('')).toBeNull()
29
29
  expect(parseDsn('not-a-dsn')).toBeNull()
30
- expect(parseDsn('https://api.hanzo.ai/v1/sentry/x')).toBeNull() // no key
30
+ expect(parseDsn('https://api.hanzo.ai/v1/event/x')).toBeNull() // no key
31
31
  expect(parseDsn('https://1:k@api.hanzo.ai')).toBeNull() // no project
32
32
  })
33
33
  })
package/src/sentry.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Analytics client (core.ts) wires these to identity + transport.
4
4
  //
5
5
  // The wire shapes are a from-scratch model of the PUBLIC, documented Sentry ingest
6
- // protocol (develop.sentry.dev), verified against the Hanzo /v1/sentry ingest
6
+ // protocol (develop.sentry.dev), verified against the Hanzo /v1/event ingest
7
7
  // (o11y pkg/modules/errortracking/implerrortracking: parseEnvelope, SentryEvent,
8
8
  // normalizeEvent, computeFingerprint). No upstream (FSL) code is used.
9
9
 
@@ -44,7 +44,7 @@ function byteLen(s: string): number {
44
44
  }
45
45
 
46
46
  /**
47
- * parseDsn parses "https://<version>:<hmac>@<host>/v1/sentry/<projectId>" into its
47
+ * parseDsn parses "https://<version>:<hmac>@<host>/v1/event/<projectId>" into its
48
48
  * public key + the derived envelope ingest URL. The key (which itself contains a
49
49
  * ':') is taken verbatim as the userinfo — we do NOT split it as user:pass. The
50
50
  * key rides ?sentry_key= (not the DSN in the body) because that is the credential
@@ -64,10 +64,14 @@ export function parseDsn(dsn: string | undefined | null): Dsn | null {
64
64
  const projectId = segs.length > 0 ? segs[segs.length - 1] : ''
65
65
  if (!projectId) return null
66
66
  const origin = `${scheme}://${host}`
67
- const ingestUrl =
68
- `${origin}/v1/sentry/${encodeURIComponent(projectId)}/envelope/` +
69
- `?sentry_key=${encodeURIComponent(publicKey)}`
70
- return { publicKey, origin, projectId, ingestUrl }
67
+ // THE DSN'S OWN PATH IS THE ADDRESS — derived here, never restated. It used to
68
+ // be rebuilt as a literal, which threw away the path the DSN carried and wrote
69
+ // the ingest address a second time; buildEnvelope wrote it a third. Three copies
70
+ // of one fact. dsnForProduct (dsn.ts) is the ONE place it is spelled; this
71
+ // follows it.
72
+ const base = `${origin}/${segs.map(encodeURIComponent).join('/')}`
73
+ const ingestUrl = `${base}/envelope/?sentry_key=${encodeURIComponent(publicKey)}`
74
+ return { publicKey, origin, projectId, base, ingestUrl }
71
75
  }
72
76
 
73
77
  const V8_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(?:(.+?):(\d+):(\d+)|([^)]+))\)?\s*$/
@@ -295,7 +299,7 @@ export function buildEnvelope(event: SentryEvent, dsn: Dsn, sentAt?: string): st
295
299
  const payload = JSON.stringify(event)
296
300
  const header = JSON.stringify({
297
301
  event_id: event.event_id,
298
- dsn: `${dsn.origin}/v1/sentry/${dsn.projectId}`,
302
+ dsn: dsn.base,
299
303
  sent_at: sentAt ?? new Date().toISOString(),
300
304
  })
301
305
  const itemHeader = JSON.stringify({
@@ -95,6 +95,32 @@ describe('one stream, one client', () => {
95
95
  expect(tx.sent[0].ingestKey).toBe(KEY)
96
96
  })
97
97
 
98
+ // A provider whose ownership resolves in an effect renders once saying
99
+ // `enabled: false` and then again saying true. Measured on hanzo.ai: the
100
+ // library's first render built a disabled client, and a registry that kept it
101
+ // handed that one to the app too — the page went silent, which is worse than
102
+ // the double it replaced.
103
+ it('does not let a disabled client take the stream', () => {
104
+ const off = createAnalytics({ product: 'site', host: HOST, enabled: false })
105
+ const on = createAnalytics({ product: 'site', host: HOST, transport: tx, flushIntervalMs: 999999 })
106
+ expect(on).not.toBe(off)
107
+ on.pageview('/pricing')
108
+ on.flush()
109
+ expect(tx.pageviews).toHaveLength(1)
110
+ expect(createAnalytics({ product: 'site', host: HOST, getToken: () => KEY })).toBe(on)
111
+ })
112
+
113
+ it('gives every disabled caller its own inert client', () => {
114
+ const a = createAnalytics({ product: 'site', host: HOST, enabled: false, transport: tx })
115
+ const b = createAnalytics({ product: 'site', host: HOST, enabled: false, transport: tx })
116
+ expect(a).not.toBe(b)
117
+ a.pageview('/pricing')
118
+ b.pageview('/pricing')
119
+ a.flush()
120
+ b.flush()
121
+ expect(tx.sent).toHaveLength(0)
122
+ })
123
+
98
124
  it('keeps separate streams separate', () => {
99
125
  expect(createAnalytics({ product: 'chat', host: HOST })).not.toBe(
100
126
  createAnalytics({ product: 'site', host: HOST }),
package/src/types.ts CHANGED
@@ -209,7 +209,7 @@ export interface AnalyticsConfig {
209
209
 
210
210
  // ── error plane (Sentry envelope -> sentry.hanzo.ai) ──────────────────────
211
211
 
212
- /** Hanzo-minted Sentry DSN: "https://<version>:<hmac>@<host>/v1/sentry/<projectId>".
212
+ /** Hanzo-minted Sentry DSN: "https://<version>:<hmac>@<host>/v1/event/<projectId>".
213
213
  * Publishable — the key authorizes writes to ONE project and can read nothing,
214
214
  * so it is safe in a browser bundle (same trust class as `ingestKey`). When
215
215
  * absent the client reads NEXT_PUBLIC_HANZO_EVENT_DSN; when neither is set the
@@ -278,6 +278,10 @@ export interface Dsn {
278
278
  origin: string
279
279
  /** Project id segment. */
280
280
  projectId: string
281
+ /** The DSN's own origin + path, e.g. "https://api.hanzo.ai/v1/event/<projectId>".
282
+ * Every URL below is derived from this, so the ingest address is named once —
283
+ * in dsnForProduct — and nowhere else. */
284
+ base: string
281
285
  /** Fully-derived envelope ingest URL incl. ?sentry_key=. */
282
286
  ingestUrl: string
283
287
  }
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  // The library version, stamped on every event (`libraryVersion`) and on the
2
2
  // Sentry `sdk` block. It lives alone so `sentry.ts` can read it without importing
3
3
  // `core.ts` — core imports sentry, so the reverse would be an import cycle.
4
- export const VERSION = '0.3.27'
4
+ export const VERSION = '0.3.29'