@hanzo/event 0.3.25 → 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.25'
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'
@@ -284,32 +284,31 @@ function hzAnonId() {
284
284
  if (!queue.length) return
285
285
  var body = JSON.stringify({ batch: queue.splice(0, queue.length) })
286
286
  var url = host + '/v1/event'
287
- // The key rides the two channels each transport can actually carry the
288
- // same pair core.ts uses, so the door cannot tell the distributions apart:
289
- // a headerless sendBeacon puts it in the query, a fetch puts it in the
290
- // Authorization header.
287
+ // BOTH transports send a SIMPLE request, and for the same reason. The key is
288
+ // publishable (data-publishable-key, write-only and safe in page source), so
289
+ // it rides the query the one carrier neither transport needs a header for —
290
+ // and the body is text/plain, the CORS-safelisted type. A simple request is
291
+ // sent whatever origin it is on, because the browser asks permission to READ
292
+ // a cross-origin response, never to send one, and nothing here reads it.
291
293
  //
292
- // The beacon body is text/plain because that type is CORS-safelisted, which
293
- // makes the POST a SIMPLE request: no preflight, and an unloading document
294
- // gets no second round trip. The door reads the raw body and dispatches on
295
- // its first non-space byte, so the type names the CORS class and nothing else.
294
+ // That property is what lets this file run on a customer's own page at all.
295
+ // Every send from there is cross-origin; an Authorization header or a JSON
296
+ // content type makes the POST preflighted instead, and an origin that does not
297
+ // pass the preflight loses the batch, which splice has already emptied.
298
+ //
299
+ // The door reads the raw body and dispatches on its first non-space byte, so
300
+ // the type names the CORS class and nothing else.
301
+ var wire = key ? url + '?ingest_key=' + encodeURIComponent(key) : url
296
302
  try {
297
- if (
298
- navigator.sendBeacon &&
299
- navigator.sendBeacon(
300
- key ? url + '?ingest_key=' + encodeURIComponent(key) : url,
301
- new Blob([body], { type: 'text/plain' }),
302
- )
303
- )
303
+ if (navigator.sendBeacon && navigator.sendBeacon(wire, new Blob([body], { type: 'text/plain' })))
304
304
  return
305
305
  } catch (e) {}
306
- var headers = { 'content-type': 'application/json' }
307
- if (key) headers.authorization = 'Bearer ' + key
308
- fetch(url, {
306
+ fetch(wire, {
309
307
  method: 'POST',
310
308
  body: body,
311
309
  keepalive: true,
312
- headers: headers,
310
+ credentials: 'omit',
311
+ headers: { 'content-type': 'text/plain' },
313
312
  }).catch(function () {})
314
313
  }
315
314
  // ── location redaction ────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hanzo/event",
3
- "version": "0.3.25",
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.",
3
+ "version": "0.3.27",
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/",
7
7
  "access": "public",
@@ -37,13 +37,6 @@
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
- },
47
40
  "exports": {
48
41
  ".": {
49
42
  "import": {
@@ -84,5 +77,12 @@
84
77
  },
85
78
  "dependencies": {
86
79
  "@hanzo/events": "^0.2.1"
80
+ },
81
+ "scripts": {
82
+ "build": "tsup",
83
+ "dev": "tsup --watch",
84
+ "test": "vitest run",
85
+ "typecheck": "tsc --noEmit",
86
+ "clean": "rm -rf dist"
87
87
  }
88
- }
88
+ }
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,
@@ -207,17 +214,25 @@ class DefaultTransport implements Transport {
207
214
  }
208
215
  }
209
216
  if (typeof fetch !== 'function') return
217
+ // A publishable key makes the send a CORS SIMPLE request: the key rides the
218
+ // query, the body is text/plain, and nothing asks for credentials. That is what
219
+ // lets a bundled client run on a customer's own page — every send from there is
220
+ // cross-origin, and an Authorization header or a JSON type preflights the POST
221
+ // for an origin that cannot pass one. A JWT is first-party by construction, so
222
+ // it keeps the header and the session it travels with, and so does the error
223
+ // plane, which names its own envelope type.
224
+ const simple = opts.ingestKey !== undefined && opts.contentType === undefined
210
225
  const headers: Record<string, string> = {
211
- 'Content-Type': opts.contentType ?? 'application/json',
226
+ 'Content-Type': simple ? BEACON_CONTENT_TYPE : (opts.contentType ?? 'application/json'),
212
227
  }
213
- const bearer = opts.ingestKey ?? opts.token
228
+ const bearer = simple ? undefined : (opts.ingestKey ?? opts.token)
214
229
  if (bearer) headers.Authorization = `Bearer ${bearer}`
