@hanzo/event 0.3.44 → 0.3.49
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/README.md +11 -50
- package/dist/core.d.ts +6 -36
- package/dist/core.d.ts.map +1 -1
- package/dist/index.cjs +242 -414
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +241 -411
- package/dist/index.mjs.map +1 -1
- package/dist/items.d.ts +22 -0
- package/dist/items.d.ts.map +1 -0
- package/dist/org.d.ts.map +1 -1
- package/dist/react.cjs +181 -415
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +8 -0
- package/dist/react.d.ts +8 -0
- package/dist/react.d.ts.map +1 -1
- package/dist/react.mjs +183 -418
- package/dist/react.mjs.map +1 -1
- package/dist/tags.d.ts +14 -1
- package/dist/tags.d.ts.map +1 -1
- package/dist/throwable.d.ts +15 -0
- package/dist/throwable.d.ts.map +1 -0
- package/dist/types.d.ts +19 -74
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core.test.ts +28 -300
- package/src/core.ts +36 -185
- package/src/exception.ts +1 -1
- package/src/index.ts +4 -6
- package/src/items.test.ts +22 -0
- package/src/items.ts +44 -0
- package/src/org.ts +4 -0
- package/src/react.tsx +22 -0
- package/src/stream.test.ts +0 -7
- package/src/tags.test.ts +18 -0
- package/src/tags.ts +38 -5
- package/src/throwable.test.ts +72 -0
- package/src/throwable.ts +126 -0
- package/src/types.ts +15 -75
- package/src/uid.test.ts +1 -1
- package/dist/dsn.d.ts +0 -43
- package/dist/dsn.d.ts.map +0 -1
- package/dist/sentry.d.ts +0 -62
- package/dist/sentry.d.ts.map +0 -1
- package/src/dsn.test.ts +0 -75
- package/src/dsn.ts +0 -56
- package/src/sentry.test.ts +0 -312
- package/src/sentry.ts +0 -311
package/src/core.ts
CHANGED
|
@@ -7,21 +7,10 @@
|
|
|
7
7
|
// Cloud resolves the tenant server-side (validated session, or the signed
|
|
8
8
|
// publishable key) and stamps it; the client NEVER sends the org.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// These are NOT the same pipe and one does NOT feed the other. The event stream
|
|
16
|
-
// stores a `type:'error'` row in the cloud event warehouse (readable via
|
|
17
|
-
// GET /v1/errors) — that is product signal, not error tracking. There is no
|
|
18
|
-
// server-side fan-out from /v1/event into Sentry; without the envelope below,
|
|
19
|
-
// nothing ever reaches sentry.hanzo.ai. An earlier revision of this file claimed
|
|
20
|
-
// the one entry point was "lensed server-side into … error tracking (sentry)". It was
|
|
21
|
-
// wrong, and it silently cost the fleet all of its error telemetry.
|
|
22
|
-
//
|
|
23
|
-
// The error plane is inert (fail-safe) when no DSN is configured: nothing is
|
|
24
|
-
// sent, nothing throws, and the event stream is unaffected.
|
|
10
|
+
// Errors ride the same pipe: a captured exception is ONE event of type 'error'
|
|
11
|
+
// carrying {type, message, frames|stack, handled} plus release, environment,
|
|
12
|
+
// site, product and level. The server files it on the error plane and groups it
|
|
13
|
+
// into an issue. There is no second endpoint, DSN or envelope.
|
|
25
14
|
//
|
|
26
15
|
// Auth is orthogonal — the SAME body to the SAME endpoint, differing only in how the
|
|
27
16
|
// caller proves its tenant:
|
|
@@ -34,29 +23,17 @@
|
|
|
34
23
|
// is the carrier neither send needs a header for, which is what keeps both
|
|
35
24
|
// CORS-simple and therefore sendable from a customer's own origin.
|
|
36
25
|
//
|
|
37
|
-
// The wire is the canonical `Event` (== the cloud CaptureEvent)
|
|
38
|
-
// is what Cloud folds to event_type='error', which is how the event WAREHOUSE
|
|
39
|
-
// classifies the row (GET /v1/errors). That is the extent of it — the fold does
|
|
40
|
-
// not forward anything to Sentry. The error dashboard is fed only by the envelope
|
|
41
|
-
// in plane 2 above, and only when a DSN is set.
|
|
26
|
+
// The wire is the canonical `Event` (== the cloud CaptureEvent).
|
|
42
27
|
|
|
43
28
|
import {
|
|
44
29
|
parseAttribution,
|
|
45
30
|
hasAttribution,
|
|
46
31
|
deriveChannel,
|
|
47
32
|
} from './attribution'
|
|
48
|
-
import { dsnForProduct } from './dsn'
|
|
49
33
|
import { keyForPage } from './org'
|
|
50
|
-
import {
|
|
51
|
-
import { exceptionProperties } from './exception'
|
|
34
|
+
import { PAGEVIEW } from './events'
|
|
52
35
|
import { scrubText, withoutFragment } from './scrub'
|
|
53
|
-
import {
|
|
54
|
-
buildEnvelope,
|
|
55
|
-
buildSentryEvent,
|
|
56
|
-
normalizeError as normalizeThrowable,
|
|
57
|
-
parseDsn,
|
|
58
|
-
type ErrorIdentity,
|
|
59
|
-
} from './sentry'
|
|
36
|
+
import { framesFromStack, normalizeError as normalizeThrowable } from './throwable'
|
|
60
37
|
import {
|
|
61
38
|
adoptAnonId,
|
|
62
39
|
adoptSession,
|
|
@@ -74,7 +51,6 @@ import type {
|
|
|
74
51
|
Attribution,
|
|
75
52
|
CaptureErrorOptions,
|
|
76
53
|
Cohort,
|
|
77
|
-
Dsn,
|
|
78
54
|
EventKind,
|
|
79
55
|
Exception,
|
|
80
56
|
Transport,
|
|
@@ -86,7 +62,6 @@ export { VERSION }
|
|
|
86
62
|
|
|
87
63
|
const EVENT_PATH = '/v1/event' // the ONE canonical ingestion entry point
|
|
88
64
|
const DEFAULT_HOST = 'https://api.hanzo.ai' // the one edge; cookie apps pass host:''
|
|
89
|
-
const ENVELOPE_CONTENT_TYPE = 'application/x-sentry-envelope'
|
|
90
65
|
// The beacon body's type. text/plain is CORS-SAFELISTED, which is the whole
|
|
91
66
|
// property: a safelisted type makes the POST a SIMPLE request, and a simple
|
|
92
67
|
// request needs no preflight. An unloading document does not get a second round
|
|
@@ -95,22 +70,7 @@ const ENVELOPE_CONTENT_TYPE = 'application/x-sentry-envelope'
|
|
|
95
70
|
// CORS class and nothing else.
|
|
96
71
|
const BEACON_CONTENT_TYPE = 'text/plain'
|
|
97
72
|
|
|
98
|
-
/**
|
|
99
|
-
* gets the error plane by setting ONE build-time variable and nothing else.
|
|
100
|
-
* Next/Vite inline these at build; the access is guarded so it is safe in a bare
|
|
101
|
-
* browser and during SSR/prerender where `process` may not exist. */
|
|
102
|
-
function readEnvDsn(): string | undefined {
|
|
103
|
-
try {
|
|
104
|
-
if (typeof process !== 'undefined' && process.env) {
|
|
105
|
-
return process.env.NEXT_PUBLIC_HANZO_EVENT_DSN || process.env.HANZO_EVENT_DSN || undefined
|
|
106
|
-
}
|
|
107
|
-
} catch {
|
|
108
|
-
/* no process — browser without inlined env */
|
|
109
|
-
}
|
|
110
|
-
return undefined
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** readEnv reads an inlined build-time variable, guarded like readEnvDsn. */
|
|
73
|
+
/** readEnv reads an inlined build-time variable, guarded for SSR. */
|
|
114
74
|
function readEnv(name: string): string | undefined {
|
|
115
75
|
try {
|
|
116
76
|
if (typeof process !== 'undefined' && process.env) return process.env[name] || undefined
|
|
@@ -127,14 +87,15 @@ function appendQuery(url: string, key: string, value: string): string {
|
|
|
127
87
|
}
|
|
128
88
|
|
|
129
89
|
/** Normalize anything thrown (Error | string | unknown) into an Exception. */
|
|
130
|
-
/** normalizeError adapts the
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* plane rolled its own reader the stream still lost the report that the error
|
|
134
|
-
* plane had already survived. */
|
|
90
|
+
/** normalizeError adapts the hostile-input-safe normalizer (sentry.ts) to the
|
|
91
|
+
* stream's Exception shape: a thrown object may define `name`/`message`/`stack`
|
|
92
|
+
* as throwing getters. */
|
|
135
93
|
function normalizeError(err: unknown): Exception {
|
|
136
94
|
const n = normalizeThrowable(err)
|
|
137
|
-
|
|
95
|
+
const frames = framesFromStack(n.stack)
|
|
96
|
+
.slice(-50)
|
|
97
|
+
.map((f) => ({ function: f.function, file: f.filename, line: f.lineno, column: f.colno }))
|
|
98
|
+
return { type: n.name, message: n.message, stack: n.stack, frames }
|
|
138
99
|
}
|
|
139
100
|
|
|
140
101
|
// React Native defines a `window` but no `document` (and no `window.location`),
|
|
@@ -267,8 +228,6 @@ export class Analytics {
|
|
|
267
228
|
private started = false
|
|
268
229
|
/** The view pageview() last counted — path + location. */
|
|
269
230
|
private counted?: string
|
|
270
|
-
/** Parsed error-plane DSN, or null when the plane is inert. */
|
|
271
|
-
private dsn: Dsn | null
|
|
272
231
|
/** Guards against an error thrown *inside* the error path re-entering it. */
|
|
273
232
|
private reentrant = false
|
|
274
233
|
|
|
@@ -304,43 +263,14 @@ export class Analytics {
|
|
|
304
263
|
config.ingestKey ?? readEnv('NEXT_PUBLIC_PUBLISHABLE_KEY') ?? keyForPage(),
|
|
305
264
|
}
|
|
306
265
|
this.transport = config.transport ?? new DefaultTransport()
|
|
307
|
-
// Error plane, most specific source first: an explicit DSN wins, then the
|
|
308
|
-
// inlined build-time env (a per-deploy override), then the product registry —
|
|
309
|
-
// whose DSN carries the SAME resolved key (not a baked one), so declaring
|
|
310
|
-
// `product` + providing the key is enough to report errors. Malformed or
|
|
311
|
-
// absent => null => inert, never throwing into the host app.
|
|
312
|
-
this.dsn = parseDsn(
|
|
313
|
-
config.dsn ?? readEnvDsn() ?? dsnForProduct(this.cfg.product, this.cfg.ingestKey)
|
|
314
|
-
)
|
|
315
266
|
}
|
|
316
267
|
|
|
317
268
|
/** adopt gives this client a credential it does not have. The key belongs to
|
|
318
269
|
* the stream, not to whichever caller happened to ask for the handle first,
|
|
319
|
-
* so a later caller carrying one hands it over.
|
|
320
|
-
* the key, so it comes up here too when it was inert for want of one.
|
|
321
|
-
* Present fields are never overwritten: the first caller's key stays. */
|
|
270
|
+
* so a later caller carrying one hands it over. Present fields are never overwritten: the first caller's key stays. */
|
|
322
271
|
adopt(config: AnalyticsConfig): void {
|
|
323
272
|
if (config.ingestKey && !this.cfg.ingestKey) this.cfg.ingestKey = config.ingestKey
|
|
324
273
|
if (config.getToken && !this.cfg.getToken) this.cfg.getToken = config.getToken
|
|
325
|
-
if (!this.dsn) {
|
|
326
|
-
this.dsn = parseDsn(
|
|
327
|
-
config.dsn ?? readEnvDsn() ?? dsnForProduct(this.cfg.product, this.cfg.ingestKey),
|
|
328
|
-
)
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/** errorPlaneEnabled reports whether captured exceptions can actually reach the
|
|
333
|
-
* error host. False means a DSN was never configured — the documented
|
|
334
|
-
* fail-safe. Exposed so an app (or a test) can assert its wiring instead of
|
|
335
|
-
* discovering months later that nothing was ever reported. */
|
|
336
|
-
get errorPlaneEnabled(): boolean {
|
|
337
|
-
return this.dsn !== null
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/** errorIngestUrl is the fully-derived envelope endpoint, or undefined when the
|
|
341
|
-
* plane is inert. Diagnostics only. */
|
|
342
|
-
get errorIngestUrl(): string | undefined {
|
|
343
|
-
return this.dsn?.ingestUrl
|
|
344
274
|
}
|
|
345
275
|
|
|
346
276
|
/** init is idempotent and browser-only for its side effects: capture first-touch
|
|
@@ -383,10 +313,7 @@ export class Analytics {
|
|
|
383
313
|
window.addEventListener('visibilitychange', flushHidden)
|
|
384
314
|
window.addEventListener('pagehide', () => this.flush(true))
|
|
385
315
|
|
|
386
|
-
//
|
|
387
|
-
// rejected promises are reported on BOTH planes: a Sentry envelope to the DSN
|
|
388
|
-
// host (what reaches the error dashboard — requires a DSN) and a type:'error'
|
|
389
|
-
// event on the stream (product signal in the warehouse).
|
|
316
|
+
// Unhandled errors and rejected promises are reported as type:'error' events.
|
|
390
317
|
if (this.cfg.captureErrors) {
|
|
391
318
|
window.addEventListener('error', (e: ErrorEvent) => {
|
|
392
319
|
this.captureError(e.error ?? e.message, { handled: false })
|
|
@@ -462,71 +389,30 @@ export class Analytics {
|
|
|
462
389
|
}
|
|
463
390
|
|
|
464
391
|
/** captureError reports a caught error, an unhandled rejection, a React render
|
|
465
|
-
* error, or a manual report
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
* - the EVENT STREAM — a `type:'error'` row in the cloud event warehouse, so
|
|
471
|
-
* an error stays correlated with the session's pageviews for product
|
|
472
|
-
* analysis (readable via GET /v1/errors).
|
|
473
|
-
*
|
|
474
|
-
* Both carry the SAME session and subject id, so an error and the pageview
|
|
475
|
-
* before it join up. Never throws back into the app; errors are higher-signal
|
|
476
|
-
* than pageviews, so both planes flush promptly (a crash may unload the page
|
|
477
|
-
* moments later). */
|
|
392
|
+
* error, or a manual report as ONE event of type 'error' on the one stream.
|
|
393
|
+
* It carries the exception (type, message, frames, stack, handled) and the
|
|
394
|
+
* release, environment, site, product and level that group and scope it, and
|
|
395
|
+
* flushes promptly (a crash may unload the page moments later). Never throws
|
|
396
|
+
* back into the app. */
|
|
478
397
|
captureError(err: unknown, context?: CaptureErrorOptions): void {
|
|
479
398
|
// A failure inside the error path must not recurse through the global handlers.
|
|
480
399
|
if (this.reentrant) return
|
|
481
400
|
this.reentrant = true
|
|
482
401
|
try {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
const handled = context?.handled ?? true
|
|
498
|
-
const ex = normalizeError(err)
|
|
499
|
-
ex.handled = handled
|
|
500
|
-
// NAME: the reserved '$exception', never the message. The message was the
|
|
501
|
-
// name until 0.3.20, which put every distinct error string — one per failed
|
|
502
|
-
// chunk id, per ResizeObserver notification — permanently into the event
|
|
503
|
-
// taxonomy, and left Error Tracking (which reads this exact name) at zero.
|
|
504
|
-
//
|
|
505
|
-
// TYPE 'event', not 'error'. `type` alone picks the storage plane: 'error'
|
|
506
|
-
// routes to the error plane, which the product-event projection does not
|
|
507
|
-
// read, so an exception filed there is invisible to Error Tracking however
|
|
508
|
-
// well-formed it is. The full error record still reaches the error plane as
|
|
509
|
-
// a Sentry envelope above — this row is the product-analytics breadcrumb,
|
|
510
|
-
// which is what keeps a crash correlated with the session's pageviews.
|
|
511
|
-
//
|
|
512
|
-
// `error` is still carried: the server folds it into properties.$exception
|
|
513
|
-
// (scrubbing message and stack on the way), which is the shape existing
|
|
514
|
-
// readers bind to.
|
|
515
|
-
this.enqueue('event', EXCEPTION, {
|
|
516
|
-
error: ex,
|
|
517
|
-
properties: {
|
|
518
|
-
...context?.properties,
|
|
519
|
-
...exceptionProperties(err, {
|
|
520
|
-
handled,
|
|
521
|
-
id: uuidv7(),
|
|
522
|
-
level: context?.level,
|
|
523
|
-
}),
|
|
524
|
-
},
|
|
525
|
-
})
|
|
526
|
-
this.flush()
|
|
527
|
-
} catch {
|
|
528
|
-
/* nor the reverse */
|
|
529
|
-
}
|
|
402
|
+
const handled = context?.handled ?? true
|
|
403
|
+
const ex = normalizeError(err)
|
|
404
|
+
ex.handled = handled
|
|
405
|
+
this.enqueue('error', undefined, {
|
|
406
|
+
error: ex,
|
|
407
|
+
level: context?.level ?? (handled ? 'error' : 'fatal'),
|
|
408
|
+
release: this.cfg.release ?? readEnv('NEXT_PUBLIC_HANZO_RELEASE'),
|
|
409
|
+
environment: this.cfg.environment ?? readEnv('NODE_ENV'),
|
|
410
|
+
site: isBrowser() ? window.location.hostname : undefined,
|
|
411
|
+
properties: context?.properties,
|
|
412
|
+
})
|
|
413
|
+
this.flush()
|
|
414
|
+
} catch {
|
|
415
|
+
/* the error path never takes the app down */
|
|
530
416
|
} finally {
|
|
531
417
|
this.reentrant = false
|
|
532
418
|
}
|
|
@@ -588,41 +474,6 @@ export class Analytics {
|
|
|
588
474
|
|
|
589
475
|
// ── internals ────────────────────────────────────────────────────────────
|
|
590
476
|
|
|
591
|
-
/** sendError frames one exception as a Sentry envelope and posts it to the DSN's
|
|
592
|
-
* ingest URL. The DSN's own key rides ?sentry_key= (the credential channel the
|
|
593
|
-
* server trusts, and the only one a headerless beacon can carry), so NO bearer
|
|
594
|
-
* or publishable key is attached here — the two planes authenticate
|
|
595
|
-
* independently. Errors are sent one envelope per event, immediately: batching
|
|
596
|
-
* a crash report is how you lose it. */
|
|
597
|
-
private sendError(err: unknown, options?: CaptureErrorOptions): void {
|
|
598
|
-
if (!this.cfg.enabled || !this.dsn) return
|
|
599
|
-
const event = buildSentryEvent({
|
|
600
|
-
error: err,
|
|
601
|
-
options,
|
|
602
|
-
identity: this.errorIdentity(),
|
|
603
|
-
capturePII: this.cfg.capturePII ?? false,
|
|
604
|
-
})
|
|
605
|
-
const body = buildEnvelope(event, this.dsn)
|
|
606
|
-
if (this.cfg.debug) console.debug('[event] error →', this.dsn.ingestUrl, event.event_id)
|
|
607
|
-
this.transport.send(this.dsn.ingestUrl, body, {
|
|
608
|
-
beacon: false,
|
|
609
|
-
contentType: ENVELOPE_CONTENT_TYPE,
|
|
610
|
-
debug: this.cfg.debug,
|
|
611
|
-
})
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
/** errorIdentity is the SAME identity the event stream stamps — the OIDC subject
|
|
615
|
-
* once identify() has run, else the anon id. Never email/PII. */
|
|
616
|
-
private errorIdentity(): ErrorIdentity {
|
|
617
|
-
return {
|
|
618
|
-
userId: this.personId ?? anonId(),
|
|
619
|
-
sessionId: sessionId(),
|
|
620
|
-
product: this.cfg.product,
|
|
621
|
-
release: this.cfg.release ?? readEnv('NEXT_PUBLIC_HANZO_RELEASE'),
|
|
622
|
-
environment: this.cfg.environment ?? readEnv('NODE_ENV'),
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
477
|
private enqueue(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): void {
|
|
627
478
|
if (!this.cfg.enabled) return
|
|
628
479
|
if (!this.started) this.init()
|
package/src/exception.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// throw site. framesFromStack (sentry.ts) already returns that order, which is why
|
|
30
30
|
// this module reuses it rather than re-parsing.
|
|
31
31
|
|
|
32
|
-
import { framesFromStack, normalizeError } from './
|
|
32
|
+
import { framesFromStack, normalizeError } from './throwable'
|
|
33
33
|
import type {
|
|
34
34
|
ExceptionEntry,
|
|
35
35
|
ExceptionFrame,
|
package/src/index.ts
CHANGED
|
@@ -15,12 +15,10 @@ export {
|
|
|
15
15
|
getCohort,
|
|
16
16
|
getFirstTouch,
|
|
17
17
|
} from './core'
|
|
18
|
-
export {
|
|
18
|
+
export { framesFromStack } from './throwable'
|
|
19
19
|
export { uuidv7, uuidv7Time } from './uid'
|
|
20
20
|
export { linkUrl, readLink, stripLink, LINK_PARAMS } from './link'
|
|
21
21
|
export type { LinkState } from './link'
|
|
22
|
-
export { PRODUCT_PROJECT, dsnForProduct } from './dsn'
|
|
23
|
-
export type { ErrorIdentity } from './sentry'
|
|
24
22
|
export { scrubText, redactSecrets, scrubPII } from './scrub'
|
|
25
23
|
export { EVENTS, EXCEPTION, PAGEVIEW } from './events'
|
|
26
24
|
export { exceptionEntry, exceptionProperties, fingerprint, digest } from './exception'
|
|
@@ -31,8 +29,10 @@ export {
|
|
|
31
29
|
} from './consent'
|
|
32
30
|
export type { Choice, Region } from './consent'
|
|
33
31
|
export { CLICK_IDS, capture as captureClick, touch } from './touch'
|
|
34
|
-
export { start as startTags, track, mirror, reach, visit } from './tags'
|
|
32
|
+
export { start as startTags, track, mirror, reach, visit, SITE_KEY } from './tags'
|
|
35
33
|
export type { BrowserTag, TagOptions } from './tags'
|
|
34
|
+
export { commerceItem, worth } from './items'
|
|
35
|
+
export type { CommerceItem, PlanLine } from './items'
|
|
36
36
|
export { GOALS, COHORTS } from './goals'
|
|
37
37
|
export type { GoalDef, CohortDef } from './goals'
|
|
38
38
|
export { FUNNELS, PRODUCTS, eventsOf } from './funnels'
|
|
@@ -49,13 +49,11 @@ export type {
|
|
|
49
49
|
Attribution,
|
|
50
50
|
CaptureErrorOptions,
|
|
51
51
|
Cohort,
|
|
52
|
-
Dsn,
|
|
53
52
|
EventKind,
|
|
54
53
|
Exception,
|
|
55
54
|
ExceptionEntry,
|
|
56
55
|
ExceptionFrame,
|
|
57
56
|
ExceptionProperties,
|
|
58
|
-
SentryEvent,
|
|
59
57
|
SentryFrame,
|
|
60
58
|
SentryLevel,
|
|
61
59
|
Transport,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { commerceItem, worth } from './items'
|
|
3
|
+
|
|
4
|
+
describe('commerceItem', () => {
|
|
5
|
+
it('names an individual plan by its id, term and price', () => {
|
|
6
|
+
expect(commerceItem({ id: 'dev', name: 'Dev', interval: 'month', price: 19 })).toEqual({
|
|
7
|
+
item_id: 'dev', item_name: 'Dev', item_category: 'individual', item_variant: 'monthly', price: 19, quantity: 1,
|
|
8
|
+
})
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('names a team plan by its seats, and an annual term however commerce spells it', () => {
|
|
12
|
+
for (const interval of ['year', 'yearly', 'annual', 'Year']) {
|
|
13
|
+
const i = commerceItem({ id: 'team_standard', team: true, interval, price: 24, quantity: 3 })
|
|
14
|
+
expect(i).toMatchObject({ item_category: 'team', item_variant: 'annual', quantity: 3 })
|
|
15
|
+
expect(worth(i)).toBe(72)
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('a plan with no price is free, not unknown', () => {
|
|
20
|
+
expect(commerceItem({ id: 'free', interval: 'month', price: null }).price).toBe(0)
|
|
21
|
+
})
|
|
22
|
+
})
|
package/src/items.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// A plan as every ecommerce event names it: GA4's item shape, which the tag
|
|
2
|
+
// manager hands Meta, TikTok and X, and which cloud's forwarder lifts from the
|
|
3
|
+
// events the server states. One function, so a plan carries the same line from
|
|
4
|
+
// the pricing page to the checkout to the order.
|
|
5
|
+
//
|
|
6
|
+
// `item_id` is the plan's id as the catalog spells it (free, dev, max, and the
|
|
7
|
+
// team plans), `item_category` says who it is for, `item_variant` the term,
|
|
8
|
+
// `price` the unit price for that term in US dollars and `quantity` the seats
|
|
9
|
+
// (1 for an individual plan).
|
|
10
|
+
|
|
11
|
+
export interface CommerceItem {
|
|
12
|
+
item_id: string
|
|
13
|
+
item_name?: string
|
|
14
|
+
item_category: 'individual' | 'team'
|
|
15
|
+
item_variant: 'monthly' | 'annual'
|
|
16
|
+
price: number
|
|
17
|
+
quantity: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PlanLine {
|
|
21
|
+
id: string
|
|
22
|
+
name?: string
|
|
23
|
+
/** A team plan is sold per seat. */
|
|
24
|
+
team?: boolean
|
|
25
|
+
/** The term, as commerce (`month`, `year`) or the funnel (`monthly`, `annual`) says it. */
|
|
26
|
+
interval: string
|
|
27
|
+
price: number | null
|
|
28
|
+
quantity?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function commerceItem(p: PlanLine): CommerceItem {
|
|
32
|
+
const annual = /^(year|yearly|annual)$/i.test(p.interval)
|
|
33
|
+
return {
|
|
34
|
+
item_id: p.id,
|
|
35
|
+
...(p.name ? { item_name: p.name } : {}),
|
|
36
|
+
item_category: p.team ? 'team' : 'individual',
|
|
37
|
+
item_variant: annual ? 'annual' : 'monthly',
|
|
38
|
+
price: p.price ?? 0,
|
|
39
|
+
quantity: p.quantity ?? 1,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What the whole order is worth: the unit price times the seats. */
|
|
44
|
+
export const worth = (i: CommerceItem): number => i.price * i.quantity
|
package/src/org.ts
CHANGED
|
@@ -51,11 +51,15 @@ export const ORG_DOMAIN: Readonly<Record<string, readonly string[]>> = Object.fr
|
|
|
51
51
|
// separate fact, which is the whole reason this is a table.
|
|
52
52
|
hanzo: Object.freeze([
|
|
53
53
|
'hanzo.ai',
|
|
54
|
+
'hanzo.agency',
|
|
54
55
|
'hanzo.app',
|
|
55
56
|
'hanzo.bot',
|
|
56
57
|
'hanzo.chat',
|
|
57
58
|
'hanzo.codes',
|
|
58
59
|
'hanzo.id',
|
|
60
|
+
'hanzo.industries',
|
|
61
|
+
'hanzo.market',
|
|
62
|
+
'hanzo.network',
|
|
59
63
|
'hanzo.sh',
|
|
60
64
|
'hanzo.team',
|
|
61
65
|
'hanzo.ventures',
|
package/src/react.tsx
CHANGED
|
@@ -16,11 +16,13 @@ import {
|
|
|
16
16
|
useContext,
|
|
17
17
|
useEffect,
|
|
18
18
|
useRef,
|
|
19
|
+
useSyncExternalStore,
|
|
19
20
|
type ErrorInfo,
|
|
20
21
|
type ReactNode,
|
|
21
22
|
} from 'react'
|
|
22
23
|
import { Analytics, createAnalytics } from './core'
|
|
23
24
|
import type { AnalyticsConfig } from './types'
|
|
25
|
+
import { CONSENT_EVENT, read, render, type Choice } from './consent'
|
|
24
26
|
|
|
25
27
|
const Ctx = createContext<Analytics | null>(null)
|
|
26
28
|
|
|
@@ -120,3 +122,23 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|
|
120
122
|
return fallback ?? null
|
|
121
123
|
}
|
|
122
124
|
}
|
|
125
|
+
|
|
126
|
+
const listen = (on: () => void) => {
|
|
127
|
+
window.addEventListener(CONSENT_EVENT, on)
|
|
128
|
+
return () => window.removeEventListener(CONSENT_EVENT, on)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The visitor's consent, live: what a region presumes until they choose, then
|
|
133
|
+
* what they chose, re-rendering the moment they do. `enabled: choice.analytics`
|
|
134
|
+
* on the provider's config is how a stream that started silent for an EU visitor
|
|
135
|
+
* starts on Accept, with no reload.
|
|
136
|
+
*/
|
|
137
|
+
export function useConsent(): Choice {
|
|
138
|
+
const kept = useSyncExternalStore(listen, () => render(read()), () => '')
|
|
139
|
+
return {
|
|
140
|
+
analytics: kept.includes('analytics'),
|
|
141
|
+
marketing: kept.includes('marketing'),
|
|
142
|
+
ads: kept.includes('ads'),
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/stream.test.ts
CHANGED
|
@@ -81,13 +81,6 @@ describe('one stream, one client', () => {
|
|
|
81
81
|
expect(tx.sent[0].token).toBe(KEY)
|
|
82
82
|
})
|
|
83
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
84
|
it('keeps the first credential rather than overwriting it', () => {
|
|
92
85
|
createAnalytics({ product: 'site', host: HOST, ingestKey: KEY, transport: tx })
|
|
93
86
|
createAnalytics({ product: 'site', host: HOST, ingestKey: 'pk-second' }).capture('x')
|
package/src/tags.test.ts
CHANGED
|
@@ -185,3 +185,21 @@ describe('one event, every tag', () => {
|
|
|
185
185
|
expect(sent.items[0].price).toBe(0)
|
|
186
186
|
})
|
|
187
187
|
})
|
|
188
|
+
|
|
189
|
+
describe('a paid order the server also states', () => {
|
|
190
|
+
it('fires the pixels under the order id and does not touch our stream', async () => {
|
|
191
|
+
browser('America/New_York')
|
|
192
|
+
const { startTags, track } = await import('./index')
|
|
193
|
+
startTags({ key: 'pk-x' })
|
|
194
|
+
await tick()
|
|
195
|
+
const w = g.window as unknown as Record<string, unknown>
|
|
196
|
+
const fbq: unknown[][] = []
|
|
197
|
+
const captured: unknown[] = []
|
|
198
|
+
w.gtag = () => undefined
|
|
199
|
+
w.fbq = (...a: unknown[]) => fbq.push(a)
|
|
200
|
+
track({ capture: (...a: unknown[]) => captured.push(a) } as never, 'order_completed', { event_id: 'sub_1', order_id: 'sub_1', value: 19 }, 'tags')
|
|
201
|
+
await tick()
|
|
202
|
+
expect(captured).toEqual([])
|
|
203
|
+
expect(fbq.map((a) => (a[3] as { eventID: string }).eventID)).toEqual(['sub_1', 'sub_1'])
|
|
204
|
+
})
|
|
205
|
+
})
|
package/src/tags.ts
CHANGED
|
@@ -54,6 +54,24 @@ export interface TagOptions {
|
|
|
54
54
|
domains?: string[]
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The publishable key of each site's project, which is how cloud finds the site's
|
|
59
|
+
* tag set. A `pk-` is public by design (it ships in every page); it names a site,
|
|
60
|
+
* never a platform id. A host absent here has no tag config unless `start` is
|
|
61
|
+
* given a key.
|
|
62
|
+
*/
|
|
63
|
+
export const SITE_KEY: Readonly<Record<string, string>> = Object.freeze({
|
|
64
|
+
'hanzo.ai': 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
|
|
65
|
+
'www.hanzo.ai': 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
|
|
66
|
+
'hanzo.app': 'pk-wlnXN2a9_vmCm60yTFtQ629Q8TyuaxBNZbY1RWT72gQ',
|
|
67
|
+
'hanzo.team': 'pk-NCzD2FiHpZv8KUpkCX4olT1LJOJsMxBC_Z8NkiQsOFQ',
|
|
68
|
+
'pay.hanzo.ai': 'pk-eX6kv7JZNoiYn1WkeJH3tT_8OvkVYScmCXnLMwxTKf8',
|
|
69
|
+
'platform.hanzo.ai': 'pk-My1RpZLEUnTj8vAdPbWKYdDUuhxZJ4dVjHcwjZN4rZ8',
|
|
70
|
+
'docs.hanzo.ai': 'pk-jukhtjMT2ymoeBDAeFjINQWlBv-v9sNn1TPztiCrrwk',
|
|
71
|
+
'hanzo.bot': 'pk-W5d7Mn7ZukT7igyscIy6Pqe8JpA0Ge604Yn4xNR4JCU',
|
|
72
|
+
'cloud.hanzo.ai': 'pk-RAfEGHPoNdCEU9fnA_cPd_Xo9Tci44rlYQV9xuJ1Ob0',
|
|
73
|
+
})
|
|
74
|
+
|
|
57
75
|
type Call = (...args: unknown[]) => void
|
|
58
76
|
type Page = {
|
|
59
77
|
dataLayer?: unknown[]
|
|
@@ -281,8 +299,10 @@ export function start(o: TagOptions = {}): () => void {
|
|
|
281
299
|
configured = true
|
|
282
300
|
const base = (o.base ?? 'https://api.hanzo.ai').replace(/\/$/, '')
|
|
283
301
|
const q = new URLSearchParams()
|
|
284
|
-
|
|
285
|
-
|
|
302
|
+
const host = o.host ?? window.location.hostname
|
|
303
|
+
const key = o.key ?? SITE_KEY[host]
|
|
304
|
+
if (key) q.set('key', key)
|
|
305
|
+
q.set('host', host)
|
|
286
306
|
const answer = (list: BrowserTag[]) => {
|
|
287
307
|
tags = list
|
|
288
308
|
configAnswered = true
|
|
@@ -420,17 +440,30 @@ export function mirror(name: string, params: Record<string, unknown>, id: string
|
|
|
420
440
|
* forwards on), and each browser tag that is running hears it too. A moment
|
|
421
441
|
* that arrives while gtag.js is still on its way waits for it, so GA4 sees it
|
|
422
442
|
* with a session; a page that is leaving sends what it holds as things stand.
|
|
443
|
+
*
|
|
444
|
+
* A paid order is stated by the SERVER on our stream (and forwarded server-side);
|
|
445
|
+
* its browser copy is `track(stream, 'order_completed', {event_id: order, order_id:
|
|
446
|
+
* order, …}, 'tags')`: the same id, the pixels only, so nothing counts twice.
|
|
423
447
|
*/
|
|
424
|
-
export function track(
|
|
448
|
+
export function track(
|
|
449
|
+
stream: Analytics | undefined,
|
|
450
|
+
name: string,
|
|
451
|
+
params: Record<string, unknown> = {},
|
|
452
|
+
/** `'tags'` fires the browser pixels only: the server states this moment on our stream itself. */
|
|
453
|
+
only?: 'tags',
|
|
454
|
+
): void {
|
|
425
455
|
if (typeof window === 'undefined') return
|
|
426
456
|
if (!leaving && !settled()) {
|
|
427
|
-
held.push(() => track(stream, name, params))
|
|
457
|
+
held.push(() => track(stream, name, params, only))
|
|
428
458
|
return
|
|
429
459
|
}
|
|
430
460
|
const c = read()
|
|
431
|
-
|
|
461
|
+
// A moment the server also states (a paid order) brings its own id, the order's,
|
|
462
|
+
// so each platform sees the browser's copy and the server's as one.
|
|
463
|
+
const event_id = typeof params.event_id === 'string' && params.event_id ? params.event_id : crypto.randomUUID()
|
|
432
464
|
const to = reach()
|
|
433
465
|
mirror(name, params, event_id, to)
|
|
466
|
+
if (only === 'tags') return
|
|
434
467
|
stream?.capture(name, {
|
|
435
468
|
...params,
|
|
436
469
|
...touch(c),
|