@hanzo/event 0.3.26 → 0.3.28
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-DO8tBUy7.d.cts → core-usULsfUB.d.cts} +16 -4
- package/dist/{core-DO8tBUy7.d.ts → core-usULsfUB.d.ts} +16 -4
- package/dist/index.cjs +43 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.mjs +43 -5
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +45 -10
- 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 +46 -11
- package/dist/react.mjs.map +1 -1
- package/hz.js +1 -1
- package/package.json +1 -1
- package/src/core.ts +100 -17
- package/src/react.tsx +7 -7
- package/src/stream.test.ts +195 -0
- package/src/version.ts +1 -1
package/hz.js
CHANGED
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
var LIB = 'hz.js'
|
|
58
|
-
var VERSION = '0.3.
|
|
58
|
+
var VERSION = '0.3.28'
|
|
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.
|
|
3
|
+
"version": "0.3.28",
|
|
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: '
|
|
32
|
-
// fetch,
|
|
33
|
-
//
|
|
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
|
|
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
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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
|
|
179
|
-
* is a CORS class, not a payload
|
|
180
|
-
*
|
|
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
|
|
467
|
-
*
|
|
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
|
|
494
|
-
//
|
|
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,59 @@ export class Analytics {
|
|
|
631
664
|
}
|
|
632
665
|
}
|
|
633
666
|
|
|
634
|
-
|
|
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
|
-
|
|
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)
|
|
710
|
+
const key = (config.host ?? DEFAULT_HOST) + '\0' + config.product
|
|
711
|
+
const clients = registry()
|
|
712
|
+
const existing = clients.get(key)
|
|
713
|
+
if (existing) {
|
|
714
|
+
existing.adopt(config)
|
|
715
|
+
return existing
|
|
716
|
+
}
|
|
717
|
+
const fresh = new Analytics(config)
|
|
718
|
+
clients.set(key, fresh)
|
|
719
|
+
return fresh
|
|
637
720
|
}
|
|
638
721
|
|
|
639
722
|
// 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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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,195 @@
|
|
|
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
|
+
// 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
|
+
|
|
124
|
+
it('keeps separate streams separate', () => {
|
|
125
|
+
expect(createAnalytics({ product: 'chat', host: HOST })).not.toBe(
|
|
126
|
+
createAnalytics({ product: 'site', host: HOST }),
|
|
127
|
+
)
|
|
128
|
+
expect(createAnalytics({ product: 'site', host: '' })).not.toBe(
|
|
129
|
+
createAnalytics({ product: 'site', host: HOST }),
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('leaves new Analytics outside the registry', () => {
|
|
134
|
+
const own = new Analytics({ product: 'site', host: HOST })
|
|
135
|
+
expect(createAnalytics({ product: 'site', host: HOST })).not.toBe(own)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// The package publishes two entries and the react one carries its own copy of
|
|
139
|
+
// this module, so on a page that imports both there are two module scopes. A
|
|
140
|
+
// registry held in module scope is per-copy and would not dedupe the only case
|
|
141
|
+
// that matters. Two distinct instances of this module, one client.
|
|
142
|
+
it('holds one registry across separate copies of this module', async () => {
|
|
143
|
+
const one = (await import('./core?copy=1')) as typeof import('./core')
|
|
144
|
+
const two = (await import('./core?copy=2')) as typeof import('./core')
|
|
145
|
+
expect(one).not.toBe(two)
|
|
146
|
+
expect(one.createAnalytics({ product: 'site', host: HOST })).toBe(
|
|
147
|
+
two.createAnalytics({ product: 'site', host: HOST, ingestKey: KEY }),
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('one pageview per view', () => {
|
|
153
|
+
it('counts a page once when two emitters both count the first view', () => {
|
|
154
|
+
library().pageview('/pricing') // the library's route hook, on mount
|
|
155
|
+
app().pageview() // the app provider's autoPageview, same frame
|
|
156
|
+
app().flush()
|
|
157
|
+
expect(tx.pageviews).toHaveLength(1)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('counts a page once even through one client', () => {
|
|
161
|
+
const a = library()
|
|
162
|
+
a.pageview('/pricing')
|
|
163
|
+
a.pageview('/pricing')
|
|
164
|
+
a.flush()
|
|
165
|
+
expect(tx.pageviews).toHaveLength(1)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('counts a route change', () => {
|
|
169
|
+
const a = library()
|
|
170
|
+
a.pageview('/pricing')
|
|
171
|
+
window.history.pushState(null, '', '/docs')
|
|
172
|
+
a.pageview('/docs')
|
|
173
|
+
a.flush()
|
|
174
|
+
expect(tx.pageviews.map((e) => e.path)).toEqual(['/pricing', '/docs'])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('counts a return to a page already seen', () => {
|
|
178
|
+
const a = library()
|
|
179
|
+
a.pageview('/pricing')
|
|
180
|
+
a.pageview('/docs')
|
|
181
|
+
a.pageview('/pricing')
|
|
182
|
+
a.flush()
|
|
183
|
+
expect(tx.pageviews).toHaveLength(3)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('counts a query change on one path', () => {
|
|
187
|
+
const a = library()
|
|
188
|
+
window.history.replaceState(null, '', '/search?q=a')
|
|
189
|
+
a.pageview('/search')
|
|
190
|
+
window.history.replaceState(null, '', '/search?q=b')
|
|
191
|
+
a.pageview('/search')
|
|
192
|
+
a.flush()
|
|
193
|
+
expect(tx.pageviews).toHaveLength(2)
|
|
194
|
+
})
|
|
195
|
+
})
|
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.
|
|
4
|
+
export const VERSION = '0.3.28'
|