215
- void fetch(url, {
230
+ void fetch(simple ? appendQuery(url, 'ingest_key', opts.ingestKey!) : url, {
216
231
  method: 'POST',
217
232
  headers,
218
233
  body,
219
234
  keepalive: true,
220
- credentials: 'include',
235
+ credentials: simple ? 'omit' : 'include',
221
236
  })
222
237
  .then((res) => {
223
238
  // Telemetry loss never throws into the app — but silence is how this
@@ -246,6 +261,8 @@ export class Analytics {
246
261
  private attribution: Attribution = { utm: {} }
247
262
  private cohort: Cohort = {}
248
263
  private started = false
264
+ /** The view pageview() last counted — path + location. */
265
+ private counted?: string
249
266
  /** Parsed error-plane DSN, or null when the plane is inert. */
250
267
  private dsn: Dsn | null
251
268
  /** Guards against an error thrown *inside* the error path re-entering it. */
@@ -284,6 +301,21 @@ export class Analytics {
284
301
  )
285
302
  }
286
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
+
287
319
  /** errorPlaneEnabled reports whether captured exceptions can actually reach the
288
320
  * error host. False means a DSN was never configured — the documented
289
321
  * fail-safe. Exposed so an app (or a test) can assert its wiring instead of
@@ -348,13 +380,22 @@ export class Analytics {
348
380
  this.enqueue('group', undefined, { groupId, properties: traits })
349
381
  }
350
382
 
351
- /** 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. */
352
386
  pageview(path?: string, properties?: Record<string, unknown>): void {
353
387
  // No `url` here: build() reads window.location.href for every event, in the
354
388
  // same tick, so this recomputed it to the identical value. Only `path` is
355
389
  // passed, because a route change fires before window.location has caught up
356
390
  // and the caller's value has to win.
357
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
358
399
  this.enqueue('pageview', PAGEVIEW, { path: p, properties })
359
400
  }
360
401
 
@@ -455,8 +496,8 @@ export class Analytics {
455
496
  * front door POST /v1/event, body { batch: [Event…] }. beacon=true selects the
456
497
  * unload-safe transport. Auth is orthogonal to the wire:
457
498
  *
458
- * • publishable key set → rides Authorization: Bearer pk_… (fetch) or
459
- * ?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.
460
501
  * • else a bearer JWT rides Authorization (fetch only — sendBeacon cannot
461
502
  * carry a header, so token apps fall back to keepalive fetch on unload).
462
503
  * • else a cookie app rides same-origin credentials (beacon carries the
@@ -482,8 +523,8 @@ export class Analytics {
482
523
  // leak introduced by an env var.
483
524
  const token = this.cfg.getToken?.() ?? undefined
484
525
  const key = token ? undefined : this.cfg.ingestKey?.trim() || undefined
485
- // Only a headerful bearer JWT blocks the beacon: sendBeacon cannot set an
486
- // 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.
487
528
  const useBeacon = beacon && !token
488
529
  const body = serializeBatch(batch)
489
530
  if (body === null) {
@@ -623,9 +664,52 @@ export class Analytics {
623
664
  }
624
665
  }
625
666
 
626
- /** 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. */
627
701
  export function createAnalytics(config: AnalyticsConfig): Analytics {
628
- 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
629
713
  }
630
714
 
631
715
  // Re-export the hydrate helpers so consumers can read persisted cohort/attribution
package/src/hz.test.ts CHANGED
@@ -298,13 +298,24 @@ describe('hz.js', () => {
298
298
  // keyed static surface therefore sent UNATTRIBUTED writes, which the door
299
299
  // refuses — silently, because nothing here reads the response.
300
300
 
301
- it('presents the publishable key as a bearer on fetch', () => {
301
+ // The fetch presents it on the SAME carrier the beacon does, and stays a CORS
302
+ // simple request doing so. This file's whole job is to run on a customer's own
303
+ // page, where every send is cross-origin: an Authorization header or a JSON
304
+ // content type would preflight the POST, and an origin that does not pass the
305
+ // preflight loses the batch that splice has already emptied. A simple request is
306
+ // sent from any origin — the browser asks permission to READ a cross-origin
307
+ // response, never to send one, and nothing here reads it.
308
+ it('presents the publishable key on the query, as a simple request', () => {
302
309
  const r = runSnippet({ attrs: { 'data-publishable-key': 'pk-abc123' } })
303
310
  r.api!.flush()
304
311
  const post = r.posts.at(-1)!
305
312
  expect(post.via).toBe('fetch')
306
- expect(post.headers.authorization).toBe('Bearer pk-abc123')
307
- expect(post.url).toBe('https://api.hanzo.ai/v1/event')
313
+ expect(post.url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
314
+ expect(post.headers.authorization).toBeUndefined()
315
+ expect(post.headers['content-type']).toBe('text/plain')
316
+ // Content-Type is safelisted only for three values; every other header is
317
+ // outside the safelist by construction, so the count is the check.
318
+ expect(Object.keys(post.headers)).toEqual(['content-type'])
308
319
  })
309
320
 
310
321
  it('still reads the retiring data-ingest-key, on a headerless beacon', () => {
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
+ })
@@ -129,7 +129,13 @@ describe('the unload beacon', () => {
129
129
  expect(fetches).toHaveLength(1)
130
130
  const batch = (JSON.parse(fetches[0].body) as { batch: { event?: string }[] }).batch
131
131
  expect(batch.map((e) => e.event)).toContain('checkout_started')
132
- expect(fetches[0].headers.Authorization).toBe('Bearer pk-abc123')
132
+
133
+ // The fallback carries the key the SAME way the beacon it replaces did, and
134
+ // stays a simple request doing so — a fallback that preflights is no fallback
135
+ // on the customer origin this client is meant to run on.
136
+ expect(fetches[0].url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
137
+ expect(fetches[0].headers.Authorization).toBeUndefined()
138
+ expect(fetches[0].headers['Content-Type']).toBe('text/plain')
133
139
 
134
140
  // The fallback is only a fallback if it survives the teardown that the beacon
135
141
  // was there for. Without this the batch is cancelled mid-flight and the
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.25'
4
+ export const VERSION = '0.3.27'