@hanzo/event 0.3.26 → 0.3.27

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.26'
58
+ var VERSION = '0.3.27'
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.26",
3
+ "version": "0.3.27",
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.ts CHANGED
@@ -28,9 +28,11 @@
28
28
  //
29
29
  // • cookie/session app (host:'') — same-origin credentials ride the request.
30
30
  // • bearer app (getToken) — Authorization: Bearer <jwt>.
31
- // • publishable-key app (ingestKey: 'pk_…') — Authorization: Bearer pk_… on
32
- // fetch, ?ingest_key=pk_… on a headerless page-unload beacon. Write-only and
33
- // safe to ship in a bundle; the door HMAC-verifies it to an org server-side.
31
+ // • publishable-key app (ingestKey: 'pk-…') — ?ingest_key=pk-… on the query, on
32
+ // the fetch and the beacon alike, with a text/plain body. Write-only and safe
33
+ // to ship in a bundle; the door resolves it to an org server-side. The query
34
+ // is the carrier neither send needs a header for, which is what keeps both
35
+ // CORS-simple and therefore sendable from a customer's own origin.
34
36
  //
35
37
  // The wire is the canonical `Event` (== the cloud CaptureEvent): its `type` field
36
38
  // is what Cloud folds to event_type='error', which is how the event WAREHOUSE
@@ -115,7 +117,7 @@ function readEnv(name: string): string | undefined {
115
117
  }
116
118
 
117
119
  /** appendQuery adds a single query param to a URL string — used to carry a
118
- * publishable key on a headerless sendBeacon (?ingest_key=…). */
120
+ * publishable key on either send (?ingest_key=…). */
119
121
  function appendQuery(url: string, key: string, value: string): string {
120
122
  return url + (url.includes('?') ? '&' : '?') + key + '=' + encodeURIComponent(value)
121
123
  }
@@ -170,14 +172,19 @@ function serializeBatch(batch: WireEvent[]): string | null {
170
172
  }
171
173
 
