@metricinsights/ca-analytics 0.1.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
@@ -33,19 +33,22 @@ npm i @metricinsights/ca-analytics
33
33
 
34
34
  ## Usage
35
35
 
36
- ```ts
36
+ ```tsx
37
37
  import { init, useAnalytics } from '@metricinsights/ca-analytics';
38
38
 
39
39
  // app entry, once
40
- init({ app: 'my-portal-page' });
40
+ init();
41
+
42
+ // in any component
43
+ function RevenueTile() {
44
+ const { track } = useAnalytics();
41
45
 
42
- // anywhere in the app
43
- const { track } = useAnalytics();
44
- track('tile_clicked', { tile: 'revenue' });
46
+ return <button onClick={() => track('tile_clicked', { tile: 'revenue' })}>Revenue</button>;
47
+ }
45
48
  ```
46
49
 
47
50
  `useAnalytics()` is a thin wrapper over the module singleton — no provider, no context, callable
48
- from any component.
51
+ from any component. Outside React, import `track`, `trackElement` and `trackPageView` directly.
49
52
 
50
53
  ## API
51
54
 
@@ -64,11 +67,15 @@ later `init()` inside the 30-minute idle window resumes the *same* `session_id`.
64
67
 
65
68
  | Export | Signature | Notes |
66
69
  | --- | --- | --- |
67
- | `track` | `(event: string, props?: Record<string, unknown>) => void` | Empty and [reserved](#events) names are ignored. |
68
- | `trackElement` | `(props: { element_id: number; segment_id?: number; [key: string]: unknown }) => void` | Emits `element_click`. |
70
+ | `track` | `(event: string, props?: object) => void` | Empty and [reserved](#events) names are ignored. |
71
+ | `trackElement` | `<T extends { element_id: number; segment_id?: number }>(props: T) => void` | Emits `element_click`. |
69
72
  | `trackPageView` | `(path?: string) => void` | For routers the `history` patch cannot observe. Skips dedupe — always emits. |
70
73
  | `useAnalytics` | `() => { track, trackElement, trackPageView }` | React hook; no provider needed. |
71
74
 
75
+ Props can be any object, including interface-typed ones, with values of any type. They are
76
+ serialized with `JSON.stringify` semantics: a `Date` becomes its ISO string, and `undefined` and
77
+ functions are dropped.
78
+
72
79
  ### Constants
73
80
 
74
81
  | Export | Value | Notes |
@@ -82,14 +89,8 @@ Every option is optional. Each falls back to a portal-page variable, then to a d
82
89
 
83
90
  | Option | Variable | Default | Effect |
84
91
  | --- | --- | --- | --- |
85
- | `app` | — | derived from the URL | Portal page internal name. |
86
92
  | `enabled` | `CUSTOM_APP_ANALYTICS_ENABLED` | `true` | `false` disables all tracking. |
87
93
 
88
- `app` is derived from `location.pathname` against `/^\/(p[tl]?)\/([^/]+)(.*)$/`. **If it cannot
89
- be derived and isn't passed, `init()` stays silent** rather than writing rows with a blank `app`
90
- that nothing could attribute. This is why a bare `init()` does nothing on a local dev server,
91
- where the path is `/`.
92
-
93
94
  Portal-page variables are read from `window.PP_VARIABLES` and always arrive as **strings**.
94
95
  `'0'`, `'false'`, `'n'`, `'no'` (case-insensitive, trimmed) mean disabled; any other non-empty
95
96
  string means enabled. Unset, empty, or left unsubstituted by MI (a literal `[Custom App
@@ -99,9 +100,9 @@ Analytics Enabled]`) counts as *not set* and falls through to the option or defa
99
100
 
100
101
  | Event | Fires when | `meta` |
101
102
  | --- | --- | --- |
102
- | `page_view` | Once on `init()`, then on every `pushState` / `replaceState` / `popstate`, or on demand via `trackPageView()`. | `referrer`, `query` — always both, always strings |
103
+ | `page_view` | Once on `init()`, then on every `pushState` / `replaceState` / `popstate` / `hashchange`, or on demand via `trackPageView()`. | `referrer`, `query` — always both, always strings |
103
104
  | `heartbeat` | Tab visible: every 60s for 5 beats, then every 300s. | `{}` |
104
- | `component_render` | The `mi-render` `CustomEvent` MI dispatches on `document`. | `component`, `element_id`, `segment_id` |
105
+ | `component_render` | The `mi-render` `CustomEvent` MI dispatches on `document`. | `component`, `element_id`, `segment_id`, plus each prop as `prop.<path>` |
105
106
  | `element_click` | `trackElement()`. | `element_id`, `segment_id`, plus your own keys |
106
107
  | *(custom)* | `track(name, props)`. | your `props` verbatim |
107
108
 
@@ -113,23 +114,44 @@ name that does not match is still tracked verbatim but logs one console warning,
113
114
  dataset is append-only with no rename — `'Tile Click'`, `'tile_click '` and `'tileClick'` would
114
115
  become three permanent, unmergeable series. Reserved-name matching is case-insensitive.
115
116
 
116
- Two meta keys are reserved: `_truncated` is stamped only when meta actually exceeds 2000
117
- characters, and the dashboard hides both `_truncated` and `capped` from its property view, so do
117
+ Two meta keys are reserved: `_truncated` is stamped only when meta exceeds 2000 characters or
118
+ cannot be serialized as a whole (for example a `BigInt` or a circular value), and the dashboard hides both `_truncated` and `capped` from its property view, so do
118
119
  not use either as your own prop name.
119
120
 
120
- **`page_view`** dedupes on path + search, so a router calling `replaceState` for scroll or filter
121
- sync does not emit. `meta.referrer` follows GA4 semantics: the first view of the document uses
122
- `document.referrer` normalized to origin + pathname; every later view uses the `page_path` of the
123
- previous view. `meta.query` is `location.search` without the leading `?`. Both are capped at 300
124
- characters; either can be `''`.
121
+ **`page_view`** dedupes on path + search (+ hash route, see below), so a router calling
122
+ `replaceState` for scroll or filter sync does not emit. `meta.referrer` follows GA4 semantics: the
123
+ first view of the document uses `document.referrer` normalized to origin + pathname; every later
124
+ view uses the `page_path` of the previous view. `meta.query` is `location.search` without the
125
+ leading `?`. Both are capped at 300 characters; either can be `''`.
126
+
127
+ **Hash routing.** Hash-routed apps (for example React Router's `HashRouter`) are tracked without
128
+ extra setup. When `location.hash` starts with `#/`, the fragment is the route: `#/reports/42?tab=1`
129
+ records `page_path` `/reports/42` (it replaces any path below the portal page), and the fragment's
130
+ own query is appended to `meta.query` with `&` (`?embed=1#/reports?tab=1` → `embed=1&tab=1`). A
131
+ change of the hash route is a new view. A plain in-page anchor (`#section`, anything not starting
132
+ with `#/`) is not a route: it never changes `page_path` and never emits a `page_view`.
133
+
134
+ **`component_render`** reads `detail.component` plus `detail.props`. `MiNamespace.render()` is a
135
+ pure event emitter, so `document` sees every shared component render — no patching.
136
+
137
+ Identity comes only from `element`/`element_id` and `segment`/`segment_id`/`segment_value_id` —
138
+ the spellings MI's own components use. No other key feeds `element_id`, which joins to
139
+ `/api/element_info`, where a foreign numeric id would join wrong.
125
140
 
