@metricinsights/ca-analytics 0.1.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/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/index.cjs +633 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +51 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +598 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Metric Insights
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# @metricinsights/ca-analytics
|
|
2
|
+
|
|
3
|
+
Zero-dependency usage analytics for Metric Insights Custom Apps. Runs in the browser, batches
|
|
4
|
+
page views, engagement heartbeats, component renders and custom events, and writes them into an
|
|
5
|
+
App Dataset entity named `analytics_events` on the tracked app's own portal page.
|
|
6
|
+
|
|
7
|
+
Rows written here are read back by the Custom Apps Analytics Dashboard, which turns them into
|
|
8
|
+
adoption, per-element usage, and audience reporting. This package only writes.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
npm i @metricinsights/ca-analytics
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`react` is an optional peer dependency (`>=18.0.0`), needed only for the `useAnalytics()` hook.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
1. **Create the dataset.** In MI, a new dataset with source type "CSV/Excel" (manual upload).
|
|
21
|
+
Save it **without uploading a file** — and never upload one. See
|
|
22
|
+
[One-way doors](#one-way-doors).
|
|
23
|
+
2. **Create the entity.** On the tracked app's **own** portal page, an entity named
|
|
24
|
+
`analytics_events` bound to that dataset: type Internal, App Dataset ticked, access type
|
|
25
|
+
`private`.
|
|
26
|
+
|
|
27
|
+
It must live on the app's own page. MI checks portal-page permission before any entity logic
|
|
28
|
+
runs, so a central page holding every app's entity would 403 for the very users the app is
|
|
29
|
+
trying to track.
|
|
30
|
+
3. **Call `init()`.** Nothing else. The table and its columns are created from the first batch
|
|
31
|
+
`ca-analytics` POSTs, and `createRow()` always emits correct types, so there is no
|
|
32
|
+
provisioning step.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { init, useAnalytics } from '@metricinsights/ca-analytics';
|
|
38
|
+
|
|
39
|
+
// app entry, once
|
|
40
|
+
init({ app: 'my-portal-page' });
|
|
41
|
+
|
|
42
|
+
// anywhere in the app
|
|
43
|
+
const { track } = useAnalytics();
|
|
44
|
+
track('tile_clicked', { tile: 'revenue' });
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`useAnalytics()` is a thin wrapper over the module singleton — no provider, no context, callable
|
|
48
|
+
from any component.
|
|
49
|
+
|
|
50
|
+
## API
|
|
51
|
+
|
|
52
|
+
### Lifecycle
|
|
53
|
+
|
|
54
|
+
| Export | Signature | Notes |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `init` | `(options?: Options) => void` | Idempotent; a second call while active is a no-op. Never throws. |
|
|
57
|
+
| `shutdown` | `() => void` | Flushes the buffer, stops every signal, detaches listeners, unpatches `history`. |
|
|
58
|
+
| `isActive` | `() => boolean` | Whether `init()` has run and `shutdown()` hasn't. |
|
|
59
|
+
|
|
60
|
+
`shutdown()` clears in-memory session state only. The session id stays in `localStorage`, so a
|
|
61
|
+
later `init()` inside the 30-minute idle window resumes the *same* `session_id`.
|
|
62
|
+
|
|
63
|
+
### Tracking
|
|
64
|
+
|
|
65
|
+
| Export | Signature | Notes |
|
|
66
|
+
| --- | --- | --- |
|
|
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`. |
|
|
69
|
+
| `trackPageView` | `(path?: string) => void` | For routers the `history` patch cannot observe. Skips dedupe — always emits. |
|
|
70
|
+
| `useAnalytics` | `() => { track, trackElement, trackPageView }` | React hook; no provider needed. |
|
|
71
|
+
|
|
72
|
+
### Constants
|
|
73
|
+
|
|
74
|
+
| Export | Value | Notes |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| `ENTITY_NAME` | `'analytics_events'` | The entity name every app binds to. Use it to build read endpoints (`/data/page/<app>/<ENTITY_NAME>`) instead of hardcoding the string. |
|
|
77
|
+
| `VERSION` | `string` | This package's version, injected from `package.json` at build time. Already stamped on every row as `version`, so readers can segment by build; read it directly for debug output. |
|
|
78
|
+
|
|
79
|
+
### Options
|
|
80
|
+
|
|
81
|
+
Every option is optional. Each falls back to a portal-page variable, then to a default.
|
|
82
|
+
|
|
83
|
+
| Option | Variable | Default | Effect |
|
|
84
|
+
| --- | --- | --- | --- |
|
|
85
|
+
| `app` | — | derived from the URL | Portal page internal name. |
|
|
86
|
+
| `enabled` | `CUSTOM_APP_ANALYTICS_ENABLED` | `true` | `false` disables all tracking. |
|
|
87
|
+
|
|
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
|
+
Portal-page variables are read from `window.PP_VARIABLES` and always arrive as **strings**.
|
|
94
|
+
`'0'`, `'false'`, `'n'`, `'no'` (case-insensitive, trimmed) mean disabled; any other non-empty
|
|
95
|
+
string means enabled. Unset, empty, or left unsubstituted by MI (a literal `[Custom App
|
|
96
|
+
Analytics Enabled]`) counts as *not set* and falls through to the option or default.
|
|
97
|
+
|
|
98
|
+
## Events
|
|
99
|
+
|
|
100
|
+
| Event | Fires when | `meta` |
|
|
101
|
+
| --- | --- | --- |
|
|
102
|
+
| `page_view` | Once on `init()`, then on every `pushState` / `replaceState` / `popstate`, or on demand via `trackPageView()`. | `referrer`, `query` — always both, always strings |
|
|
103
|
+
| `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
|
+
| `element_click` | `trackElement()`. | `element_id`, `segment_id`, plus your own keys |
|
|
106
|
+
| *(custom)* | `track(name, props)`. | your `props` verbatim |
|
|
107
|
+
|
|
108
|
+
The four built-in names are reserved: `track('page_view', …)` from app code is ignored, so
|
|
109
|
+
nothing can forge a row `ca-analytics` produces.
|
|
110
|
+
|
|
111
|
+
Custom event names should match `/^[a-z][a-z0-9_]{0,99}$/` (lowercase snake_case, ≤100 chars). A
|
|
112
|
+
name that does not match is still tracked verbatim but logs one console warning, because the
|
|
113
|
+
dataset is append-only with no rename — `'Tile Click'`, `'tile_click '` and `'tileClick'` would
|
|
114
|
+
become three permanent, unmergeable series. Reserved-name matching is case-insensitive.
|
|
115
|
+
|
|
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
|
|
118
|
+
not use either as your own prop name.
|
|
119
|
+
|
|
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 `''`.
|
|
125
|
+
|
|
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.
|
|
131
|
+
|
|
132
|
+
**`trackElement`** needs `element_id` to be finite and non-zero — anything else drops the event
|
|
133
|
+
(one `console.warn` per runtime). `segment_id` is optional and defaults to `0` when omitted, with
|
|
134
|
+
no warning; a supplied non-finite value also records `0` but logs one `console.warn` per runtime.
|
|
135
|
+
Extra props pass through to `meta` untouched, and
|
|
136
|
+
`element_id`/`segment_id` are written *after* the spread, so a caller cannot overwrite them.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
trackElement({ element_id: 4417, segment_id: 12, label: 'Q3 Revenue' });
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## The row
|
|
143
|
+
|
|
144
|
+
Every event becomes the same nine keys. `owner_user_id` is a tenth stored column the server
|
|
145
|
+
stamps itself — the client never sends it.
|
|
146
|
+
|
|
147
|
+
| Column | Type | Notes |
|
|
148
|
+
| --- | --- | --- |
|
|
149
|
+
| `id` | text | Client UUID. Reused across a retry so readers dedupe with `COUNT(DISTINCT id)`. |
|
|
150
|
+
| `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. |
|
|
152
|
+
| `event` | text | Capped at 100. |
|
|
153
|
+
| `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. |
|
|
155
|
+
| `element_id` | int | `0` when not applicable. Integers only. |
|
|
156
|
+
| `version` | text | This package's version, capped at 20. |
|
|
157
|
+
| `meta` | text | JSON string, capped at 2000. |
|
|
158
|
+
|
|
159
|
+
Text columns are wide (10500) and the caps above are client-side payload budget, keeping a batch
|
|
160
|
+
inside `sendBeacon`'s 64 KiB quota — they are not schema limits.
|
|
161
|
+
|
|
162
|
+
`meta` over 2000 characters drops whole keys rather than slicing, so the stored value always
|
|
163
|
+
parses, and stamps `_truncated: 1`.
|
|
164
|
+
|
|
165
|
+
## Delivery
|
|
166
|
+
|
|
167
|
+
Rows buffer and flush on whichever comes first: 15 seconds, 50 rows, or 30 KB encoded. On
|
|
168
|
+
`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 })`.
|
|
170
|
+
|
|
171
|
+
A failed batch retries once after 2 seconds — network errors, 429 and 5xx are retryable; a `200`
|
|
172
|
+
carrying a non-zero `resultCode` is a rejected insert and is not. A failed batch is dropped, not
|
|
173
|
+
re-buffered, since re-sending a batch whose response was merely lost is what creates duplicates.
|
|
174
|
+
|
|
175
|
+
Three consecutive failed flushes trip a kill-switch that tears everything down — timers,
|
|
176
|
+
listeners, history patch — because every row after that point would be discarded anyway.
|
|
177
|
+
|
|
178
|
+
Nothing here throws into the host app. Every public entry point and every host callback is
|
|
179
|
+
wrapped.
|
|
180
|
+
|
|
181
|
+
## One-way doors
|
|
182
|
+
|
|
183
|
+
> Each of these is unrecoverable in place.
|
|
184
|
+
>
|
|
185
|
+
> - **Never upload a CSV/Excel file to the `analytics_events` dataset.** A Collect run against a
|
|
186
|
+
> manual dataset that has never had a file uploaded fails early and leaves the table alone. A
|
|
187
|
+
> dataset that already has columns instead drops its table on a zero-row fetch.
|
|
188
|
+
> - **Never add a `UNIQUE` index, least of all on `id`.** The server does not deduplicate. Under a
|
|
189
|
+
> unique constraint a duplicate raises an integrity violation that kills the *entire* batch,
|
|
190
|
+
> good rows included. Retries deliberately reuse the same `id`, and readers are expected to
|
|
191
|
+
> dedupe with `COUNT(DISTINCT id)`.
|
|
192
|
+
> - **Never send `ts` as ISO 8601.** MI infers column types from the first payload. A `T`/`Z`
|
|
193
|
+
> timestamp is read as text and the column is text forever; the space-separated form is what
|
|
194
|
+
> produces a real `datetime`.
|
|
195
|
+
> - **Never put a non-integer in `element_id`.** Inferred types widen and never narrow
|
|
196
|
+
> (`int → float → text`). One float or string converts the column permanently.
|
|
197
|
+
> - **Never send `owner_user_id`.** The server stamps it and creates the column itself.
|
|
198
|
+
|
|
199
|
+
## License
|
|
200
|
+
|
|
201
|
+
MIT © 2026 Metric Insights
|