@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.
@@ -0,0 +1,828 @@
1
+ import { _isEmptyObject, isServerSide } from '@naturalcycles/js-lib';
2
+ import { CANONICAL_UTM_PARAMS, ANALYTICS_IDENTIFY_EVENT_NAME, truncateAnalyticsProperty, } from '@naturalcycles/js-lib/analytics';
3
+ import { _mapToObject } from '@naturalcycles/js-lib/array';
4
+ import { _errorDataAppend, AppError } from '@naturalcycles/js-lib/error';
5
+ import { getFetcher } from '@naturalcycles/js-lib/http';
6
+ import { nanoidBrowser, nanoidBrowserCustomAlphabet } from '@naturalcycles/js-lib/nanoid';
7
+ import { _filterNullishValues, _filterObject } from '@naturalcycles/js-lib/object';
8
+ import { _safeJsonStringify } from '@naturalcycles/js-lib/string';
9
+ import { _noop } from '@naturalcycles/js-lib/types';
10
+ /**
11
+ * Browsers drop a cookie over 4096 bytes, and the identity goes with it. The 3800 budget also
12
+ * has to cover the `; expires=`, `; path=`, `; domain=` and `; secure` attributes that setCookie
13
+ * appends (roughly 75 bytes), which cookieLength below does not measure. Keep that in mind
14
+ * before raising this toward 4096.
15
+ */
16
+ const MAX_COOKIE_LENGTH = 3800;
17
+ const UTM_QUERY_PARAM_PREFIX = 'utm_';
18
+ const CLICK_QUERY_PARAMS = [
19
+ 'dclid',
20
+ 'fbclid',
21
+ 'gclid',
22
+ 'ko_click_id',
23
+ 'li_fat_id',
24
+ 'msclkid',
25
+ 'sccid',
26
+ 'ttclid',
27
+ 'twclid',
28
+ 'wbraid',
29
+ ];
30
+ const generateEventId = nanoidBrowserCustomAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 16);
31
+ /**
32
+ * Self-hosted analytics event client.
33
+ *
34
+ * Captures client-side analytics events, adds browser page properties, batches them, retries
35
+ * transient failures and delivers them to our own Backend via a single REST endpoint.
36
+ */
37
+ export class AnalyticsClient {
38
+ constructor(cfg) {
39
+ const localStorageKeyPrefix = cfg.localStorageKeyPrefix || 'nca';
40
+ this.cfg = {
41
+ onError: _noop,
42
+ firstTouchUrl: '',
43
+ isEnabled: () => true,
44
+ getCommonProps: () => ({}),
45
+ flushInterval: 5000,
46
+ maxBatchSize: 50,
47
+ maxBatchBytes: 60_000,
48
+ maxQueueSize: 1000,
49
+ maxRetryBackoff: 600_000,
50
+ requestTimeout: 30_000,
51
+ persistQueue: true,
52
+ maxPersistedAge: 24 * 3_600_000,
53
+ localStorageKeyPrefix,
54
+ logger: console,
55
+ debug: false,
56
+ ...cfg,
57
+ };
58
+ this.identity = new AnalyticsIdentity(this.cfg.identity);
59
+ // Unlike events, we only fire firstTouch once per pageload, so
60
+ // we care about and can tolerate retries
61
+ this.firstTouchFetcher = getFetcher({
62
+ logger: this.cfg.logger,
63
+ retryPost: true,
64
+ timeoutSeconds: 10,
65
+ });
66
+ if (isServerSide())
67
+ return;
68
+ this.restoreOrphanedQueues();
69
+ globalThis.addEventListener('pagehide', this.handlePageHide);
70
+ document.addEventListener('visibilitychange', this.handleVisibilityChange);
71
+ }
72
+ cfg;
73
+ firstTouchFetcher;
74
+ /**
75
+ * The built-in identity, constructed from `cfg.identity`. Also readable by application code,
76
+ * e.g to feed the distinctId or acquisition props to other integrations.
77
+ */
78
+ identity;
79
+ /**
80
+ * Random id of this AnalyticsClient instance (in practice - of this tab/pageload),
81
+ * used to namespace the localStorage queue snapshot.
82
+ */
83
+ tabId = nanoidBrowser(10);
84
+ queue = [];
85
+ flushTimer;
86
+ isFlushing = false;
87
+ consecutiveFailures = 0;
88
+ /**
89
+ * Requested by the server via `Retry-After` header (already converted to ms).
90
+ */
91
+ retryAfter = 0;
92
+ hasUpdatedAcquisitionProps = false;
93
+ eventListeners = new Set();
94
+ handlePageHide = () => {
95
+ this.flushNow();
96
+ };
97
+ handleVisibilityChange = () => {
98
+ // Fires on mobile when the tab is backgrounded (where pagehide may never fire) -
99
+ // the last reliable moment to hand events over to the browser
100
+ if (document.visibilityState === 'hidden')
101
+ this.flushNow();
102
+ };
103
+ /**
104
+ * Optional eager bootstrapping, to call once at app boot: registers the acquisition props
105
+ * into the identity entry while the landing url/referrer are still current - the moment
106
+ * an SPA navigation can strip the utm params off the url before the first event fires. Gated by cfg.isEnabled, like the events themselves.
107
+ * Without it the registration happens lazily before the first enabled event.
108
+ */
109
+ init() {
110
+ if (!this.canTrack())
111
+ return;
112
+ this.ensureAcquisitionProps();
113
+ this.replayFromStub();
114
+ }
115
+ track(name, props) {
116
+ const event = this.enqueue(name, props);
117
+ if (event)
118
+ this.notifyEventListeners(event);
119
+ }
120
+ /**
121
+ * Replays the calls a stub recorded on the page before this client loaded, under the timestamps
122
+ * they were made at. Tracked in order, so calls made before the stub's `identify()` keep the
123
+ * anonymous identity.
124
+ */
125
+ replayFromStub() {
126
+ const stub = globalThis.analyticsClient;
127
+ if (!stub || !('q' in stub))
128
+ return;
129
+ for (const call of stub.q) {
130
+ try {
131
+ if (call.method === 'identify') {
132
+ this.identify(call.args[0]);
133
+ }
134
+ else {
135
+ // Not track(), so the call keeps the timestamp it was originally made at
136
+ const event = this.enqueue(call.args[0], call.args[1], call.ts);
137
+ if (event)
138
+ this.notifyEventListeners(event);
139
+ }
140
+ }
141
+ catch (err) {
142
+ this.cfg.logger.warn('[analytics] could not replay a stubbed call', err);
143
+ }
144
+ }
145
+ stub.q.length = 0;
146
+ }
147
+ /**
148
+ * Registers a listener called synchronously for every delivered event, past the gates in
149
+ * `track()`. Returns an unsubscribe function. Listeners receive a snapshot and cannot affect
150
+ * delivery, and a throwing listener never breaks tracking.
151
+ */
152
+ onEvent(listener) {
153
+ this.eventListeners.add(listener);
154
+ return () => this.eventListeners.delete(listener);
155
+ }
156
+ notifyEventListeners(event) {
157
+ if (!this.eventListeners.size)
158
+ return;
159
+ const distinctId = event.userId || this.identity.getDistinctId();
160
+ // Snapshot so a listener cannot mutate the event already queued for delivery. Shallow because
161
+ // props may hold circular references (see track()) that a deep JSON copy would throw on.
162
+ const snapshot = { ...event, props: { ...event.props } };
163
+ for (const listener of this.eventListeners) {
164
+ try {
165
+ listener(snapshot, distinctId);
166
+ }
167
+ catch (err) {
168
+ this.cfg.logger.warn('[analytics] onEvent listener threw', err);
169
+ }
170
+ }
171
+ }
172
+ /**
173
+ * Sets the distinctId going forward (persisted), e.g after a successful signup.
174
+ * Pending events are kept, each under the identity it was tracked with.
175
+ */
176
+ identify(userId) {
177
+ if (!this.canTrack())
178
+ return;
179
+ const previousDistinctId = this.identity.getDistinctId();
180
+ this.identity.identify(userId);
181
+ const distinctId = userId;
182
+ if (previousDistinctId === distinctId)
183
+ return;
184
+ this.enqueue(ANALYTICS_IDENTIFY_EVENT_NAME, { anon_distinct_id: previousDistinctId });
185
+ void this.flush();
186
+ // The destination merges the event streams of the two ids, but not their profiles
187
+ void this.sendFirstTouch();
188
+ }
189
+ enqueue(name, props, ts) {
190
+ if (!this.canTrack())
191
+ return;
192
+ this.ensureAcquisitionProps();
193
+ // Deliberately no client-side normalization: the server owns length limits, while
194
+ // circular references are handled at serialization time by _safeJsonStringify.
195
+ const event = {
196
+ id: generateEventId(),
197
+ name,
198
+ ts: ts || Date.now(),
199
+ props: {
200
+ ...this.getDefaultProps(),
201
+ ...this.cfg.getCommonProps(),
202
+ ...props,
203
+ },
204
+ userId: this.identity.getDistinctId(),
205
+ };
206
+ if (this.cfg.debug)
207
+ this.cfg.logger.log(`[analytics] ${event.name}`, event.props);
208
+ this.queue.push(event);
209
+ if (this.queue.length > this.cfg.maxQueueSize) {
210
+ this.queue.shift();
211
+ this.cfg.logger.warn('[analytics] queue overflow, dropped the oldest event');
212
+ }
213
+ this.persistQueue();
214
+ if (this.queue.length >= this.cfg.maxBatchSize && !this.consecutiveFailures) {
215
+ void this.flush();
216
+ }
217
+ else {
218
+ this.scheduleFlush();
219
+ }
220
+ return event;
221
+ }
222
+ canTrack() {
223
+ return !isServerSide() && this.cfg.isEnabled();
224
+ }
225
+ /**
226
+ * Clears pending events and the persisted identity entry, including its acquisition
227
+ * properties. A new identity is generated on next use.
228
+ */
229
+ reset() {
230
+ this.clearPendingEvents();
231
+ this.identity.reset();
232
+ // The entry was wiped - re-register the acquisition props on the next tracked event
233
+ this.hasUpdatedAcquisitionProps = false;
234
+ }
235
+ /**
236
+ * Drains the queue, one batch per request.
237
+ * Called automatically (flush interval / batch size / pagehide) - public for manual flushing.
238
+ */
239
+ async flush() {
240
+ if (isServerSide() || this.isFlushing)
241
+ return;
242
+ this.isFlushing = true;
243
+ this.clearFlushTimer();
244
+ try {
245
+ // The outer loop picks up events tracked while a batch was in-flight
246
+ while (this.queue.length) {
247
+ for (const batch of this.prepareBatches()) {
248
+ const result = await this.sendBatch(batch);
249
+ if (result === 'retry') {
250
+ this.consecutiveFailures++;
251
+ this.scheduleFlush();
252
+ return;
253
+ }
254
+ // 'ok' or 'drop' - the batch is done either way
255
+ this.removeFromQueue(batch.events);
256
+ this.consecutiveFailures = 0;
257
+ this.retryAfter = 0;
258
+ }
259
+ }
260
+ }
261
+ finally {
262
+ this.isFlushing = false;
263
+ }
264
+ }
265
+ /**
266
+ * Immediate flush: hands queued events over to the browser via keepalive fetch.
267
+ * Runs automatically on pagehide / visibilitychange:hidden. Public so that app code can
268
+ * hand events over right away: events tracked in its own page-lifecycle handlers (which
269
+ * run after this client's own), and events tracked right before a navigation (e.g a
270
+ * cta click) - unload-time delivery is best-effort and can be lost, while a
271
+ * keepalive request from a still-alive page survives the navigation.
272
+ * The persisted queue remains untouched because keepalive requests cannot reliably process
273
+ * their response; a later regular flush re-sends and deduplicates the events.
274
+ */
275
+ flushNow() {
276
+ if (!this.queue.length)
277
+ return;
278
+ this.clearFlushTimer();
279
+ for (const batch of this.prepareBatches()) {
280
+ void this.postBatch(batch, true).catch(err => {
281
+ this.cfg.logger.warn('[analytics] lifecycle batch failed to send; retained for retry', err);
282
+ });
283
+ }
284
+ // visibilitychange can fire without unloading the page, so retain normal retry behavior.
285
+ this.scheduleFlush();
286
+ }
287
+ /**
288
+ * Removes listeners and pending timers. Only needed when an instance is discarded
289
+ * (e.g in tests or HMR) - the app-wide singleton never needs it.
290
+ */
291
+ destroy() {
292
+ this.clearFlushTimer();
293
+ if (isServerSide())
294
+ return;
295
+ globalThis.removeEventListener('pagehide', this.handlePageHide);
296
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange);
297
+ }
298
+ /**
299
+ * Registers the acquisition props into the identity entry once per pageload, like
300
+ * eagerly from init(), or lazily before the first
301
+ * enabled event. Either path is gated by cfg.isEnabled, so bot/e2e/consent gating
302
+ * applies to the persistence write too.
303
+ */
304
+ ensureAcquisitionProps() {
305
+ if (this.hasUpdatedAcquisitionProps)
306
+ return;
307
+ this.hasUpdatedAcquisitionProps = true;
308
+ this.identity.updateAcquisitionProps();
309
+ void this.sendFirstTouch();
310
+ }
311
+ /**
312
+ * Posts the first-touch props to their own endpoint, once per pageload. Fire-and-forget:
313
+ * `$set_once` ignores every write after the first, so a lost or repeated call costs nothing.
314
+ */
315
+ async sendFirstTouch() {
316
+ const { firstTouchUrl } = this.cfg;
317
+ if (!firstTouchUrl)
318
+ return;
319
+ const props = this.identity.getFirstTouchProps();
320
+ if (!props)
321
+ return;
322
+ const input = {
323
+ clientId: this.cfg.clientId,
324
+ userId: this.identity.getDistinctId(),
325
+ props,
326
+ };
327
+ const res = await this.firstTouchFetcher.doFetch({
328
+ url: firstTouchUrl,
329
+ method: 'POST',
330
+ text: _safeJsonStringify(input),
331
+ responseType: 'void',
332
+ });
333
+ if (!res.err)
334
+ return;
335
+ try {
336
+ this.cfg.onError(_errorDataAppend(res.err, { firstTouch: true }));
337
+ }
338
+ catch (err) {
339
+ this.cfg.logger.warn('[analytics] onError hook threw', err);
340
+ }
341
+ }
342
+ getDefaultProps() {
343
+ const referrer = document.referrer;
344
+ const url = new URL(globalThis.location.href);
345
+ return {
346
+ ...(referrer && { referrer }),
347
+ current_url: globalThis.location.href,
348
+ screen_height: globalThis.screen.height,
349
+ screen_width: globalThis.screen.width,
350
+ // First-touch initial_referrer and last-touch utm_* come from the identity entry -
351
+ // the single acquisition-props store (cross-subdomain when cookie-persisted)
352
+ ...this.identity.getAcquisitionProps(),
353
+ // Utms of the current url win over the persisted last-touch values
354
+ ...getLastTouchUtms(url),
355
+ // Click ids are read from the current url only, never persisted
356
+ ...getQueryProperties(url, CLICK_QUERY_PARAMS),
357
+ };
358
+ }
359
+ clearPendingEvents() {
360
+ this.clearFlushTimer();
361
+ this.queue = [];
362
+ this.consecutiveFailures = 0;
363
+ this.retryAfter = 0;
364
+ this.persistQueue();
365
+ }
366
+ async sendBatch(batch) {
367
+ let res;
368
+ try {
369
+ // Optional chaining: AbortSignal.timeout is missing in older Safari (<16)
370
+ res = await this.postBatch(batch, false, AbortSignal.timeout?.(this.cfg.requestTimeout));
371
+ }
372
+ catch (err) {
373
+ // Network error or timeout - eligible for retry
374
+ this.cfg.logger.warn('[analytics] batch failed to send, will retry', err);
375
+ return 'retry';
376
+ }
377
+ if (res.ok)
378
+ return 'ok';
379
+ if (res.status === 429 || res.status >= 500) {
380
+ const retryAfter = Number(res.headers.get('retry-after'));
381
+ if (retryAfter)
382
+ this.retryAfter = retryAfter * 1000;
383
+ this.cfg.logger.warn(`[analytics] batch rejected with ${res.status}, will retry`);
384
+ return 'retry';
385
+ }
386
+ // Non-retryable 4xx - drop the batch to avoid a poison-pill retry loop
387
+ this.cfg.logger.error(`[analytics] batch rejected with ${res.status}, dropping ${batch.events.length} event(s)`);
388
+ try {
389
+ this.cfg.onError(new AppError('batch dropped on a non-retryable status', {
390
+ status: res.status,
391
+ eventCount: batch.events.length,
392
+ }));
393
+ }
394
+ catch (err) {
395
+ this.cfg.logger.warn('[analytics] onError hook threw', err);
396
+ }
397
+ return 'drop';
398
+ }
399
+ async postBatch(batch, isLifecycleFlush, signal) {
400
+ return fetch(this.cfg.url, {
401
+ method: 'POST',
402
+ // `text/plain` (a CORS-safelisted content-type) avoids a preflight OPTIONS round-trip
403
+ // on every batch and keeps pagehide requests deliverable. The body is still a JSON string.
404
+ headers: { 'content-type': 'text/plain' },
405
+ body: batch.body,
406
+ // Regular flushes use ordinary fetch; keepalive is reserved for page lifecycle delivery.
407
+ ...(isLifecycleFlush && { keepalive: true }),
408
+ signal,
409
+ });
410
+ }
411
+ /**
412
+ * Splits the whole queue into request-ready batches of at most maxBatchSize events
413
+ * and maxBatchBytes serialized bytes each.
414
+ */
415
+ prepareBatches() {
416
+ const batches = [];
417
+ let current;
418
+ const userId = this.identity.getDistinctId();
419
+ for (const event of this.queue) {
420
+ // _safeJsonStringify (here and wherever events are serialized): circular references in
421
+ // props degrade to '[Circular ~]' markers instead of throwing - tracking must never
422
+ // break the app. Non-circular events take its native JSON.stringify fast path.
423
+ const eventBytes = getUtf8ByteLength(_safeJsonStringify(event));
424
+ const hasReachedCount = current?.events.length === this.cfg.maxBatchSize;
425
+ const separatorBytes = current?.events.length ? 1 : 0;
426
+ const hasReachedBytes = !!current && current.bodyBytes + separatorBytes + eventBytes > this.cfg.maxBatchBytes;
427
+ if (current && (hasReachedCount || hasReachedBytes)) {
428
+ batches.push(this.finalizeBatch(current));
429
+ current = undefined;
430
+ }
431
+ current ||= this.createPendingBatch(userId);
432
+ const nextSeparatorBytes = current.events.length ? 1 : 0;
433
+ current.events.push(event);
434
+ current.bodyBytes += nextSeparatorBytes + eventBytes;
435
+ }
436
+ if (current?.events.length) {
437
+ batches.push(this.finalizeBatch(current));
438
+ }
439
+ return batches;
440
+ }
441
+ createPendingBatch(userId) {
442
+ const sentAt = Date.now();
443
+ const emptyBody = {
444
+ sentAt,
445
+ clientId: this.cfg.clientId,
446
+ userId,
447
+ events: [],
448
+ };
449
+ return {
450
+ sentAt,
451
+ userId,
452
+ events: [],
453
+ bodyBytes: getUtf8ByteLength(JSON.stringify(emptyBody)),
454
+ };
455
+ }
456
+ finalizeBatch(batch) {
457
+ const input = {
458
+ sentAt: batch.sentAt,
459
+ clientId: this.cfg.clientId,
460
+ userId: batch.userId,
461
+ events: batch.events,
462
+ };
463
+ return {
464
+ events: batch.events,
465
+ body: _safeJsonStringify(input),
466
+ };
467
+ }
468
+ /**
469
+ * Adopts queue snapshots persisted by previous pageloads (crashed/killed tabs) and re-sends them.
470
+ * A snapshot of a still-alive tab may be adopted too - the resulting duplicate delivery
471
+ * is deduped by the Backend via event ids.
472
+ */
473
+ restoreOrphanedQueues() {
474
+ if (!this.cfg.persistQueue)
475
+ return;
476
+ try {
477
+ const prefix = `${this.cfg.localStorageKeyPrefix}.q.`;
478
+ const keys = [];
479
+ for (let i = 0; i < localStorage.length; i++) {
480
+ const key = localStorage.key(i);
481
+ if (key?.startsWith(prefix))
482
+ keys.push(key);
483
+ }
484
+ if (!keys.length)
485
+ return;
486
+ const minTs = Date.now() - this.cfg.maxPersistedAge;
487
+ for (const key of keys) {
488
+ try {
489
+ const events = JSON.parse(localStorage.getItem(key) || '[]');
490
+ localStorage.removeItem(key);
491
+ this.queue.push(...events.filter(event => event.ts >= minTs));
492
+ }
493
+ catch {
494
+ // Corrupted snapshot - discard only this key and continue restoring the others.
495
+ localStorage.removeItem(key);
496
+ }
497
+ }
498
+ if (this.queue.length > this.cfg.maxQueueSize) {
499
+ this.queue.splice(0, this.queue.length - this.cfg.maxQueueSize);
500
+ }
501
+ if (this.queue.length) {
502
+ this.persistQueue();
503
+ this.scheduleFlush();
504
+ }
505
+ }
506
+ catch {
507
+ // localStorage unavailable - pending in-memory events continue normally
508
+ }
509
+ }
510
+ scheduleFlush() {
511
+ if (this.flushTimer || isServerSide())
512
+ return;
513
+ let delayMs = this.cfg.flushInterval;
514
+ if (this.consecutiveFailures) {
515
+ delayMs = Math.min(this.cfg.flushInterval * 2 ** this.consecutiveFailures, this.cfg.maxRetryBackoff);
516
+ delayMs = Math.max(delayMs, this.retryAfter);
517
+ }
518
+ this.flushTimer = setTimeout(() => {
519
+ this.flushTimer = undefined;
520
+ void this.flush();
521
+ }, delayMs);
522
+ }
523
+ clearFlushTimer() {
524
+ if (!this.flushTimer)
525
+ return;
526
+ clearTimeout(this.flushTimer);
527
+ this.flushTimer = undefined;
528
+ }
529
+ removeFromQueue(batch) {
530
+ const ids = new Set(batch.map(event => event.id));
531
+ this.queue = this.queue.filter(event => !ids.has(event.id));
532
+ this.persistQueue();
533
+ }
534
+ persistQueue() {
535
+ if (!this.cfg.persistQueue)
536
+ return;
537
+ try {
538
+ if (this.queue.length) {
539
+ localStorage.setItem(this.queueKey, _safeJsonStringify(this.queue));
540
+ }
541
+ else {
542
+ localStorage.removeItem(this.queueKey);
543
+ }
544
+ }
545
+ catch {
546
+ // localStorage unavailable/full - analytics must never break the app
547
+ }
548
+ }
549
+ get queueKey() {
550
+ return `${this.cfg.localStorageKeyPrefix}.q.${this.tabId}`;
551
+ }
552
+ }
553
+ /**
554
+ * Distinct-id generation and persistence:
555
+ *
556
+ * - a new identity is a UUID v4 device id, stored as `$device_id`,
557
+ * with `distinct_id` derived from it by `generateDistinctId`
558
+ * - `identify(userId)` sets `distinct_id` and `user_id`, keeping `$device_id` (same person)
559
+ * - `reset()` clears the whole entry and generates a fresh anonymous identity (unrelated person)
560
+ * - the identity is stored as a JSON object under a single cookie / localStorage name
561
+ *
562
+ * Apps sharing a persistenceName and cookie domain read and write the same identity:
563
+ * whichever writes first, the others adopt it. Properties they keep in the same entry
564
+ * are preserved on write, never interpreted.
565
+ *
566
+ * Storage failures (SSR, blocked cookies/localStorage) degrade to an in-memory session-scoped
567
+ * identity - analytics must never break the app.
568
+ */
569
+ export class AnalyticsIdentity {
570
+ constructor(cfg) {
571
+ this.cfg = {
572
+ onError: _noop,
573
+ cookieDomain: '',
574
+ expireDays: 365,
575
+ secureCookie: false,
576
+ // TODO: make it emit something else by default, like the device id itself
577
+ generateDistinctId: deviceId => `$device:${deviceId}`,
578
+ ...cfg,
579
+ };
580
+ }
581
+ cfg;
582
+ /**
583
+ * Fallback identity for when storage is unavailable, and the last-known-good copy
584
+ * if storage becomes unreadable later.
585
+ */
586
+ memoryEntry = {};
587
+ hasRefreshedExpiry = false;
588
+ getDistinctId() {
589
+ return this.ensureIdentity().distinct_id;
590
+ }
591
+ /**
592
+ * The bare (unprefixed) device UUID. Undefined for legacy identities persisted before
593
+ * a device id was stored (their `distinct_id` has no device prefix either).
594
+ */
595
+ getDeviceId() {
596
+ return this.ensureIdentity().$device_id;
597
+ }
598
+ /**
599
+ * Reads any property of the persisted entry: the acquisition props maintained by
600
+ * updateAcquisitionProps(), or props written by another app sharing the entry.
601
+ */
602
+ getProperty(key) {
603
+ return this.loadEntry()[key];
604
+ }
605
+ /**
606
+ * The acquisition props that can't be derived from event payloads, and are stored separately.
607
+ */
608
+ getAcquisitionProps() {
609
+ const entry = this.loadEntry();
610
+ const props = _filterObject(entry, k => String(k).startsWith(UTM_QUERY_PARAM_PREFIX));
611
+ const { firstTouch } = entry;
612
+ if (firstTouch) {
613
+ props['initial_referrer'] = firstTouch.referrer;
614
+ }
615
+ return props;
616
+ }
617
+ /** The captured first touch, as the props to `$set_once` on the profile. */
618
+ getFirstTouchProps() {
619
+ const { firstTouch } = this.loadEntry();
620
+ if (!firstTouch)
621
+ return;
622
+ return { ...firstTouch.utms, initial_referrer: firstTouch.referrer };
623
+ }
624
+ /**
625
+ * Sets the identity going forward, e.g after a successful signup/login.
626
+ * `$device_id` is kept, so the destination can merge the pre-identify
627
+ * anonymous events into the same user.
628
+ */
629
+ identify(userId) {
630
+ const entry = this.ensureIdentity();
631
+ // Identities persisted before a device id was stored adopt the previous
632
+ // distinct_id as their device id
633
+ entry.$device_id ||= entry.distinct_id;
634
+ entry.user_id = userId;
635
+ entry.distinct_id = entry.user_id;
636
+ this.saveEntry(entry);
637
+ }
638
+ /**
639
+ * Clears the whole persisted entry and generates a fresh anonymous identity.
640
+ * Call only when switching to an UNRELATED identity (e.g logout): everything else stored
641
+ * in the entry may belong to the previous user, so it goes too.
642
+ */
643
+ reset() {
644
+ const deviceId = crypto.randomUUID();
645
+ this.saveEntry({
646
+ distinct_id: this.cfg.generateDistinctId(deviceId),
647
+ $device_id: deviceId,
648
+ });
649
+ }
650
+ /** Collects properties of the user that can be used to attribute traffic. */
651
+ updateAcquisitionProps() {
652
+ if (isServerSide())
653
+ return;
654
+ const entry = this.loadEntry();
655
+ const url = new URL(globalThis.location.href);
656
+ Object.assign(entry, getLastTouchUtms(url));
657
+ entry.firstTouch ||= { referrer: getReferrer() };
658
+ entry.firstTouch.utms ||= getFirstTouchUtms(url);
659
+ this.saveEntry(entry);
660
+ }
661
+ ensureIdentity() {
662
+ const entry = this.loadEntry();
663
+ if (entry.distinct_id) {
664
+ this.refreshExpiry(entry);
665
+ }
666
+ else {
667
+ const deviceId = crypto.randomUUID();
668
+ entry.distinct_id = this.cfg.generateDistinctId(deviceId);
669
+ entry.$device_id = deviceId;
670
+ this.saveEntry(entry);
671
+ }
672
+ return entry;
673
+ }
674
+ /**
675
+ * The cookie expiration window slides: expireDays counts from the LAST visit, not the
676
+ * first. Re-saved once per instance (in practice - once per pageload).
677
+ */
678
+ refreshExpiry(entry) {
679
+ if (this.hasRefreshedExpiry || this.cfg.persistence !== 'cookie')
680
+ return;
681
+ this.saveEntry(entry);
682
+ }
683
+ loadEntry() {
684
+ try {
685
+ const raw = this.cfg.persistence === 'cookie'
686
+ ? getCookie(this.cfg.persistenceKey)
687
+ : localStorage.getItem(this.cfg.persistenceKey);
688
+ if (raw)
689
+ this.memoryEntry = JSON.parse(raw);
690
+ }
691
+ catch {
692
+ // Storage unavailable (SSR, blocked) or corrupted JSON - keep the in-memory copy
693
+ }
694
+ return this.memoryEntry;
695
+ }
696
+ saveEntry(entry) {
697
+ this.memoryEntry = entry;
698
+ this.hasRefreshedExpiry = true;
699
+ // Stays 0 when the failure came before serializing, which distinguishes it from a rejected write
700
+ let bytes = 0;
701
+ try {
702
+ const value = JSON.stringify(entry);
703
+ bytes = value.length;
704
+ if (this.cfg.persistence === 'cookie') {
705
+ bytes = this.cookieLength(entry);
706
+ if (bytes > MAX_COOKIE_LENGTH) {
707
+ // Writing it would make the browser drop the cookie, and the persisted identity
708
+ // with it. The last good cookie is kept instead, and this entry stays in memory.
709
+ this.reportError(new AnalyticsClientError('cookie exceeded the length limit', { bytes }));
710
+ return;
711
+ }
712
+ setCookie(this.cfg.persistenceKey, value, this.cfg.expireDays, this.cfg.cookieDomain, this.cfg.secureCookie);
713
+ // `document.cookie` swallows a rejected write (cookies disabled, a domain the page is
714
+ // not allowed to set, ITP), so reading it back is the only way to notice. A leftover
715
+ // cookie of the same name can shadow the one just written, so any match counts.
716
+ const persisted = getCookieValues(this.cfg.persistenceKey);
717
+ if (!persisted.includes(value)) {
718
+ this.reportError(new AnalyticsClientError('cookie write did not persist', {
719
+ bytes,
720
+ hadPreviousCookie: persisted.length > 0,
721
+ }));
722
+ }
723
+ }
724
+ else {
725
+ localStorage.setItem(this.cfg.persistenceKey, value);
726
+ }
727
+ }
728
+ catch (err) {
729
+ // The identity stays session-scoped in memory
730
+ this.reportError(_errorDataAppend(err, { bytes }));
731
+ }
732
+ }
733
+ cookieLength(entry) {
734
+ return this.cfg.persistenceKey.length + encodeURIComponent(JSON.stringify(entry)).length;
735
+ }
736
+ reportError(err) {
737
+ try {
738
+ this.cfg.onError(err);
739
+ }
740
+ catch {
741
+ // A consumer hook must not break identity updates
742
+ }
743
+ }
744
+ }
745
+ /** The non-empty canonical `utm_*` params of the given url. */
746
+ function getLastTouchUtms(url) {
747
+ const props = {};
748
+ for (const param of CANONICAL_UTM_PARAMS) {
749
+ const value = url.searchParams.get(param);
750
+ if (value)
751
+ props[param] = truncateAnalyticsProperty(value);
752
+ }
753
+ return props;
754
+ }
755
+ /** The referrer of this pageload, or null when it has none (a direct visit). */
756
+ function getReferrer() {
757
+ return truncateAnalyticsProperty(document.referrer) || null;
758
+ }
759
+ /** Captures all utms if any are set, otherwise none */
760
+ function getFirstTouchUtms(url) {
761
+ // The absent ones are stored as null, so a later campaign cannot fill in the gaps
762
+ const utms = _mapToObject(CANONICAL_UTM_PARAMS, param => {
763
+ const value = url.searchParams.get(param);
764
+ return [`initial_${param}`, value ? truncateAnalyticsProperty(value) : null];
765
+ });
766
+ if (_isEmptyObject(_filterNullishValues(utms)))
767
+ return;
768
+ return utms;
769
+ }
770
+ function getQueryProperties(url, keys) {
771
+ return Object.fromEntries(keys.flatMap(key => {
772
+ const value = url.searchParams.get(key);
773
+ return value ? [[key, value]] : [];
774
+ }));
775
+ }
776
+ // Shared instance: runs once per event per batch preparation (not per property).
777
+ // we measure to stay under the fetch keepalive body quota.
778
+ const textEncoder = new TextEncoder();
779
+ function getUtf8ByteLength(value) {
780
+ return textEncoder.encode(value).byteLength;
781
+ }
782
+ /** The first visible value under this name, or null. */
783
+ export function getCookie(name) {
784
+ if (!name)
785
+ return null;
786
+ return getCookieValues(name)[0] ?? null;
787
+ }
788
+ /**
789
+ * Every visible value under this name, in `document.cookie` order. A host-only and a
790
+ * parent-domain cookie with the same name and path coexist as separate cookies.
791
+ */
792
+ function getCookieValues(name) {
793
+ const nameEq = `${name}=`;
794
+ const values = [];
795
+ for (let c of document.cookie.split(';')) {
796
+ while (c.startsWith(' '))
797
+ c = c.slice(1);
798
+ if (!c.startsWith(nameEq))
799
+ continue;
800
+ values.push(decodeURIComponent(c.slice(nameEq.length)));
801
+ }
802
+ return values;
803
+ }
804
+ /**
805
+ * Writes the cookie and returns what was written, which the tests assert on.
806
+ * `domain` is explicit, e.g `.example.com` to share it across subdomains.
807
+ */
808
+ export function setCookie(name, value, days, domain, isSecure) {
809
+ const attrs = [`${name}=${encodeURIComponent(value)}`];
810
+ if (days) {
811
+ attrs.push(`expires=${new Date(Date.now() + days * 24 * 3600 * 1000).toUTCString()}`);
812
+ }
813
+ attrs.push('path=/');
814
+ if (domain)
815
+ attrs.push(`domain=${domain}`);
816
+ if (isSecure)
817
+ attrs.push('secure');
818
+ const cookie = attrs.join('; ');
819
+ // The Cookie Store API is async and missing in Safari, so this stays synchronous
820
+ // oxlint-disable-next-line unicorn/no-document-cookie
821
+ document.cookie = cookie;
822
+ return cookie;
823
+ }
824
+ export class AnalyticsClientError extends AppError {
825
+ constructor(message, data) {
826
+ super(message, data, { name: 'AnalyticsClientError' });
827
+ }
828
+ }