@hanzo/event 0.2.0 → 0.3.0

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 CHANGED
@@ -1,18 +1,30 @@
1
1
  # @hanzo/event
2
2
 
3
- Tiny, batched product-analytics capture client for Hanzo surfaces. Emits
4
- `pageview` / `event` / `identify` / `group` to **Hanzo Cloud** — never to a
5
- third-party or to `insights-capture` directly. Cloud (`/v1/analytics` +
6
- `/v1/tracker`) is the one front door and fans out to the insights/datastore
7
- warehouse.
3
+ The ONE telemetry client for Hanzo surfaces. Emits every kind of event —
4
+ `pageview` / `event` / `identify` / `group` **and errors** — to **Hanzo Cloud**,
5
+ never to a third-party. There is exactly one front door:
6
+
7
+ ```
8
+ POST {host}/v1/event body: { batch: [Event, …] } -> { accepted, dropped }
9
+ ```
10
+
11
+ Cloud resolves the tenant server-side and fans the one stream out into three
12
+ lenses: **product analytics** (insights.hanzo.ai), **web analytics**
13
+ (analytics.hanzo.ai), and **error tracking** (sentry.hanzo.ai). Errors are just
14
+ events (`type:'error'`) on the same pipe — one client, one door.
8
15
 
9
16
  - **Batched** with a size + interval flush, and **beacon-on-unload**
10
- (`sendBeacon` for cookie apps, `fetch(keepalive)` for token apps).
17
+ (`sendBeacon` for cookie/publishable-key apps, `fetch(keepalive)` for token apps).
18
+ - **Auto error capture** (subsumes `@sentry`): `window.onerror`,
19
+ `unhandledrejection`, and a React `ErrorBoundary` become `type:'error'` events;
20
+ Cloud stamps `event_type='error'` → the sentry lens. Opt out with
21
+ `captureErrors: false`.
11
22
  - **First-touch attribution**: UTM + referrer + `refCode` are parsed once and
12
23
  persisted, then attached to every event.
13
24
  - **Cohorts**: `signupWeek`, `channel`, `refCode` ride each event.
14
25
  - **Tenant-safe**: the client NEVER sends an org/tenant — Cloud stamps it from
15
- the validated session.
26
+ the validated session or the signed publishable key.
27
+ - **Fail-soft**: telemetry loss is swallowed; the client never throws into the app.
16
28
  - **SSR-safe**: importing on the server is a no-op; it only acts in the browser.
17
29
 
18
30
  ## Core (framework-agnostic)
@@ -58,6 +70,41 @@ function UpgradeButton() {
58
70
  }
