@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 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