126
- **`component_render`** reads `detail.component` plus `detail.props`, accepting either
127
- `{element, segment}` or `{element_id, segment_value_id}`. Capped at 50 emits per distinct
128
- component + element + segment per page load; the emit that hits the cap carries `capped: 1`.
129
- `MiNamespace.render()` is a pure event emitter, so listening on `document` observes every shared
130
- component render without patching or wrapping anything.
141
+ Every other prop flattens to `prop.<dotted.path>` — flat, because the dashboard summarizes per
142
+ key and a nested object collapses to one opaque string. Scalars verbatim, strings capped at 40,
143
+ arrays as `arr:<length>`, 4 levels deep, 40 keys. When the depth or key cap drops a prop, the row
144
+ carries `props_dropped: 1`.
131
145
 
132
- **`trackElement`** needs `element_id` to be finite and non-zero — anything else drops the event
146
+ Props are stored verbatim and readable by anyone with dashboard access. Keep record-specific text
147
+ out of props you render with.
148
+
149
+ Capped at 50 emits per distinct component + element + segment + props; the capping emit carries
150
+ `capped: 1`. Past 200 distinct buckets per `init()`, a render that would open a new bucket goes into
151
+ one shared overflow bucket per component instead, which has the same cap of 50.
152
+
153
+ **`trackElement`** needs `element_id` to be a non-zero number within the `int` column's range
154
+ (−2³¹ … 2³¹−1; a fraction is truncated) — anything else drops the event
133
155
  (one `console.warn` per runtime). `segment_id` is optional and defaults to `0` when omitted, with
