@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 +69 -33
- package/dist/index.cjs +115 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -7
- package/dist/index.d.ts +9 -7
- package/dist/index.js +115 -18
- package/dist/index.js.map +1 -1
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -33,19 +33,22 @@ npm i @metricinsights/ca-analytics
|
|
|
33
33
|
|
|
34
34
|
## Usage
|
|
35
35
|
|
|
36
|
-
```
|
|
36
|
+
```tsx
|
|
37
37
|
import { init, useAnalytics } from '@metricinsights/ca-analytics';
|
|
38
38
|
|
|
39
39
|
// app entry, once
|
|
40
|
-
init(
|
|
40
|
+
init();
|
|
41
|
+
|
|
42
|
+
// in any component
|
|
43
|
+
function RevenueTile() {
|
|
44
|
+
const { track } = useAnalytics();
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
|
|
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?:
|
|
68
|
-
| `trackElement` |
|
|
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
|
|
117
|
-
|
|
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
|
|
121
|
-
sync does not emit. `meta.referrer` follows GA4 semantics: the
|
|
122
|
-
`document.referrer` normalized to origin + pathname; every later
|
|
123
|
-
previous view. `meta.query` is `location.search` without the
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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 = (
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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, () =>
|
|
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
|
|
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);
|