59
71
  ```
60
72
 
73
+ ## Errors (the @sentry replacement)
74
+
75
+ Unhandled errors and promise rejections are captured automatically. React render
76
+ errors never reach `window.onerror`, so wrap your tree in the `ErrorBoundary` to
77
+ catch those too. Report caught errors yourself with `captureError`.
78
+
79
+ ```tsx
80
+ import { ErrorBoundary } from '@hanzo/event/react'
81
+
82
+ <AnalyticsProvider config={{ product: 'console' }}>
83
+ <ErrorBoundary fallback={(err, reset) => <Crash error={err} onReset={reset} />}>
84
+ <App />
85
+ </ErrorBoundary>
86
+ </AnalyticsProvider>
87
+ ```
88
+
89
+ ```ts
90
+ try { risky() } catch (err) { analytics.captureError(err, { properties: { where: 'checkout' } }) }
91
+ ```
92
+
93
+ Each becomes a `type:'error'` event carrying the exception; Cloud folds it into
94
+ `properties.$exception`, stamps `event_type='error'`, and it surfaces in the
95
+ error-tracking lens (`GET /v1/errors` → sentry.hanzo.ai).
96
+
97
+ ## Publishable key (public pages, no bearer)
98
+
99
+ Marketing/public pages have no session. Mint a write-only publishable key
100
+ (`POST /v1/ingest/keys`) and pass it as `ingestKey`; it rides `Authorization`
101
+ on fetch and `?ingest_key` on an unload beacon, so all three lenses light up
102
+ anonymously. It is safe to ship in a bundle (write-only, cannot read).
103
+
104
+ ```ts
105
+ createAnalytics({ product: 'site', host: 'https://api.hanzo.ai', ingestKey: 'pk_live_…' })
106
+ ```
107
+
61
108
  ## Goals & cohorts
62
109
 
63
110
  `GOALS` and `COHORTS` (see `goals.ts`) are the shared, machine-readable insights
@@ -1,8 +1,10 @@
1
1
  /** The event kinds — the closed set the server understands. An error is just
2
- * another event on the one stream (lensed to the error-tracking view). */
2
+ * another event on the one stream (Cloud stamps type:'error' event_type='error',
3
+ * the key the error-tracking lens filters on). */
3
4
  type EventKind = 'pageview' | 'event' | 'identify' | 'group' | 'error';
4
- /** A captured exception. Carried on a `type:'error'` event; the server lenses it
5
- * into the error-tracking view (sentry.hanzo.ai). */
5
+ /** A captured exception. Carried on a `type:'error'` event's top-level `error`
6
+ * field; Cloud folds it into properties.$exception and lenses the event into the
7
+ * error-tracking view (sentry.hanzo.ai). */
6
8
  interface Exception {
7
9
  /** Constructor/class name, e.g. "TypeError". */
8
10
  type?: string;
@@ -35,8 +37,10 @@ interface Cohort {
35
37
  channel?: string;
36
38
  refCode?: string;
37
39
  }
38
- /** One event as sent on the wire. tenant/org is NEVER set here the server
39
- * stamps it from the validated session. */
40
+ /** One event as sent on the wire the canonical Hanzo Cloud event. Maps 1:1 to
41
+ * the cloud `CaptureEvent` (camelCase JSON keys); a batch of these is POSTed to
42
+ * the ONE front door `/v1/event` as `{ batch: [WireEvent, …] }`. tenant/org is
43
+ * NEVER a field here — the server stamps it from the validated session/key. */
40
44
  interface WireEvent {
41
45
  messageId: string;
42
46
  type: EventKind;
@@ -59,29 +63,43 @@ interface WireEvent {
59
63
  quantity?: number;
60
64
  revenue?: number;
61
65
  currency?: string;
62
- /** Set on `type:'error'` events — the captured exception. */
66
+ /** Set on `type:'error'` events — the captured exception. Cloud lifts it into
67
+ * properties.$exception (foldException) for the error-tracking lens. */
63
68
  error?: Exception;
64
69
  properties?: Record<string, unknown>;
65
70
  library?: string;
66
71
  libraryVersion?: string;
67
72
  }
68
- /** Injectable transports — overridden in tests; default in core.ts uses fetch. */
73
+ /** Injectable transports — overridden in tests; the default in core.ts uses fetch
74
+ * (keepalive) and sendBeacon. A bearer JWT or a publishable pk_ key rides
75
+ * Authorization on fetch; on a headerless beacon a publishable key rides the
76
+ * ?ingest_key query. */
69
77
  interface Transport {
70
78
  /** Durable POST usable during page unload (fetch keepalive / sendBeacon). */
71
79
  send(url: string, body: string, opts: {
72
80
  beacon: boolean;
73
81
  token?: string;
82
+ ingestKey?: string;
74
83
  }): void;
75
84
  }
76
85
  interface AnalyticsConfig {
77
- /** Cloud base URL. Same-origin ("") for cookie-auth apps (console/admin);
78
- * e.g. "https://api.hanzo.ai" for bearer apps (app/site). */
86
+ /** Cloud base URL. Defaults to "https://api.hanzo.ai" (the one edge). Set to
87
+ * same-origin ("") for cookie-auth apps served behind the same edge
88
+ * (console/admin/chat), so the browser rides the session cookie. */
79
89
  host?: string;
80
90
  /** Emitting surface: console | chat | app | site | admin. */
81
91
  product: string;
82
92
  /** Bearer token provider for token-auth apps. Omit for cookie/session apps
83
93
  * (the client then relies on same-origin credentials). */
84
94
  getToken?: () => string | undefined | null;
95
+ /** Publishable ingest key (pk_…). When set, the client authenticates to the ONE
96
+ * front door `/v1/event` with this key instead of a bearer/cookie: it rides
97
+ * Authorization: Bearer pk_… on fetch and ?ingest_key=pk_… on a headerless
98
+ * page-unload beacon, so ALL THREE lenses (web + product + error) light up with
99
+ * no bearer and unload beacons work anonymously. The key is write-only (cannot
100
+ * read) and safe to ship in a bundle; mint one per org via POST /v1/ingest/keys.
101
+ * Recommended for marketing/public pages and the full sentry-subsuming setup. */
102
+ ingestKey?: string;
85
103
  /** Max events buffered before an automatic flush. */
86
104
  batchSize?: number;
87
105
  /** Auto-flush cadence in ms. */
@@ -98,7 +116,7 @@ interface AnalyticsConfig {
98
116
  debug?: boolean;
99
117
  }
100
118
 
101
- declare const VERSION = "0.2.0";
119
+ declare const VERSION = "0.3.0";
102
120
  declare class Analytics {
103
121
  private cfg;
104
122
  private transport;
@@ -110,8 +128,9 @@ declare class Analytics {
110
128
  private started;
111
129
  constructor(config: AnalyticsConfig);
112
130
  /** init is idempotent and browser-only for its side effects: capture first-touch
113
- * attribution, hydrate cohort, and register the unload flush. Safe to call from
114
- * a React effect on every render. */
131
+ * attribution, hydrate cohort, register the unload flush, and (unless opted out)
132
+ * auto-capture unhandled errors. Safe to call from a React effect on every
133
+ * render. */
115
134
  init(): void;
116
135
  /** identify binds the current visitor to a stable person id (post-login). */
117
136
  identify(personId: string, traits?: Record<string, unknown>): void;
@@ -126,11 +145,12 @@ declare class Analytics {
126
145
  /** track is an alias of capture (Segment familiarity). */
127
146
  track: (event: string, properties?: Record<string, unknown>, commerce?: Pick<WireEvent, "productId" | "quantity" | "revenue" | "currency">) => void;
128
147
  /** captureError records an exception as a first-class error event — the ONE
129
- * error path (subsumes @sentry). A caught error, an unhandled rejection, or a
130
- * manual report all become a type:'error' event on the same stream, lensed to
131
- * the error-tracking view server-side. Never throws back into the app; errors
132
- * are higher-signal than pageviews, so it flushes promptly (a crash may unload
133
- * the page moments later). */
148
+ * error path (subsumes @sentry). A caught error, an unhandled rejection, a
149
+ * React render error, or a manual report all become a type:'error' event on the
150
+ * same stream; Cloud folds the exception into properties.$exception and stamps
151
+ * event_type='error', so it surfaces in the error-tracking lens. Never throws
152
+ * back into the app; errors are higher-signal than pageviews, so it flushes
153
+ * promptly (a crash may unload the page moments later). */
134
154
  captureError(err: unknown, context?: {
135
155
  handled?: boolean;
136
156
  properties?: Record<string, unknown>;
@@ -143,8 +163,17 @@ declare class Analytics {
143
163
  /** setCohort persists cohort dimensions (e.g. signupWeek at signup) so they ride
144
164
  * every subsequent event. */
145
165
  setCohort(patch: Cohort): void;
146
- /** flush drains the buffer to the server as one batch. beacon=true uses the
147
- * unload-safe path. */
166
+ /** flush drains the buffer to the server as ONE batch through the ONE ingest
167
+ * front door POST /v1/event, body { batch: [Event…] }. beacon=true selects the
168
+ * unload-safe transport. Auth is orthogonal to the wire:
169
+ *
170
+ * • publishable key set → rides Authorization: Bearer pk_… (fetch) or
171
+ * ?ingest_key=pk_… (beacon), so unload beacons work anonymously.
172
+ * • else a bearer JWT rides Authorization (fetch only — sendBeacon cannot
173
+ * carry a header, so token apps fall back to keepalive fetch on unload).
174
+ * • else a cookie app rides same-origin credentials (beacon carries the
175
+ * cookie fine).
176
+ */
148
177
  flush(beacon?: boolean): void;
149
178
  private enqueue;
150
179
  private build;
@@ -1,8 +1,10 @@
1
1
  /** The event kinds — the closed set the server understands. An error is just
2
- * another event on the one stream (lensed to the error-tracking view). */
2
+ * another event on the one stream (Cloud stamps type:'error' event_type='error',
3
+ * the key the error-tracking lens filters on). */
3
4
  type EventKind = 'pageview' | 'event' | 'identify' | 'group' | 'error';
4
- /** A captured exception. Carried on a `type:'error'` event; the server lenses it
5
- * into the error-tracking view (sentry.hanzo.ai). */
5
+ /** A captured exception. Carried on a `type:'error'` event's top-level `error`
6
+ * field; Cloud folds it into properties.$exception and lenses the event into the
7
+ * error-tracking view (sentry.hanzo.ai). */
6
8
  interface Exception {
7
9
  /** Constructor/class name, e.g. "TypeError". */
8
10
  type?: string;
@@ -35,8 +37,10 @@ interface Cohort {
35
37
  channel?: string;
36
38
  refCode?: string;
37
39
  }
38
- /** One event as sent on the wire. tenant/org is NEVER set here the server
39
- * stamps it from the validated session. */
40
+ /** One event as sent on the wire the canonical Hanzo Cloud event. Maps 1:1 to
41
+ * the cloud `CaptureEvent` (camelCase JSON keys); a batch of these is POSTed to
42
+ * the ONE front door `/v1/event` as `{ batch: [WireEvent, …] }`. tenant/org is
43
+ * NEVER a field here — the server stamps it from the validated session/key. */
40
44
  interface WireEvent {
41
45
  messageId: string;
42
46
  type: EventKind;
@@ -59,29 +63,43 @@ interface WireEvent {
59
63
  quantity?: number;
60
64
  revenue?: number;
61
65
  currency?: string;
62
- /** Set on `type:'error'` events — the captured exception. */
66
+ /** Set on `type:'error'` events — the captured exception. Cloud lifts it into
67
+ * properties.$exception (foldException) for the error-tracking lens. */
63
68
  error?: Exception;
64
69
  properties?: Record<string, unknown>;
65
70
  library?: string;
66
71
  libraryVersion?: string;
67
72
  }
68
- /** Injectable transports — overridden in tests; default in core.ts uses fetch. */
73
+ /** Injectable transports — overridden in tests; the default in core.ts uses fetch
74
+ * (keepalive) and sendBeacon. A bearer JWT or a publishable pk_ key rides
75
+ * Authorization on fetch; on a headerless beacon a publishable key rides the
76
+ * ?ingest_key query. */
69
77
  interface Transport {
70
78
  /** Durable POST usable during page unload (fetch keepalive / sendBeacon). */
71
79
  send(url: string, body: string, opts: {
72
80
  beacon: boolean;
73
81
  token?: string;
82
+ ingestKey?: string;
74
83
  }): void;
75
84
  }
76
85
  interface AnalyticsConfig {
77
- /** Cloud base URL. Same-origin ("") for cookie-auth apps (console/admin);
78
- * e.g. "https://api.hanzo.ai" for bearer apps (app/site). */
86
+ /** Cloud base URL. Defaults to "https://api.hanzo.ai" (the one edge). Set to
87
+ * same-origin ("") for cookie-auth apps served behind the same edge
88
+ * (console/admin/chat), so the browser rides the session cookie. */
79
89
  host?: string;
80
90
  /** Emitting surface: console | chat | app | site | admin. */
81
91
  product: string;
82
92
  /** Bearer token provider for token-auth apps. Omit for cookie/session apps
83
93
  * (the client then relies on same-origin credentials). */
84
94
  getToken?: () => string | undefined | null;
95
+ /** Publishable ingest key (pk_…). When set, the client authenticates to the ONE
96
+ * front door `/v1/event` with this key instead of a bearer/cookie: it rides
97
+ * Authorization: Bearer pk_… on fetch and ?ingest_key=pk_… on a headerless
98
+ * page-unload beacon, so ALL THREE lenses (web + product + error) light up with
99
+ * no bearer and unload beacons work anonymously. The key is write-only (cannot
100
+ * read) and safe to ship in a bundle; mint one per org via POST /v1/ingest/keys.
101
+ * Recommended for marketing/public pages and the full sentry-subsuming setup. */
102
+ ingestKey?: string;
85
103
  /** Max events buffered before an automatic flush. */
86
104
  batchSize?: number;
87
105
  /** Auto-flush cadence in ms. */
@@ -98,7 +116,7 @@ interface AnalyticsConfig {
98
116
  debug?: boolean;
99
117
  }
100
118
 
101
- declare const VERSION = "0.2.0";
119
+ declare const VERSION = "0.3.0";
102
120
  declare class Analytics {
103
121
  private cfg;
104
122
  private transport;
@@ -110,8 +128,9 @@ declare class Analytics {
110
128
  private started;
111
129
  constructor(config: AnalyticsConfig);
112
130
  /** init is idempotent and browser-only for its side effects: capture first-touch
113
- * attribution, hydrate cohort, and register the unload flush. Safe to call from
114
- * a React effect on every render. */
131
+ * attribution, hydrate cohort, register the unload flush, and (unless opted out)
132
+ * auto-capture unhandled errors. Safe to call from a React effect on every
133
+ * render. */
115
134
  init(): void;
116
135
  /** identify binds the current visitor to a stable person id (post-login). */
117
136
  identify(personId: string, traits?: Record<string, unknown>): void;
@@ -126,11 +145,12 @@ declare class Analytics {
126
145
  /** track is an alias of capture (Segment familiarity). */
127
146
  track: (event: string, properties?: Record<string, unknown>, commerce?: Pick<WireEvent, "productId" | "quantity" | "revenue" | "currency">) => void;
128
147
  /** captureError records an exception as a first-class error event — the ONE
129
- * error path (subsumes @sentry). A caught error, an unhandled rejection, or a
130
- * manual report all become a type:'error' event on the same stream, lensed to
131
- * the error-tracking view server-side. Never throws back into the app; errors
132
- * are higher-signal than pageviews, so it flushes promptly (a crash may unload
133
- * the page moments later). */
148
+ * error path (subsumes @sentry). A caught error, an unhandled rejection, a
149
+ * React render error, or a manual report all become a type:'error' event on the
150
+ * same stream; Cloud folds the exception into properties.$exception and stamps
151
+ * event_type='error', so it surfaces in the error-tracking lens. Never throws
152
+ * back into the app; errors are higher-signal than pageviews, so it flushes
153
+ * promptly (a crash may unload the page moments later). */
134
154
  captureError(err: unknown, context?: {
135
155
  handled?: boolean;
136
156
  properties?: Record<string, unknown>;
@@ -143,8 +163,17 @@ declare class Analytics {
143
163
  /** setCohort persists cohort dimensions (e.g. signupWeek at signup) so they ride
144
164
  * every subsequent event. */
145
165
  setCohort(patch: Cohort): void;
146
- /** flush drains the buffer to the server as one batch. beacon=true uses the
147
- * unload-safe path. */
166
+ /** flush drains the buffer to the server as ONE batch through the ONE ingest
167
+ * front door POST /v1/event, body { batch: [Event…] }. beacon=true selects the
168
+ * unload-safe transport. Auth is orthogonal to the wire:
169
+ *
170
+ * • publishable key set → rides Authorization: Bearer pk_… (fetch) or
171
+ * ?ingest_key=pk_… (beacon), so unload beacons work anonymously.
172
+ * • else a bearer JWT rides Authorization (fetch only — sendBeacon cannot
173
+ * carry a header, so token apps fall back to keepalive fetch on unload).
174
+ * • else a cookie app rides same-origin credentials (beacon carries the
175
+ * cookie fine).
176
+ */
148
177
  flush(beacon?: boolean): void;
149
178
  private enqueue;
150
179
  private build;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as Cohort, A as Attribution } from './core-DDGwms7M.cjs';
2
- export { a as Analytics, b as AnalyticsConfig, E as EventKind, c as Exception, T as Transport, V as VERSION, W as WireEvent, d as createAnalytics } from './core-DDGwms7M.cjs';
1
+ import { C as Cohort, A as Attribution } from './core-CrbiAQhN.cjs';
2
+ export { a as Analytics, b as AnalyticsConfig, E as EventKind, c as Exception, T as Transport, V as VERSION, W as WireEvent, d as createAnalytics } from './core-CrbiAQhN.cjs';
3
3
 
4
4
  /** Read the persisted first-touch attribution. */
5
5
  declare function getFirstTouch(): Attribution | undefined;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as Cohort, A as Attribution } from './core-DDGwms7M.js';
2
- export { a as Analytics, b as AnalyticsConfig, E as EventKind, c as Exception, T as Transport, V as VERSION, W as WireEvent, d as createAnalytics } from './core-DDGwms7M.js';
1
+ import { C as Cohort, A as Attribution } from './core-CrbiAQhN.js';
2
+ export { a as Analytics, b as AnalyticsConfig, E as EventKind, c as Exception, T as Transport, V as VERSION, W as WireEvent, d as createAnalytics } from './core-CrbiAQhN.js';
3
3
 
4
4
  /** Read the persisted first-touch attribution. */
5
5
  declare function getFirstTouch(): Attribution | undefined;
package/dist/index.js CHANGED
@@ -193,9 +193,12 @@ function mergeCohort(patch) {
193
193
  }
194
194
 
195
195
  // src/core.ts
196
- var VERSION = "0.2.0";
197
- var ANALYTICS_PATH = "/v1/analytics";
198
- var TRACKER_PATH = "/v1/tracker";
196
+ var VERSION = "0.3.0";
197
+ var EVENT_PATH = "/v1/event";
198
+ var DEFAULT_HOST = "https://api.hanzo.ai";
199
+ function appendQuery(url, key, value) {
200
+ return url + (url.includes("?") ? "&" : "?") + key + "=" + encodeURIComponent(value);
201
+ }
199
202
  function uid2() {
200
203
  const c = typeof crypto !== "undefined" ? crypto : void 0;
201
204
  if (c && "randomUUID" in c) return c.randomUUID();
@@ -216,15 +219,17 @@ var isBrowser = () => typeof window !== "undefined";
216
219
  var DefaultTransport = class {
217
220
  send(url, body, opts) {
218
221
  if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === "function") {
222
+ const beaconUrl = opts.ingestKey ? appendQuery(url, "ingest_key", opts.ingestKey) : url;
219
223
  try {
220
- navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
224
+ navigator.sendBeacon(beaconUrl, new Blob([body], { type: "application/json" }));
221
225
  return;
222
226
  } catch {
223
227
  }
224
228
  }
225
229
  if (typeof fetch !== "function") return;
226
230
  const headers = { "Content-Type": "application/json" };
227
- if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
231
+ const bearer = opts.ingestKey ?? opts.token;
232
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
228
233
  void fetch(url, {
229
234
  method: "POST",
230
235
  headers,
@@ -247,7 +252,7 @@ var Analytics = class {
247
252
  /** captureException — @sentry-familiar alias of captureError. */
248
253
  this.captureException = this.captureError.bind(this);
249
254
  this.cfg = {
250
- host: "",
255
+ host: DEFAULT_HOST,
251
256
  batchSize: 20,
252
257
  flushIntervalMs: 5e3,
253
258
  enabled: true,
@@ -257,8 +262,9 @@ var Analytics = class {
257
262
  this.transport = config.transport ?? new DefaultTransport();
258
263
  }
259
264
  /** init is idempotent and browser-only for its side effects: capture first-touch
260
- * attribution, hydrate cohort, and register the unload flush. Safe to call from
261
- * a React effect on every render. */
265
+ * attribution, hydrate cohort, register the unload flush, and (unless opted out)
266
+ * auto-capture unhandled errors. Safe to call from a React effect on every
267
+ * render. */
262
268
  init() {
263
269
  if (this.started || !this.cfg.enabled) return;
264
270
  this.started = true;
@@ -305,11 +311,12 @@ var Analytics = class {
305
311
  this.enqueue("event", event, { properties, ...commerce });
306
312
  }
307
313
  /** captureError records an exception as a first-class error event — the ONE
308
- * error path (subsumes @sentry). A caught error, an unhandled rejection, or a
309
- * manual report all become a type:'error' event on the same stream, lensed to
310
- * the error-tracking view server-side. Never throws back into the app; errors
311
- * are higher-signal than pageviews, so it flushes promptly (a crash may unload
312
- * the page moments later). */
314
+ * error path (subsumes @sentry). A caught error, an unhandled rejection, a
315
+ * React render error, or a manual report all become a type:'error' event on the
316
+ * same stream; Cloud folds the exception into properties.$exception and stamps
317
+ * event_type='error', so it surfaces in the error-tracking lens. Never throws
318
+ * back into the app; errors are higher-signal than pageviews, so it flushes
319
+ * promptly (a crash may unload the page moments later). */
313
320
  captureError(err, context) {
314
321
  const ex = normalizeError(err);
315
322
  ex.handled = context?.handled ?? true;
@@ -321,19 +328,28 @@ var Analytics = class {
321
328
  setCohort(patch) {
322
329
  this.cohort = mergeCohort(patch);
323
330
  }
324
- /** flush drains the buffer to the server as one batch. beacon=true uses the
325
- * unload-safe path. */
331
+ /** flush drains the buffer to the server as ONE batch through the ONE ingest
332
+ * front door POST /v1/event, body { batch: [Event…] }. beacon=true selects the
333
+ * unload-safe transport. Auth is orthogonal to the wire:
334
+ *
335
+ * • publishable key set → rides Authorization: Bearer pk_… (fetch) or
336
+ * ?ingest_key=pk_… (beacon), so unload beacons work anonymously.
337
+ * • else a bearer JWT rides Authorization (fetch only — sendBeacon cannot
338
+ * carry a header, so token apps fall back to keepalive fetch on unload).
339
+ * • else a cookie app rides same-origin credentials (beacon carries the
340
+ * cookie fine).
341
+ */
326
342
  flush(beacon = false) {
327
343
  if (!this.cfg.enabled || this.queue.length === 0) return;
328
344
  const batch = this.queue;
329
345
  this.queue = [];
330
346
  this.clearTimer();
331
- const token = this.cfg.getToken?.() ?? void 0;
347
+ const key = this.cfg.ingestKey?.trim() || void 0;
348
+ const token = key ? void 0 : this.cfg.getToken?.() ?? void 0;
332
349
  const useBeacon = beacon && !token;
333
- const path = useBeacon ? TRACKER_PATH : ANALYTICS_PATH;
334
350
  const body = JSON.stringify({ batch });
335
- if (this.cfg.debug) console.debug("[analytics] flush", batch.length, path);
336
- this.transport.send(this.cfg.host + path, body, { beacon: useBeacon, token: token ?? void 0 });
351
+ if (this.cfg.debug) console.debug("[event] flush \u2192", EVENT_PATH, batch.length);
352
+ this.transport.send(this.cfg.host + EVENT_PATH, body, { beacon: useBeacon, token, ingestKey: key });
337
353
  }
338
354
  // ── internals ────────────────────────────────────────────────────────────
339
355
  enqueue(kind, event, extra) {