134
156
  no warning; a supplied non-finite value also records `0` but logs one `console.warn` per runtime.
135
157
  Extra props pass through to `meta` untouched, and
@@ -148,25 +170,30 @@ stamps itself — the client never sends it.
148
170
  | --- | --- | --- |
149
171
  | `id` | text | Client UUID. Reused across a retry so readers dedupe with `COUNT(DISTINCT id)`. |
150
172
  | `ts` | datetime | `YYYY-MM-DD HH:MM:SS`, UTC. **Never ISO** — see [One-way doors](#one-way-doors). |
151
- | `app` | text | Portal page internal name, capped at 100. |
173
+ | `app` | text | Portal page internal name, read from `location.pathname` against `/^\/(p[tl]?)\/([^/]+)(.*)$/`. Capped at 100. |
152
174
  | `event` | text | Capped at 100. |
153
175
  | `session_id` | text | New session after 30 minutes idle. Survives reloads via `localStorage`. |
154
- | `page_path` | text | Route below the portal page, `/` at the root. Capped at 400. |
176
+ | `page_path` | text | Route below the portal page, `/` at the root; a `#/` hash route when present (see [Hash routing](#events)). Capped at 400. |
155
177
  | `element_id` | int | `0` when not applicable. Integers only. |
156
178
  | `version` | text | This package's version, capped at 20. |
157
179
  | `meta` | text | JSON string, capped at 2000. |
158
180
 
181
+ `pp-dev` serves that same path locally. **Off that path shape, `init()` stays silent** rather
182
+ than writing rows with a blank `app` that nothing could attribute.
183
+
159
184
  Text columns are wide (10500) and the caps above are client-side payload budget, keeping a batch
160
185
  inside `sendBeacon`'s 64 KiB quota — they are not schema limits.
161
186
 
162
187
  `meta` over 2000 characters drops whole keys rather than slicing, so the stored value always
163
- parses, and stamps `_truncated: 1`.
188
+ parses, and stamps `_truncated: 1`. Meta that `JSON.stringify` cannot serialize as a whole takes the
189
+ same path: keys that fail on their own are dropped, and `_truncated: 1` is stamped.
164
190
 
165
191
  ## Delivery
166
192
 
167
193
  Rows buffer and flush on whichever comes first: 15 seconds, 50 rows, or 30 KB encoded. On
168
194
  `pagehide`, and on `visibilitychange` to hidden (which is what actually fires on iOS), the buffer
169
- goes out via `sendBeacon`, falling back to `fetch(…, { keepalive: true })`.
195
+ goes out via `sendBeacon`, falling back to `fetch(…, { keepalive: true })`, in chunks of at most
196
+ 30 KB each.
170
197
 
171
198
  A failed batch retries once after 2 seconds — network errors, 429 and 5xx are retryable; a `200`
172
199
  carrying a non-zero `resultCode` is a rejected insert and is not. A failed batch is dropped, not
@@ -196,6 +223,15 @@ wrapped.
196
223
  > (`int → float → text`). One float or string converts the column permanently.
197
224
  > - **Never send `owner_user_id`.** The server stamps it and creates the column itself.
198
225
 
226
+ ## Releases
227
+
228
+ Releases use the shared workflows from [mi-examples-workflows](https://github.com/mi-examples/mi-examples-workflows) ([release flow](https://github.com/mi-examples/mi-examples-workflows/blob/main/docs/workflows.md#release-workflows)). This repository has no `develop` branch, so there are no betas:
229
+
230
+ 1. Run **Actions → Release → Run workflow**. It opens a release pull request `release/vX.Y.Z → main` with the version bump and the new `CHANGELOG.md` entry.
231
+ 2. Review and edit the entry in the pull request, then merge it.
232
+ 3. Merging publishes to npm under `latest` and creates the tag and the GitHub release.
233
+
234
+ Versions come from [Conventional Commits](https://www.conventionalcommits.org/) (`feat` → minor; `fix`, `perf`, `revert` → patch; `!` or `BREAKING CHANGE:` → major). Publishing uses npm Trusted Publishing (OIDC) from `.github/workflows/release.yml`, so don't rename that file.
199
235
  ## License
200
236
 
201
237
  MIT © 2026 Metric Insights
package/dist/index.cjs CHANGED
@@ -34,7 +34,7 @@ module.exports = __toCommonJS(index_exports);
34
34
 
35
35
  // src/core/constants.ts
36
36
  var ENTITY_NAME = "analytics_events";
37
- var VERSION = "0.1.0";
37
+ var VERSION = "0.3.0";
38
38
  var MI_PAGE_PATH_RE = /^\/(p[tl]?)\/([^/]+)(.*)$/;
39
39
  var WIDTHS = {
40
40
  app: 100,
@@ -66,25 +66,44 @@ function matchPage() {
66
66
  if (typeof window === "undefined" || !window.location) return null;
67
67
  return MI_PAGE_PATH_RE.exec(window.location.pathname);
68
68
  }
69
+ function isHashRoute(hash2) {
70
+ return hash2.startsWith("#/");
71
+ }
72
+ function hashRoute() {
73
+ const hash2 = window.location.hash;
74
+ if (!isHashRoute(hash2)) return null;
75
+ const route = hash2.slice(1);
76
+ const pathEnd = route.search(/[?#]/);
77
+ const path = pathEnd === -1 ? route : route.slice(0, pathEnd);
78
+ const queryMatch = /\?([^#]*)/.exec(route);
79
+ return { path, query: queryMatch ? queryMatch[1] : "" };
80
+ }
69
81
  function currentPagePath() {
70
82
  const match = matchPage();
71
83
  if (!match) return "/";
72
- const rest = match[3];
84
+ const rest = hashRoute()?.path ?? match[3];
73
85
  return (rest && rest !== "" && rest !== "/" ? rest : "/").slice(0, WIDTHS.page_path);
74
86
  }
87
+ function currentQuery() {
88
+ const outer = window.location.search.replace(/^\?/, "");
89
+ const inner = hashRoute()?.query ?? "";
90
+ return outer && inner ? `${outer}&${inner}` : outer || inner;
91
+ }
75
92
  function resolveConfig(options = {}) {
76
93
  const enabled = options.enabled ?? readFlag("CUSTOM_APP_ANALYTICS_ENABLED") ?? true;
77
94
  if (!enabled) return null;
78
- const app = (options.app ?? matchPage()?.[2] ?? "").slice(0, WIDTHS.app);
95
+ const app = (matchPage()?.[2] ?? "").slice(0, WIDTHS.app);
79
96
  if (!app) return null;
80
97
  return { app, endpoint: `/data/page/${encodeURIComponent(app)}/${ENTITY_NAME}` };
81
98
  }
82
99
 
83
100
  // src/core/event.ts
84
101
  var pad2 = (n) => String(n).padStart(2, "0");
102
+ var INT32_MIN = -(2 ** 31);
103
+ var INT32_MAX = 2 ** 31 - 1;
85
104
  function toId(value) {
86
- const n = Number(value);
87
- return Number.isFinite(n) ? Math.trunc(n) : 0;
105
+ const n = Math.trunc(Number(value));
106
+ return Number.isSafeInteger(n) && n >= INT32_MIN && n <= INT32_MAX ? n : 0;
88
107
  }
89
108
  function formatTs(date) {
90
109
  return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}`;
@@ -179,12 +198,13 @@ function sendOnUnload(endpoint, rows) {
179
198
  } catch {
180
199
  }
181
200
  try {
182
- void fetch(endpoint, {
201
+ fetch(endpoint, {
183
202
  method: "POST",
184
203
  credentials: "same-origin",
185
204
  headers: { "Content-Type": "application/json" },
186
205
  body,
187
206
  keepalive: true
207
+ }).catch(() => {
188
208
  });
189
209
  } catch {
190
210
  }
@@ -196,6 +216,22 @@ var MAX_FLUSH_ROWS = 50;
196
216
  var MAX_FLUSH_BYTES = 3e4;
197
217
  var MAX_CONSECUTIVE_FAILURES = 3;
198
218
  var textEncoder = new TextEncoder();
219
+ function chunkBySize(rows, maxBytes) {
220
+ const chunks = [];
221
+ let current = [];
222
+ let size = 2;
223
+ for (const row of rows) {
224
+ if (current.length > 0 && size + 1 + row.size > maxBytes) {
225
+ chunks.push(current);
226
+ current = [];
227
+ size = 2;
228
+ }
229
+ size += (current.length > 0 ? 1 : 0) + row.size;
230
+ current.push(row);
231
+ }
232
+ if (current.length > 0) chunks.push(current);
233
+ return chunks;
234
+ }
199
235
  function createQueue(endpoint, onKilled) {
200
236
  let buffer = [];
201
237
  let bytes = 0;
@@ -218,8 +254,8 @@ function createQueue(endpoint, onKilled) {
218
254
  buffer = [];
219
255
  bytes = 0;
220
256
  inFlight = true;
257
+ let outcome = "failed";
221
258
  try {
222
- let outcome;
223
259
  try {
224
260
  outcome = await send(
225
261
  endpoint,
@@ -228,6 +264,7 @@ function createQueue(endpoint, onKilled) {
228
264
  } catch {
229
265
  outcome = "failed";
230
266
  }
267
+ if (stopped) return;
231
268
  if (outcome === "ok") {
232
269
  failures = 0;
233
270
  return;
@@ -243,6 +280,7 @@ function createQueue(endpoint, onKilled) {
243
280
  }
244
281
  } finally {
245
282
  inFlight = false;
283
+ if (!stopped && outcome === "ok" && (buffer.length >= MAX_FLUSH_ROWS || bytes >= MAX_FLUSH_BYTES)) void flush();
246
284
  }
247
285
  }
248
286
  return {
@@ -267,10 +305,12 @@ function createQueue(endpoint, onKilled) {
267
305
  const batch = buffer;
268
306
  buffer = [];
269
307
  bytes = 0;
270
- sendOnUnload(
271
- endpoint,
272
- batch.map((b) => b.json)
273
- );
308
+ for (const chunk of chunkBySize(batch, MAX_FLUSH_BYTES)) {
309
+ sendOnUnload(
310
+ endpoint,
311
+ chunk.map((b) => b.json)
312
+ );
313
+ }
274
314
  },
275
315
  stop
276
316
  };
@@ -380,6 +420,53 @@ function startHeartbeat(emit) {
380
420
 
381
421
  // src/core/signals/miRender.ts
382
422
  var RENDER_CAP = 50;
423
+ var MAX_KEYS = 200;
424
+ var PROP_DEPTH = 4;
425
+ var PROP_KEYS = 40;
426
+ var PROP_STRING = 40;
427
+ var IDENTITY_KEYS = /* @__PURE__ */ new Set(["element", "element_id", "segment", "segment_id", "segment_value_id"]);
428
+ var SKIP = /* @__PURE__ */ Symbol("skip");
429
+ function leafValue(value) {
430
+ if (value === null) return null;
431
+ switch (typeof value) {
432
+ case "boolean":
433
+ return value;
434
+ case "number":
435
+ return Number.isFinite(value) ? value : SKIP;
436
+ case "string":
437
+ return value.slice(0, PROP_STRING);
438
+ default:
439
+ return SKIP;
440
+ }
441
+ }
442
+ function flattenProps(source, out, prefix, depth) {
443
+ let dropped = false;
444
+ for (const [key, value] of Object.entries(source)) {
445
+ if (Object.keys(out).length >= PROP_KEYS) return true;
446
+ if (depth === 0 && IDENTITY_KEYS.has(key)) continue;
447
+ const path = prefix === "" ? key : `${prefix}.${key}`;
448
+ if (Array.isArray(value)) {
449
+ out[`prop.${path}`] = `arr:${value.length}`;
450
+ continue;
451
+ }
452
+ if (value !== null && typeof value === "object") {
453
+ if (depth + 1 < PROP_DEPTH) dropped = flattenProps(value, out, path, depth + 1) || dropped;
454
+ else dropped = true;
455
+ continue;
456
+ }
457
+ const leaf = leafValue(value);
458
+ if (leaf !== SKIP) out[`prop.${path}`] = leaf;
459
+ }
460
+ return dropped;
461
+ }
462
+ function hash(text) {
463
+ let h = 2166136261;
464
+ for (let i = 0; i < text.length; i += 1) {
465
+ h ^= text.charCodeAt(i);
466
+ h = Math.imul(h, 16777619);
467
+ }
468
+ return (h >>> 0).toString(36);
469
+ }
383
470
  function startMiRender(emit) {
384
471
  let stopped = false;
385
472
  const counts = /* @__PURE__ */ new Map();
@@ -392,16 +479,21 @@ function startMiRender(emit) {
392
479
  if (!component) return;
393
480
  const props = detail.props && typeof detail.props === "object" ? detail.props : {};
394
481
  const elementId = toId(props.element ?? props.element_id ?? 0);
395
- const segmentId = toId(props.segment ?? props.segment_value_id ?? 0);
396
- const key = `${component}|${elementId}|${segmentId}`;
482
+ const segmentId = toId(props.segment ?? props.segment_id ?? props.segment_value_id ?? 0);
483
+ const flat = {};
484
+ const propsDropped = flattenProps(props, flat, "", 0);
485
+ const bucket = `${component}|${elementId}|${segmentId}|${hash(JSON.stringify(flat))}`;
486
+ const key = counts.has(bucket) || counts.size < MAX_KEYS ? bucket : `${component}|*`;
397
487
  const seq = (counts.get(key) ?? 0) + 1;
398
488
  if (seq > RENDER_CAP) return;
399
489
  counts.set(key, seq);
400
490
  const meta = {
401
491
  component,
402
492
  element_id: elementId,
403
- segment_id: segmentId
493
+ segment_id: segmentId,
494
+ ...flat
404
495
  };
496
+ if (propsDropped) meta.props_dropped = 1;
405
497
  if (seq === RENDER_CAP) meta.capped = 1;
406
498
  emit({ elementId, meta });
407
499
  } catch {
@@ -434,7 +526,8 @@ function startPageView(emit, currentPath) {
434
526
  let lastLocation = null;
435
527
  const locationKey = (path) => {
436
528
  try {
437
- return `${path}${window.location.search}`;
529
+ const { search, hash: hash2 } = window.location;
530
+ return `${path}${search}${isHashRoute(hash2) ? hash2 : ""}`;
438
531
  } catch {
439
532
  return path;
440
533
  }
@@ -465,6 +558,7 @@ function startPageView(emit, currentPath) {
465
558
  window.history.replaceState = patchedReplace;
466
559
  const onPopState = () => safeEmit(currentPath, true);
467
560
  window.addEventListener("popstate", onPopState);
561
+ window.addEventListener("hashchange", onPopState);
468
562
  safeEmit(currentPath, true);
469
563
  return {
470
564
  stop() {
@@ -478,6 +572,7 @@ function startPageView(emit, currentPath) {
478
572
  window.history.replaceState = originalReplace;
479
573
  }
480
574
  window.removeEventListener("popstate", onPopState);
575
+ window.removeEventListener("hashchange", onPopState);
481
576
  },
482
577
  emitNow(path) {
483
578
  safeEmit(() => path ?? currentPath(), false);
@@ -500,7 +595,7 @@ function warnOnce(key, message) {
500
595
  console.warn(`[ca-analytics] ${message}`);
501
596
  }
502
597
  function pageViewMeta(referrer) {
503
- const query = window.location.search.replace(/^\?/, "");
598
+ const query = currentQuery();
504
599
  return { referrer: referrer.slice(0, LIMITS.referrer), query: query.slice(0, LIMITS.query) };
505
600
  }
506
601
  function stopStarted(parts, flushFirst) {
@@ -524,7 +619,9 @@ function init(options = {}) {
524
619
  if (runtime) return;
525
620
  const config = resolveConfig(options);
526
621
  if (!config) return;
527
- const queue = createQueue(config.endpoint, () => shutdown());
622
+ const queue = createQueue(config.endpoint, () => {
623
+ if (runtime?.queue === queue) shutdown();
624
+ });
528
625
  const emit = (event, extra = {}) => {
529
626
  queue.push(
530
627
  createRow({
@@ -587,7 +684,7 @@ function trackElement(props) {
587
684
  const { element_id, segment_id, ...extra } = props ?? {};
588
685
  const id = toId(element_id);
589
686
  if (!id) {
590
- warnOnce("element", `trackElement() ignored a non-finite or zero element_id (${element_id})`);
687
+ warnOnce("element", `trackElement() ignored a zero, non-finite or out-of-range element_id (${element_id})`);
591
688
  return;
592
689
  }
593
690
  const segmentRaw = Number(segment_id);