@naturalcycles/internal-web-lib 1.0.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/bundle/analyticsClient.js +1334 -0
- package/bundle/analyticsClient.js.map +1 -0
- package/dist/analytics/analyticsClient.d.ts +356 -0
- package/dist/analytics/analyticsClient.js +828 -0
- package/dist/analytics/index.d.ts +1 -0
- package/dist/analytics/index.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +54 -0
- package/readme.md +81 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import type { FirstTouchUtms, SetOnceUserProperties } from '@naturalcycles/js-lib/analytics';
|
|
2
|
+
import { AppError } from '@naturalcycles/js-lib/error';
|
|
3
|
+
import type { ErrorData } from '@naturalcycles/js-lib/error';
|
|
4
|
+
import type { CommonLogger } from '@naturalcycles/js-lib/log';
|
|
5
|
+
import type { AnyObject, NumberOfMilliseconds, PositiveInteger, UnixTimestampMillis } from '@naturalcycles/js-lib/types';
|
|
6
|
+
import type { AnalyticsEventListener, ClientId, MixpanelDistinctId } from '@naturalcycles/shared';
|
|
7
|
+
export type { AnalyticsEventListener } from '@naturalcycles/shared';
|
|
8
|
+
/**
|
|
9
|
+
* Self-hosted analytics event client.
|
|
10
|
+
*
|
|
11
|
+
* Captures client-side analytics events, adds browser page properties, batches them, retries
|
|
12
|
+
* transient failures and delivers them to our own Backend via a single REST endpoint.
|
|
13
|
+
*/
|
|
14
|
+
export declare class AnalyticsClient implements AnalyticsClientApi {
|
|
15
|
+
constructor(cfg: AnalyticsClientCfg);
|
|
16
|
+
private cfg;
|
|
17
|
+
private firstTouchFetcher;
|
|
18
|
+
/**
|
|
19
|
+
* The built-in identity, constructed from `cfg.identity`. Also readable by application code,
|
|
20
|
+
* e.g to feed the distinctId or acquisition props to other integrations.
|
|
21
|
+
*/
|
|
22
|
+
readonly identity: AnalyticsIdentity;
|
|
23
|
+
/**
|
|
24
|
+
* Random id of this AnalyticsClient instance (in practice - of this tab/pageload),
|
|
25
|
+
* used to namespace the localStorage queue snapshot.
|
|
26
|
+
*/
|
|
27
|
+
private tabId;
|
|
28
|
+
private queue;
|
|
29
|
+
private flushTimer?;
|
|
30
|
+
private isFlushing;
|
|
31
|
+
private consecutiveFailures;
|
|
32
|
+
/**
|
|
33
|
+
* Requested by the server via `Retry-After` header (already converted to ms).
|
|
34
|
+
*/
|
|
35
|
+
private retryAfter;
|
|
36
|
+
private hasUpdatedAcquisitionProps;
|
|
37
|
+
private readonly eventListeners;
|
|
38
|
+
private handlePageHide;
|
|
39
|
+
private handleVisibilityChange;
|
|
40
|
+
/**
|
|
41
|
+
* Optional eager bootstrapping, to call once at app boot: registers the acquisition props
|
|
42
|
+
* into the identity entry while the landing url/referrer are still current - the moment
|
|
43
|
+
* an SPA navigation can strip the utm params off the url before the first event fires. Gated by cfg.isEnabled, like the events themselves.
|
|
44
|
+
* Without it the registration happens lazily before the first enabled event.
|
|
45
|
+
*/
|
|
46
|
+
init(): void;
|
|
47
|
+
track(name: string, props?: AnyObject): void;
|
|
48
|
+
/**
|
|
49
|
+
* Replays the calls a stub recorded on the page before this client loaded, under the timestamps
|
|
50
|
+
* they were made at. Tracked in order, so calls made before the stub's `identify()` keep the
|
|
51
|
+
* anonymous identity.
|
|
52
|
+
*/
|
|
53
|
+
private replayFromStub;
|
|
54
|
+
/**
|
|
55
|
+
* Registers a listener called synchronously for every delivered event, past the gates in
|
|
56
|
+
* `track()`. Returns an unsubscribe function. Listeners receive a snapshot and cannot affect
|
|
57
|
+
* delivery, and a throwing listener never breaks tracking.
|
|
58
|
+
*/
|
|
59
|
+
onEvent(listener: AnalyticsEventListener): () => void;
|
|
60
|
+
private notifyEventListeners;
|
|
61
|
+
/**
|
|
62
|
+
* Sets the distinctId going forward (persisted), e.g after a successful signup.
|
|
63
|
+
* Pending events are kept, each under the identity it was tracked with.
|
|
64
|
+
*/
|
|
65
|
+
identify(userId: string): void;
|
|
66
|
+
private enqueue;
|
|
67
|
+
private canTrack;
|
|
68
|
+
/**
|
|
69
|
+
* Clears pending events and the persisted identity entry, including its acquisition
|
|
70
|
+
* properties. A new identity is generated on next use.
|
|
71
|
+
*/
|
|
72
|
+
reset(): void;
|
|
73
|
+
/**
|
|
74
|
+
* Drains the queue, one batch per request.
|
|
75
|
+
* Called automatically (flush interval / batch size / pagehide) - public for manual flushing.
|
|
76
|
+
*/
|
|
77
|
+
flush(): Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* Immediate flush: hands queued events over to the browser via keepalive fetch.
|
|
80
|
+
* Runs automatically on pagehide / visibilitychange:hidden. Public so that app code can
|
|
81
|
+
* hand events over right away: events tracked in its own page-lifecycle handlers (which
|
|
82
|
+
* run after this client's own), and events tracked right before a navigation (e.g a
|
|
83
|
+
* cta click) - unload-time delivery is best-effort and can be lost, while a
|
|
84
|
+
* keepalive request from a still-alive page survives the navigation.
|
|
85
|
+
* The persisted queue remains untouched because keepalive requests cannot reliably process
|
|
86
|
+
* their response; a later regular flush re-sends and deduplicates the events.
|
|
87
|
+
*/
|
|
88
|
+
flushNow(): void;
|
|
89
|
+
/**
|
|
90
|
+
* Removes listeners and pending timers. Only needed when an instance is discarded
|
|
91
|
+
* (e.g in tests or HMR) - the app-wide singleton never needs it.
|
|
92
|
+
*/
|
|
93
|
+
destroy(): void;
|
|
94
|
+
/**
|
|
95
|
+
* Registers the acquisition props into the identity entry once per pageload, like
|
|
96
|
+
* eagerly from init(), or lazily before the first
|
|
97
|
+
* enabled event. Either path is gated by cfg.isEnabled, so bot/e2e/consent gating
|
|
98
|
+
* applies to the persistence write too.
|
|
99
|
+
*/
|
|
100
|
+
private ensureAcquisitionProps;
|
|
101
|
+
/**
|
|
102
|
+
* Posts the first-touch props to their own endpoint, once per pageload. Fire-and-forget:
|
|
103
|
+
* `$set_once` ignores every write after the first, so a lost or repeated call costs nothing.
|
|
104
|
+
*/
|
|
105
|
+
private sendFirstTouch;
|
|
106
|
+
private getDefaultProps;
|
|
107
|
+
private clearPendingEvents;
|
|
108
|
+
private sendBatch;
|
|
109
|
+
private postBatch;
|
|
110
|
+
/**
|
|
111
|
+
* Splits the whole queue into request-ready batches of at most maxBatchSize events
|
|
112
|
+
* and maxBatchBytes serialized bytes each.
|
|
113
|
+
*/
|
|
114
|
+
private prepareBatches;
|
|
115
|
+
private createPendingBatch;
|
|
116
|
+
private finalizeBatch;
|
|
117
|
+
/**
|
|
118
|
+
* Adopts queue snapshots persisted by previous pageloads (crashed/killed tabs) and re-sends them.
|
|
119
|
+
* A snapshot of a still-alive tab may be adopted too - the resulting duplicate delivery
|
|
120
|
+
* is deduped by the Backend via event ids.
|
|
121
|
+
*/
|
|
122
|
+
private restoreOrphanedQueues;
|
|
123
|
+
private scheduleFlush;
|
|
124
|
+
private clearFlushTimer;
|
|
125
|
+
private removeFromQueue;
|
|
126
|
+
private persistQueue;
|
|
127
|
+
private get queueKey();
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Distinct-id generation and persistence:
|
|
131
|
+
*
|
|
132
|
+
* - a new identity is a UUID v4 device id, stored as `$device_id`,
|
|
133
|
+
* with `distinct_id` derived from it by `generateDistinctId`
|
|
134
|
+
* - `identify(userId)` sets `distinct_id` and `user_id`, keeping `$device_id` (same person)
|
|
135
|
+
* - `reset()` clears the whole entry and generates a fresh anonymous identity (unrelated person)
|
|
136
|
+
* - the identity is stored as a JSON object under a single cookie / localStorage name
|
|
137
|
+
*
|
|
138
|
+
* Apps sharing a persistenceName and cookie domain read and write the same identity:
|
|
139
|
+
* whichever writes first, the others adopt it. Properties they keep in the same entry
|
|
140
|
+
* are preserved on write, never interpreted.
|
|
141
|
+
*
|
|
142
|
+
* Storage failures (SSR, blocked cookies/localStorage) degrade to an in-memory session-scoped
|
|
143
|
+
* identity - analytics must never break the app.
|
|
144
|
+
*/
|
|
145
|
+
export declare class AnalyticsIdentity {
|
|
146
|
+
constructor(cfg: AnalyticsIdentityCfg);
|
|
147
|
+
private cfg;
|
|
148
|
+
/**
|
|
149
|
+
* Fallback identity for when storage is unavailable, and the last-known-good copy
|
|
150
|
+
* if storage becomes unreadable later.
|
|
151
|
+
*/
|
|
152
|
+
private memoryEntry;
|
|
153
|
+
private hasRefreshedExpiry;
|
|
154
|
+
getDistinctId(): MixpanelDistinctId;
|
|
155
|
+
/**
|
|
156
|
+
* The bare (unprefixed) device UUID. Undefined for legacy identities persisted before
|
|
157
|
+
* a device id was stored (their `distinct_id` has no device prefix either).
|
|
158
|
+
*/
|
|
159
|
+
getDeviceId(): string | undefined;
|
|
160
|
+
/**
|
|
161
|
+
* Reads any property of the persisted entry: the acquisition props maintained by
|
|
162
|
+
* updateAcquisitionProps(), or props written by another app sharing the entry.
|
|
163
|
+
*/
|
|
164
|
+
getProperty(key: string): unknown;
|
|
165
|
+
/**
|
|
166
|
+
* The acquisition props that can't be derived from event payloads, and are stored separately.
|
|
167
|
+
*/
|
|
168
|
+
getAcquisitionProps(): AnyObject;
|
|
169
|
+
/** The captured first touch, as the props to `$set_once` on the profile. */
|
|
170
|
+
getFirstTouchProps(): SetOnceUserProperties | undefined;
|
|
171
|
+
/**
|
|
172
|
+
* Sets the identity going forward, e.g after a successful signup/login.
|
|
173
|
+
* `$device_id` is kept, so the destination can merge the pre-identify
|
|
174
|
+
* anonymous events into the same user.
|
|
175
|
+
*/
|
|
176
|
+
identify(userId: string): void;
|
|
177
|
+
/**
|
|
178
|
+
* Clears the whole persisted entry and generates a fresh anonymous identity.
|
|
179
|
+
* Call only when switching to an UNRELATED identity (e.g logout): everything else stored
|
|
180
|
+
* in the entry may belong to the previous user, so it goes too.
|
|
181
|
+
*/
|
|
182
|
+
reset(): void;
|
|
183
|
+
/** Collects properties of the user that can be used to attribute traffic. */
|
|
184
|
+
updateAcquisitionProps(): void;
|
|
185
|
+
private ensureIdentity;
|
|
186
|
+
/**
|
|
187
|
+
* The cookie expiration window slides: expireDays counts from the LAST visit, not the
|
|
188
|
+
* first. Re-saved once per instance (in practice - once per pageload).
|
|
189
|
+
*/
|
|
190
|
+
private refreshExpiry;
|
|
191
|
+
private loadEntry;
|
|
192
|
+
private saveEntry;
|
|
193
|
+
private cookieLength;
|
|
194
|
+
private reportError;
|
|
195
|
+
}
|
|
196
|
+
/** The first visible value under this name, or null. */
|
|
197
|
+
export declare function getCookie(name: string): string | null;
|
|
198
|
+
/**
|
|
199
|
+
* Writes the cookie and returns what was written, which the tests assert on.
|
|
200
|
+
* `domain` is explicit, e.g `.example.com` to share it across subdomains.
|
|
201
|
+
*/
|
|
202
|
+
export declare function setCookie(name: string, value: string, days: PositiveInteger, domain: string, isSecure: boolean): string;
|
|
203
|
+
export declare class AnalyticsClientError extends AppError {
|
|
204
|
+
constructor(message: string, data?: ErrorData);
|
|
205
|
+
}
|
|
206
|
+
/** Listener called for every delivered event. */
|
|
207
|
+
/**
|
|
208
|
+
* A call recorded by an inline stub before the client loaded, e.g
|
|
209
|
+
* `{ method: 'track', args: ['Click', { element: 'cta' }], ts: Date.now() }`.
|
|
210
|
+
*/
|
|
211
|
+
declare global {
|
|
212
|
+
var analyticsClient: AnalyticsClient | AnalyticsClientStub | undefined;
|
|
213
|
+
}
|
|
214
|
+
/** The stub a page assigns to `globalThis.analyticsClient` before the client loads. */
|
|
215
|
+
export interface AnalyticsClientStub extends AnalyticsClientApi {
|
|
216
|
+
q: StubbedCall[];
|
|
217
|
+
}
|
|
218
|
+
/** What a page can call, on the loaded client or on a stub standing in for it. */
|
|
219
|
+
export interface AnalyticsClientApi {
|
|
220
|
+
init: () => void;
|
|
221
|
+
track: (name: string, props?: AnyObject) => void;
|
|
222
|
+
identify: (userId: string) => void;
|
|
223
|
+
onEvent: (listener: AnalyticsEventListener) => () => void;
|
|
224
|
+
reset: () => void;
|
|
225
|
+
flushNow: () => void;
|
|
226
|
+
destroy: () => void;
|
|
227
|
+
}
|
|
228
|
+
export type StubbedCall = {
|
|
229
|
+
method: 'track';
|
|
230
|
+
args: [name: string, props?: AnyObject];
|
|
231
|
+
ts: UnixTimestampMillis;
|
|
232
|
+
} | {
|
|
233
|
+
method: 'identify';
|
|
234
|
+
args: [userId: string];
|
|
235
|
+
ts: UnixTimestampMillis;
|
|
236
|
+
};
|
|
237
|
+
export interface AnalyticsClientCfg {
|
|
238
|
+
/**
|
|
239
|
+
* Full url of the ingestion endpoint, e.g `https://api.example.com/web/e`.
|
|
240
|
+
*/
|
|
241
|
+
url: string;
|
|
242
|
+
/**
|
|
243
|
+
* Full url of the first-touch endpoint, e.g `https://api.example.com/web/ft`.
|
|
244
|
+
* Omit to not send first-touch profile props at all.
|
|
245
|
+
*/
|
|
246
|
+
firstTouchUrl?: string;
|
|
247
|
+
clientId: ClientId;
|
|
248
|
+
/**
|
|
249
|
+
* Evaluated on every init(), track() and identify() call; return false to gate them all.
|
|
250
|
+
* Use it for bot/e2e/consent gating.
|
|
251
|
+
* Defaults to always-enabled (tracking is still client-side only).
|
|
252
|
+
*/
|
|
253
|
+
isEnabled?: () => boolean;
|
|
254
|
+
/**
|
|
255
|
+
* Props merged into every event, evaluated at track() time. Per-call props win.
|
|
256
|
+
*/
|
|
257
|
+
getCommonProps?: () => AnyObject;
|
|
258
|
+
/**
|
|
259
|
+
* Cfg of the built-in identity (see AnalyticsIdentity), constructed by the client and
|
|
260
|
+
* exposed as `client.identity`.
|
|
261
|
+
* Use cookie persistence to share the identity across subdomains.
|
|
262
|
+
*/
|
|
263
|
+
identity: AnalyticsIdentityCfg;
|
|
264
|
+
/**
|
|
265
|
+
* How often the queue is flushed.
|
|
266
|
+
* Default 5000.
|
|
267
|
+
*/
|
|
268
|
+
flushInterval?: NumberOfMilliseconds;
|
|
269
|
+
/**
|
|
270
|
+
* Max events per request. The queue also flushes early when it's reached.
|
|
271
|
+
* Default 50.
|
|
272
|
+
*/
|
|
273
|
+
maxBatchSize?: PositiveInteger;
|
|
274
|
+
/**
|
|
275
|
+
* Max serialized request body size. Default 60_000, leaving headroom below
|
|
276
|
+
* the browser keepalive request limit of 65_536 bytes.
|
|
277
|
+
*/
|
|
278
|
+
maxBatchBytes?: PositiveInteger;
|
|
279
|
+
/**
|
|
280
|
+
* Max events held in the queue while the endpoint is unreachable.
|
|
281
|
+
* Oldest events are dropped beyond it. Default 1000.
|
|
282
|
+
*/
|
|
283
|
+
maxQueueSize?: PositiveInteger;
|
|
284
|
+
/**
|
|
285
|
+
* Cap for the exponential retry backoff. Default 10 minutes.
|
|
286
|
+
*/
|
|
287
|
+
maxRetryBackoff?: NumberOfMilliseconds;
|
|
288
|
+
/**
|
|
289
|
+
* Per-request timeout. Default 30_000.
|
|
290
|
+
*/
|
|
291
|
+
requestTimeout?: NumberOfMilliseconds;
|
|
292
|
+
/**
|
|
293
|
+
* Persist unsent events in localStorage and restore them on the next page load,
|
|
294
|
+
* so events survive crashes/killed tabs. There is no cross-tab locking - dedupe by
|
|
295
|
+
* event id makes duplicates harmless.
|
|
296
|
+
* Default true.
|
|
297
|
+
*/
|
|
298
|
+
persistQueue?: boolean;
|
|
299
|
+
/**
|
|
300
|
+
* Max age of persisted events to restore; older ones are discarded. Default 24 hours.
|
|
301
|
+
*/
|
|
302
|
+
maxPersistedAge?: NumberOfMilliseconds;
|
|
303
|
+
/**
|
|
304
|
+
* localStorage key prefix for the queue snapshots. Default 'nca'.
|
|
305
|
+
*/
|
|
306
|
+
localStorageKeyPrefix?: string;
|
|
307
|
+
/**
|
|
308
|
+
* Default `console`.
|
|
309
|
+
*/
|
|
310
|
+
logger?: CommonLogger;
|
|
311
|
+
/**
|
|
312
|
+
* Log every tracked event. Default false.
|
|
313
|
+
*/
|
|
314
|
+
debug?: boolean;
|
|
315
|
+
/** Called on errors related to tracking events. */
|
|
316
|
+
onError?: (err: unknown) => void;
|
|
317
|
+
}
|
|
318
|
+
export interface AnalyticsIdentityCfg {
|
|
319
|
+
/**
|
|
320
|
+
* Where the identity is persisted:
|
|
321
|
+
* 'cookie' - shareable across subdomains via `cookieDomain`,
|
|
322
|
+
* 'localStorage' - per-origin, for when cross-(sub)domain sharing is not needed.
|
|
323
|
+
*/
|
|
324
|
+
persistence: 'cookie' | 'localStorage';
|
|
325
|
+
/**
|
|
326
|
+
* Cookie name / localStorage key. Apps sharing an identity must use the same one,
|
|
327
|
+
* and must rename in lockstep, otherwise their identities diverge on the next
|
|
328
|
+
* identify()/reset().
|
|
329
|
+
*/
|
|
330
|
+
persistenceKey: string;
|
|
331
|
+
/**
|
|
332
|
+
* Explicit cookie domain, e.g `.example.com` to share the identity across subdomains.
|
|
333
|
+
* Default '' - a host-only cookie.
|
|
334
|
+
*/
|
|
335
|
+
cookieDomain?: string;
|
|
336
|
+
/**
|
|
337
|
+
* Cookie lifetime in days, sliding: re-saved on first use of each pageload. Default 365.
|
|
338
|
+
*/
|
|
339
|
+
expireDays?: PositiveInteger;
|
|
340
|
+
/**
|
|
341
|
+
* Sets the `secure` cookie attribute. Default false.
|
|
342
|
+
*/
|
|
343
|
+
secureCookie?: boolean;
|
|
344
|
+
/**
|
|
345
|
+
* Builds the distinct id of a new anonymous identity from its generated device id.
|
|
346
|
+
* Apps sharing an identity must use the same one, otherwise their identities diverge.
|
|
347
|
+
*/
|
|
348
|
+
generateDistinctId?: (deviceId: string) => string;
|
|
349
|
+
/** Called on errors related to persisting the identity. */
|
|
350
|
+
onError?: (err: unknown) => void;
|
|
351
|
+
}
|
|
352
|
+
/** Analytics properties we only persist once and don't set again. */
|
|
353
|
+
export interface FirstTouch {
|
|
354
|
+
referrer: string | null;
|
|
355
|
+
utms?: FirstTouchUtms;
|
|
356
|
+
}
|