172
174
  /** DefaultTransport: fetch(keepalive) for authenticated/normal sends;
173
- * navigator.sendBeacon for headerless page-unload beacons. A bearer (a JWT or a
174
- * publishable pk_ key) rides Authorization on fetch; on a beacon — which cannot
175
- * set headers — a publishable key rides the ?ingest_key query instead.
175
+ * navigator.sendBeacon for page-unload beacons.
176
+ *
177
+ * A publishable key rides the ?ingest_key query on BOTH, which is what keeps
178
+ * both CORS-simple: the query is the one carrier neither send needs a header
179
+ * for, and a simple request leaves the browser from any origin — permission is
180
+ * asked to READ a cross-origin response, never to send one. That is what lets a
181
+ * bundled client run on a customer's own page. A JWT is first-party by
182
+ * construction, so it keeps Authorization and the session it travels with.
176
183
  *
177
184
  * `contentType` names the FETCH request's Content-Type (the error plane sets the
178
- * envelope type there). The beacon body is always BEACON_CONTENT_TYPE: its type
179
- * is a CORS class, not a payload description, and a beacon that is not a simple
180
- * request is a beacon that is never sent. */
185
+ * envelope type there, and keeps the header carrier with it). The beacon body is
186
+ * always BEACON_CONTENT_TYPE: its type is a CORS class, not a payload
187
+ * description, and a beacon that is not a simple request is never sent. */
181
188
  class DefaultTransport implements Transport {
182
189
  send(
183
190
  url: string,
@@ -254,6 +261,8 @@ export class Analytics {
254
261
  private attribution: Attribution = { utm: {} }
255
262
  private cohort: Cohort = {}
256
263
  private started = false
264
+ /** The view pageview() last counted — path + location. */
265
+ private counted?: string
257
266
  /** Parsed error-plane DSN, or null when the plane is inert. */
258
267
  private dsn: Dsn | null
259
268
  /** Guards against an error thrown *inside* the error path re-entering it. */
@@ -292,6 +301,21 @@ export class Analytics {
292
301
  )
293
302
  }
294
303
 
304
+ /** adopt gives this client a credential it does not have. The key belongs to
305
+ * the stream, not to whichever caller happened to ask for the handle first,
306
+ * so a later caller carrying one hands it over. The error plane derives from
307
+ * the key, so it comes up here too when it was inert for want of one.
308
+ * Present fields are never overwritten: the first caller's key stays. */
309
+ adopt(config: AnalyticsConfig): void {
310
+ if (config.ingestKey && !this.cfg.ingestKey) this.cfg.ingestKey = config.ingestKey
311
+ if (config.getToken && !this.cfg.getToken) this.cfg.getToken = config.getToken
312
+ if (!this.dsn) {
313
+ this.dsn = parseDsn(
314
+ config.dsn ?? readEnvDsn() ?? dsnForProduct(this.cfg.product, this.cfg.ingestKey),
315
+ )
316
+ }
317
+ }
318
+
295
319
  /** errorPlaneEnabled reports whether captured exceptions can actually reach the
296
320
  * error host. False means a DSN was never configured — the documented
297
321
  * fail-safe. Exposed so an app (or a test) can assert its wiring instead of
@@ -356,13 +380,22 @@ export class Analytics {
356
380
  this.enqueue('group', undefined, { groupId, properties: traits })
357
381
  }
358
382
 
359
- /** pageview records a $pageview for the current (or given) location. */
383
+ /** pageview records a $pageview for the current (or given) location, once per
384
+ * view. A view is the path plus the full location, so a query or hash change
385
+ * is a new one and a repeat call for the same place is not. */
360
386
  pageview(path?: string, properties?: Record<string, unknown>): void {
361
387
  // No `url` here: build() reads window.location.href for every event, in the
362
388
  // same tick, so this recomputed it to the identical value. Only `path` is
363
389
  // passed, because a route change fires before window.location has caught up
364
390
  // and the caller's value has to win.
365
391
  const p = path ?? (isBrowser() ? window.location.pathname : undefined)
392
+ // Counting a page once is the client's rule, not each caller's. A page holds
393
+ // several emitters that each correctly believe they count the first view —
394
+ // the provider's autoPageview, a router hook, a nav autocapture — and per
395
+ // emitter state cannot see the others. On the stream they are one page.
396
+ const view = (p ?? '') + '\0' + (isBrowser() ? window.location.href : '')
397
+ if (view === this.counted) return
398
+ this.counted = view
366
399
  this.enqueue('pageview', PAGEVIEW, { path: p, properties })
367
400
  }
368
401
 
@@ -463,8 +496,8 @@ export class Analytics {
463
496
  * front door POST /v1/event, body { batch: [Event…] }. beacon=true selects the
464
497
  * unload-safe transport. Auth is orthogonal to the wire:
465
498
  *
466
- * • publishable key set → rides Authorization: Bearer pk_… (fetch) or
467
- * ?ingest_key=pk_… (beacon), so unload beacons work anonymously.
499
+ * • publishable key set → rides ?ingest_key=pk-… on both sends, keeping each
500
+ * a CORS-simple request that any origin may send.
468
501
  * • else a bearer JWT rides Authorization (fetch only — sendBeacon cannot
469
502
  * carry a header, so token apps fall back to keepalive fetch on unload).
470
503
  * • else a cookie app rides same-origin credentials (beacon carries the
@@ -490,8 +523,8 @@ export class Analytics {
490
523
  // leak introduced by an env var.
491
524
  const token = this.cfg.getToken?.() ?? undefined
492
525
  const key = token ? undefined : this.cfg.ingestKey?.trim() || undefined
493
- // Only a headerful bearer JWT blocks the beacon: sendBeacon cannot set an
494
- // Authorization header. A pk_ rides ?ingest_key; a cookie rides credentials.
526
+ // Only a bearer JWT blocks the beacon: sendBeacon cannot set an Authorization
527
+ // header. A pk- rides ?ingest_key; a cookie rides credentials.
495
528
  const useBeacon = beacon && !token
496
529
  const body = serializeBatch(batch)
497
530
  if (body === null) {
@@ -631,9 +664,52 @@ export class Analytics {
631
664
  }
632
665
  }
633
666
 
634
- /** createAnalytics builds a client instance. Most apps use one shared instance. */
667
+ // ── one stream, one client ──────────────────────────────────────────────────
668
+ //
669
+ // A page has ONE event stream per (host, product): one queue, one anon id, one
670
+ // credential. Two clients on it split the batch and double the pageview, and
671
+ // the half built by the caller that had no key is refused at the door — so the
672
+ // same page is both over- and under-counted, and neither number says so.
673
+ //
674
+ // Two callers ask for that stream on any real page and both are right to: the
675
+ // app mounts a provider, and the component library mounts its own telemetry.
676
+ // Neither can see the other, so the fix cannot live in either. It lives here,
677
+ // where the stream is: asking twice returns the same client.
678
+ //
679
+ // The registry is on the PAGE, under a `Symbol.for` slot, not in module scope.
680
+ // `@hanzo/event` and `@hanzo/event/react` are separate bundles that each carry
681
+ // a copy of this module, so a module-scope map is per-copy and the two copies
682
+ // never see each other's — which is exactly how the duplicate arises. A
683
+ // well-known symbol is the one slot every copy resolves to the same value.
684
+ //
685
+ // Browser only. A server process serves many visitors, and one client shared
686
+ // across requests would carry one visitor's personId into the next.
687
+ const CLIENTS = Symbol.for('hanzo.event.clients')
688
+
689
+ function registry(): Map<string, Analytics> {
690
+ const g = globalThis as unknown as Record<symbol, Map<string, Analytics> | undefined>
691
+ const existing = g[CLIENTS]
692
+ if (existing) return existing
693
+ const fresh = new Map<string, Analytics>()
694
+ g[CLIENTS] = fresh
695
+ return fresh
696
+ }
697
+
698
+ /** createAnalytics returns the client for a stream, building it on first ask.
699
+ * This is the ONE way to get a client: `new Analytics` bypasses the registry
700
+ * and is for tests and for a deliberately separate instance. */
635
701
  export function createAnalytics(config: AnalyticsConfig): Analytics {
636
- return new Analytics(config)
702
+ if (!isBrowser()) return new Analytics(config)
703
+ const key = (config.host ?? DEFAULT_HOST) + '\0' + config.product
704
+ const clients = registry()
705
+ const existing = clients.get(key)
706
+ if (existing) {
707
+ existing.adopt(config)
708
+ return existing
709
+ }
710
+ const fresh = new Analytics(config)
711
+ clients.set(key, fresh)
712
+ return fresh
637
713
  }
638
714
 
639
715
  // Re-export the hydrate helpers so consumers can read persisted cohort/attribution
package/src/react.tsx CHANGED
@@ -15,7 +15,6 @@ import {
15
15
  createElement,
16
16
  useContext,
17
17
  useEffect,
18
- useMemo,
19
18
  useRef,
20
19
  type ErrorInfo,
21
20
  type ReactNode,
@@ -36,12 +35,13 @@ export interface AnalyticsProviderProps {
36
35
 
37
36
  export function AnalyticsProvider(props: AnalyticsProviderProps) {
38
37
  const { client, config, autoPageview = true, children } = props
39
- const instance = useMemo<Analytics>(() => {
40
- if (client) return client
41
- if (config) return createAnalytics(config)
42
- throw new Error('AnalyticsProvider requires `client` or `config`')
43
- // eslint-disable-next-line react-hooks/exhaustive-deps
44
- }, [client])
38
+ // No memo. createAnalytics returns the client for a stream, so asking on every
39
+ // render hands back the same object — the identity a memo used to be here to
40
+ // preserve. Memoizing construction is what made a config change unreachable
41
+ // (deps held `client` alone) while adding `config` would have built a client
42
+ // per render, since call sites pass an object literal.
43
+ if (!client && !config) throw new Error('AnalyticsProvider requires `client` or `config`')
44
+ const instance = client ?? createAnalytics(config as AnalyticsConfig)
45
45
 
46
46
  useEffect(() => {
47
47
  instance.init()
@@ -0,0 +1,169 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // One page, one stream. The shape under test is the one hanzo.ai serves: the
4
+ // component library mounts its telemetry, the app mounts its own provider, and
5
+ // each asks for a client for the same (host, product). Measured on production,
6
+ // that page put two batches on api.hanzo.ai/v1/event in the same frame — one
7
+ // carrying the publishable key (200, accepted 2) and one carrying no
8
+ // Authorization at all (401, ingest_key_required) — because the library asked
9
+ // first and had no key. Both batches held a $pageview for the same page.
10
+ import { describe, it, expect, beforeEach } from 'vitest'
11
+ import { Analytics, createAnalytics } from './core'
12
+ import { PAGEVIEW } from './events'
13
+ import type { Transport, WireEvent } from './types'
14
+
15
+ const HOST = 'https://api.hanzo.ai'
16
+ const KEY = 'pk-CmfLA2K6kvsP'
17
+
18
+ interface Sent {
19
+ token?: string
20
+ ingestKey?: string
21
+ batch: WireEvent[]
22
+ }
23
+
24
+ class FakeTransport implements Transport {
25
+ sent: Sent[] = []
26
+ send(_url: string, body: string, opts: { token?: string; ingestKey?: string }) {
27
+ this.sent.push({
28
+ token: opts.token,
29
+ ingestKey: opts.ingestKey,
30
+ batch: (JSON.parse(body) as { batch: WireEvent[] }).batch ?? [],
31
+ })
32
+ }
33
+ get all(): WireEvent[] {
34
+ return this.sent.flatMap((s) => s.batch)
35
+ }
36
+ get pageviews(): WireEvent[] {
37
+ return this.all.filter((e) => e.event === PAGEVIEW)
38
+ }
39
+ }
40
+
41
+ let tx: FakeTransport
42
+
43
+ /** The library's ask: product from the hostname, no key — the env that carries
44
+ * one is not set on this build. It asks FIRST, as it does in the tree. */
45
+ const library = () =>
46
+ createAnalytics({ product: 'site', host: HOST, transport: tx, flushIntervalMs: 999999 })
47
+
48
+ /** The app's ask: the same stream, with the credential. */
49
+ const app = () =>
50
+ createAnalytics({
51
+ product: 'site',
52
+ host: HOST,
53
+ getToken: () => KEY,
54
+ transport: new FakeTransport(),
55
+ flushIntervalMs: 999999,
56
+ })
57
+
58
+ beforeEach(() => {
59
+ tx = new FakeTransport()
60
+ delete (globalThis as unknown as Record<symbol, unknown>)[Symbol.for('hanzo.event.clients')]
61
+ window.history.replaceState(null, '', '/pricing')
62
+ })
63
+
64
+ describe('one stream, one client', () => {
65
+ it('hands both callers the same client', () => {
66
+ expect(app()).toBe(library())
67
+ })
68
+
69
+ it('sends one batch, not two', () => {
70
+ library().pageview('/pricing')
71
+ app().capture('pricing_viewed')
72
+ app().flush()
73
+ expect(tx.sent).toHaveLength(1)
74
+ expect(tx.all).toHaveLength(2)
75
+ })
76
+
77
+ it('carries the credential even though the caller without one asked first', () => {
78
+ library().pageview('/pricing')
79
+ app().flush()
80
+ expect(tx.sent).toHaveLength(1)
81
+ expect(tx.sent[0].token).toBe(KEY)
82
+ })
83
+
84
+ it('brings up the error plane on the key it adopts', () => {
85
+ const a = createAnalytics({ product: 'console', host: HOST, transport: tx })
86
+ expect(a.errorPlaneEnabled).toBe(false)
87
+ createAnalytics({ product: 'console', host: HOST, ingestKey: KEY })
88
+ expect(a.errorPlaneEnabled).toBe(true)
89
+ })
90
+
91
+ it('keeps the first credential rather than overwriting it', () => {
92
+ createAnalytics({ product: 'site', host: HOST, ingestKey: KEY, transport: tx })
93
+ createAnalytics({ product: 'site', host: HOST, ingestKey: 'pk-second' }).capture('x')
94
+ createAnalytics({ product: 'site', host: HOST }).flush()
95
+ expect(tx.sent[0].ingestKey).toBe(KEY)
96
+ })
97
+
98
+ it('keeps separate streams separate', () => {
99
+ expect(createAnalytics({ product: 'chat', host: HOST })).not.toBe(
100
+ createAnalytics({ product: 'site', host: HOST }),
101
+ )
102
+ expect(createAnalytics({ product: 'site', host: '' })).not.toBe(
103
+ createAnalytics({ product: 'site', host: HOST }),
104
+ )
105
+ })
106
+
107
+ it('leaves new Analytics outside the registry', () => {
108
+ const own = new Analytics({ product: 'site', host: HOST })
109
+ expect(createAnalytics({ product: 'site', host: HOST })).not.toBe(own)
110
+ })
111
+
112
+ // The package publishes two entries and the react one carries its own copy of
113
+ // this module, so on a page that imports both there are two module scopes. A
114
+ // registry held in module scope is per-copy and would not dedupe the only case
115
+ // that matters. Two distinct instances of this module, one client.
116
+ it('holds one registry across separate copies of this module', async () => {
117
+ const one = (await import('./core?copy=1')) as typeof import('./core')
118
+ const two = (await import('./core?copy=2')) as typeof import('./core')
119
+ expect(one).not.toBe(two)
120
+ expect(one.createAnalytics({ product: 'site', host: HOST })).toBe(
121
+ two.createAnalytics({ product: 'site', host: HOST, ingestKey: KEY }),
122
+ )
123
+ })
124
+ })
125
+
126
+ describe('one pageview per view', () => {
127
+ it('counts a page once when two emitters both count the first view', () => {
128
+ library().pageview('/pricing') // the library's route hook, on mount
129
+ app().pageview() // the app provider's autoPageview, same frame
130
+ app().flush()
131
+ expect(tx.pageviews).toHaveLength(1)
132
+ })
133
+
134
+ it('counts a page once even through one client', () => {
135
+ const a = library()
136
+ a.pageview('/pricing')
137
+ a.pageview('/pricing')
138
+ a.flush()
139
+ expect(tx.pageviews).toHaveLength(1)
140
+ })
141
+
142
+ it('counts a route change', () => {
143
+ const a = library()
144
+ a.pageview('/pricing')
145
+ window.history.pushState(null, '', '/docs')
146
+ a.pageview('/docs')
147
+ a.flush()
148
+ expect(tx.pageviews.map((e) => e.path)).toEqual(['/pricing', '/docs'])
149
+ })
150
+
151
+ it('counts a return to a page already seen', () => {
152
+ const a = library()
153
+ a.pageview('/pricing')
154
+ a.pageview('/docs')
155
+ a.pageview('/pricing')
156
+ a.flush()
157
+ expect(tx.pageviews).toHaveLength(3)
158
+ })
159
+
160
+ it('counts a query change on one path', () => {
161
+ const a = library()
162
+ window.history.replaceState(null, '', '/search?q=a')
163
+ a.pageview('/search')
164
+ window.history.replaceState(null, '', '/search?q=b')
165
+ a.pageview('/search')
166
+ a.flush()
167
+ expect(tx.pageviews).toHaveLength(2)
168
+ })
169
+ })
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.26'
4
+ export const VERSION = '0.3.27'