@faststats/web 0.2.13 → 0.2.14

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/src/replay.ts CHANGED
@@ -7,23 +7,28 @@ import { record } from "rrweb";
7
7
  import type { recordOptions } from "rrweb/typings/types";
8
8
  import type { SlimDOMOptions } from "rrweb-snapshot";
9
9
  import { normalizeAnalyticsBaseUrl, URLS } from "./utils/api-urls";
10
+ import { getAnonymousId } from "./utils/identifiers";
11
+ import { sendData } from "./utils/send-data";
10
12
  import {
11
- getAnonymousId,
12
- getOrCreateSessionId,
13
+ createId,
14
+ getSessionContext,
13
15
  getSessionStart,
14
- } from "./utils/identifiers";
15
- import { sendData } from "./utils/send-data";
16
+ onSessionRotated,
17
+ type SessionContext,
18
+ touchActivity,
19
+ } from "./utils/session-manager";
16
20
  import { normalizeSamplingPercentage } from "./utils/types";
17
21
 
18
- const RRWEB_SEQUENTIAL_ID_KEY = "_faststatsSeqId";
19
22
  const MAX_TIMEOUT_MS = 2_147_483_647;
20
23
  const DEFAULT_MAX_QUEUE_SIZE_BYTES = 2 * 1024 * 1024;
24
+ const VIEW_META_EVENT_TYPE = 5;
21
25
 
22
26
  export interface ReplayTrackerOptions {
23
27
  siteKey: string;
24
28
  baseUrl?: string;
25
29
  debug?: boolean;
26
30
  compress?: boolean;
31
+ cookieless?: boolean;
27
32
  samplingPercentage?: number;
28
33
  flushInterval?: number;
29
34
  maxEvents?: number;
@@ -45,12 +50,16 @@ export interface ReplayTrackerOptions {
45
50
 
46
51
  type ReplayBatch = {
47
52
  token: string;
48
- sessionId: string | undefined;
53
+ sessionId: string;
54
+ windowId: string;
55
+ viewId: string;
56
+ sessionStart: number;
49
57
  identifier?: string;
50
58
  batchId: string;
51
59
  sequence: number;
52
60
  timestamp: number;
53
61
  url: string;
62
+ isFinal?: boolean;
54
63
  events: eventWithTime[];
55
64
  };
56
65
 
@@ -62,17 +71,53 @@ const defaultSampling: recordOptions<eventWithTime>["sampling"] = {
62
71
  input: "last",
63
72
  };
64
73
 
65
- const defaultSlimDOMOptions: SlimDOMOptions = {
66
- script: true,
67
- comment: true,
68
- headFavicon: true,
69
- headWhitespace: true,
70
- headMetaDescKeywords: true,
71
- headMetaSocial: true,
72
- headMetaRobots: true,
73
- headMetaHttpEquiv: true,
74
- headMetaAuthorship: true,
75
- };
74
+ function replaySampledKey(siteKey: string): string {
75
+ return `faststats_replay_sampled_${siteKey}`;
76
+ }
77
+
78
+ function hashSessionId(sessionId: string): number {
79
+ let hash = 0;
80
+ for (let i = 0; i < sessionId.length; i++) {
81
+ hash = (hash << 5) - hash + sessionId.charCodeAt(i);
82
+ hash |= 0;
83
+ }
84
+ return Math.abs(hash);
85
+ }
86
+
87
+ function resolveReplaySampled(
88
+ siteKey: string,
89
+ samplingPercentage: number,
90
+ cookieless: boolean,
91
+ ): boolean {
92
+ const pct = normalizeSamplingPercentage(samplingPercentage);
93
+ if (pct >= 100) return true;
94
+ if (pct <= 0) return false;
95
+
96
+ const sessionId = getSessionContext(siteKey, cookieless).sessionId;
97
+ const storageKey = replaySampledKey(siteKey);
98
+ const storedKey = `${sessionId}:${pct}`;
99
+
100
+ try {
101
+ const session = sessionStorage;
102
+ if (session) {
103
+ const stored = session.getItem(storageKey);
104
+ if (stored === storedKey) return true;
105
+ if (stored?.startsWith(`${sessionId}:`)) return false;
106
+ }
107
+ } catch {}
108
+
109
+ const bucket = hashSessionId(sessionId) % 100;
110
+ const sampled = bucket < pct;
111
+
112
+ try {
113
+ sessionStorage?.setItem(
114
+ storageKey,
115
+ sampled ? storedKey : `${sessionId}:out`,
116
+ );
117
+ } catch {}
118
+
119
+ return sampled;
120
+ }
76
121
 
77
122
  export default class ReplayTracker {
78
123
  private readonly endpoint: string;
@@ -84,7 +129,8 @@ export default class ReplayTracker {
84
129
  private readonly pending: ReplayBatch[] = [];
85
130
  private pendingSizeBytes = 0;
86
131
 
87
- private sessionId: string | undefined;
132
+ private viewId = createId();
133
+ private readonly windowId = createId();
88
134
  private started = false;
89
135
  private startTime = 0;
90
136
  private sequence = 0;
@@ -94,12 +140,19 @@ export default class ReplayTracker {
94
140
  private minLengthFlushTask: ReturnType<typeof setTimeout> | null = null;
95
141
  private stopRecording?: listenerHandler;
96
142
  private sending = false;
143
+ private unsubscribeRotation?: () => void;
97
144
 
98
145
  constructor(private readonly options: ReplayTrackerOptions) {
99
146
  this.endpoint = `${normalizeAnalyticsBaseUrl(options.baseUrl)}${URLS.replay}`;
100
- this.sampled =
101
- Math.random() * 100 <
102
- normalizeSamplingPercentage(options.samplingPercentage);
147
+ this.sampled = resolveReplaySampled(
148
+ options.siteKey,
149
+ options.samplingPercentage ?? 100,
150
+ options.cookieless ?? false,
151
+ );
152
+ }
153
+
154
+ private get cookieless(): boolean {
155
+ return this.options.cookieless ?? false;
103
156
  }
104
157
 
105
158
  private get debug(): boolean {
@@ -134,12 +187,20 @@ export default class ReplayTracker {
134
187
  if (this.debug) console.log("[Replay]", ...args);
135
188
  }
136
189
 
190
+ private sessionContext(): SessionContext {
191
+ return getSessionContext(this.options.siteKey, this.cookieless);
192
+ }
193
+
137
194
  start(): void {
138
195
  if (this.started || typeof window === "undefined" || !this.sampled) return;
139
196
 
140
197
  this.started = true;
141
- this.sessionId = getOrCreateSessionId();
142
- this.startTime = getSessionStart();
198
+ this.viewId = createId();
199
+ this.startTime = getSessionStart(this.cookieless);
200
+
201
+ this.unsubscribeRotation = onSessionRotated((prev) => {
202
+ this.onSessionRotated(prev);
203
+ });
143
204
 
144
205
  void this.beginRecording();
145
206
 
@@ -152,22 +213,31 @@ export default class ReplayTracker {
152
213
  this.log("Recording started");
153
214
  }
154
215
 
216
+ trackPageChange(url?: string): void {
217
+ if (!this.started) return;
218
+ const href = url ?? window.location.href;
219
+ this.viewId = createId();
220
+ const metaEvent = {
221
+ type: VIEW_META_EVENT_TYPE,
222
+ timestamp: Date.now(),
223
+ data: {
224
+ tag: "faststats:view",
225
+ payload: { href, viewId: this.viewId },
226
+ },
227
+ } as eventWithTime;
228
+ this.onEvent(metaEvent, false);
229
+ }
230
+
155
231
  private async beginRecording(): Promise<void> {
156
232
  const wantsConsole = this.options.recordConsole ?? true;
157
233
 
158
- const [{ getRecordSequentialIdPlugin }, consolePluginModule] =
159
- await Promise.all([
160
- import("@rrweb/rrweb-plugin-sequential-id-record"),
161
- wantsConsole
162
- ? import("@rrweb/rrweb-plugin-console-record")
163
- : Promise.resolve(null),
164
- ]);
234
+ const consolePluginModule = wantsConsole
235
+ ? await import("@rrweb/rrweb-plugin-console-record")
236
+ : null;
165
237
 
166
238
  if (!this.started) return;
167
239
 
168
- const plugins = [
169
- getRecordSequentialIdPlugin({ key: RRWEB_SEQUENTIAL_ID_KEY }),
170
- ];
240
+ const plugins = [];
171
241
  if (consolePluginModule) {
172
242
  plugins.push(consolePluginModule.getRecordConsolePlugin());
173
243
  }
@@ -175,7 +245,17 @@ export default class ReplayTracker {
175
245
  this.stopRecording = record({
176
246
  emit: this.onEvent,
177
247
  sampling: this.options.sampling ?? defaultSampling,
178
- slimDOMOptions: this.options.slimDOMOptions ?? defaultSlimDOMOptions,
248
+ slimDOMOptions: this.options.slimDOMOptions ?? {
249
+ script: true,
250
+ comment: true,
251
+ headFavicon: true,
252
+ headWhitespace: true,
253
+ headMetaDescKeywords: true,
254
+ headMetaSocial: true,
255
+ headMetaRobots: true,
256
+ headMetaHttpEquiv: true,
257
+ headMetaAuthorship: true,
258
+ },
179
259
  maskAllInputs: this.options.maskAllInputs ?? true,
180
260
  maskInputOptions: this.options.maskInputOptions ?? {
181
261
  password: true,
@@ -196,6 +276,9 @@ export default class ReplayTracker {
196
276
  if (!this.started) return;
197
277
  this.started = false;
198
278
 
279
+ this.unsubscribeRotation?.();
280
+ this.unsubscribeRotation = undefined;
281
+
199
282
  this.stopRecording?.();
200
283
  this.stopRecording = undefined;
201
284
 
@@ -215,20 +298,37 @@ export default class ReplayTracker {
215
298
 
216
299
  if (!this.hasReachedMinLength()) {
217
300
  this.events.length = 0;
218
- this.sessionId = undefined;
219
301
  this.log(
220
302
  `Session too short (${Date.now() - this.startTime}ms), discarding events`,
221
303
  );
222
304
  return;
223
305
  }
224
306
 
225
- void this.flush(true);
226
- this.sessionId = undefined;
307
+ void this.flush(true, undefined, true);
227
308
  this.log("Recording stopped");
228
309
  }
229
310
 
230
- getSessionId(): string | undefined {
231
- return this.sessionId ?? getOrCreateSessionId();
311
+ getSessionId(): string {
312
+ return this.sessionContext().sessionId;
313
+ }
314
+
315
+ getWindowId(): string {
316
+ return this.sessionContext().windowId;
317
+ }
318
+
319
+ private onSessionRotated(prev: SessionContext): void {
320
+ if (!this.started) return;
321
+ if (this.events.length > 0) {
322
+ if (!this.hasReachedMinLength()) {
323
+ this.scheduleMinLengthFlush();
324
+ } else {
325
+ const batch = this.createBatch(this.events.splice(0), prev, true);
326
+ this.enqueueBatch(batch);
327
+ }
328
+ }
329
+ if (this.pending.length > 0) {
330
+ queueMicrotask(() => void this.flush(false));
331
+ }
232
332
  }
233
333
 
234
334
  private onEvent = (event: eventWithTime, isCheckout?: boolean): void => {
@@ -247,12 +347,12 @@ export default class ReplayTracker {
247
347
  };
248
348
 
249
349
  private onUnload = (): void => {
250
- void this.flush(true);
350
+ void this.flush(true, undefined, true);
251
351
  };
252
352
 
253
353
  private onVisibilityChange = (): void => {
254
354
  if (document.visibilityState === "hidden") {
255
- void this.flush(true);
355
+ void this.flush(true, undefined, true);
256
356
  }
257
357
  };
258
358
 
@@ -298,28 +398,36 @@ export default class ReplayTracker {
298
398
  }, timeoutMs);
299
399
  }
300
400
 
301
- private createBatch(events: eventWithTime[]): ReplayBatch {
302
- const identifier = getAnonymousId();
401
+ private createBatch(
402
+ events: eventWithTime[],
403
+ context: SessionContext,
404
+ isFinal?: boolean,
405
+ ): ReplayBatch {
406
+ const identifier = getAnonymousId(this.cookieless);
303
407
  const sequence = this.sequence++;
304
408
 
305
409
  return {
306
410
  token: this.options.siteKey,
307
- sessionId: this.getSessionId(),
411
+ sessionId: context.sessionId,
412
+ windowId: this.windowId,
413
+ viewId: this.viewId,
414
+ sessionStart: context.sessionStart,
308
415
  ...(identifier ? { identifier } : {}),
309
- batchId: this.createBatchId(sequence),
416
+ batchId: this.createBatchId(context.sessionId, sequence),
310
417
  sequence,
311
418
  timestamp: Date.now(),
312
419
  url: window.location.href,
420
+ ...(isFinal ? { isFinal: true } : {}),
313
421
  events,
314
422
  };
315
423
  }
316
424
 
317
- private createBatchId(sequence: number): string {
425
+ private createBatchId(sessionId: string, sequence: number): string {
318
426
  const random =
319
427
  typeof crypto !== "undefined" && "randomUUID" in crypto
320
428
  ? crypto.randomUUID()
321
429
  : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
322
- return `${this.getSessionId() ?? "unknown"}-${sequence}-${random}`;
430
+ return `${sessionId}-${sequence}-${random}`;
323
431
  }
324
432
 
325
433
  private getBatchSizeBytes(batch: ReplayBatch): number {
@@ -392,14 +500,33 @@ export default class ReplayTracker {
392
500
  }
393
501
  }
394
502
 
395
- private async flush(lowLatency: boolean): Promise<void> {
503
+ private async flush(
504
+ lowLatency: boolean,
505
+ contextOverride?: SessionContext,
506
+ isFinal?: boolean,
507
+ ): Promise<void> {
396
508
  if (this.sending) return;
397
509
 
510
+ const rotation = contextOverride ? null : touchActivity(this.cookieless);
511
+ if (rotation && this.events.length > 0) {
512
+ if (!this.hasReachedMinLength()) {
513
+ this.scheduleMinLengthFlush();
514
+ } else {
515
+ const batch = this.createBatch(
516
+ this.events.splice(0),
517
+ rotation.prev,
518
+ true,
519
+ );
520
+ this.enqueueBatch(batch);
521
+ }
522
+ }
523
+ const context = contextOverride ?? this.sessionContext();
524
+
398
525
  if (this.events.length > 0) {
399
526
  if (!this.hasReachedMinLength()) {
400
527
  this.scheduleMinLengthFlush();
401
528
  } else {
402
- const batch = this.createBatch(this.events.splice(0));
529
+ const batch = this.createBatch(this.events.splice(0), context, isFinal);
403
530
  this.enqueueBatch(batch);
404
531
  }
405
532
  }
@@ -461,7 +588,7 @@ export default class ReplayTracker {
461
588
  ? "application/octet-stream"
462
589
  : "application/json",
463
590
  debug: false,
464
- useBeacon: false,
591
+ useBeacon: lowLatency,
465
592
  keepalive: lowLatency,
466
593
  }) ?? Promise.resolve(false)
467
594
  );
@@ -1,89 +1,47 @@
1
- const SESSION_TIMEOUT = 30 * 60 * 1000;
2
- const ANONYMOUS_ID_KEY = "faststats_anon_id";
3
- const SESSION_ID_KEY = "session_id";
4
- const SESSION_TIMESTAMP_KEY = "session_timestamp";
5
- const SESSION_START_KEY = "session_start";
1
+ import { createId, isCookielessMode } from "./session-manager";
6
2
 
7
- let cookielessMode = false;
3
+ const ANONYMOUS_ID_KEY = "faststats_anon_id";
8
4
 
9
- export function setCookielessMode(mode: boolean): void {
10
- cookielessMode = mode;
5
+ function getLocalStorage(): Storage | undefined {
6
+ try {
7
+ return globalThis.localStorage;
8
+ } catch {
9
+ return undefined;
10
+ }
11
11
  }
12
12
 
13
13
  export function getAnonymousId(cookieless?: boolean): string {
14
- const useCookieless = cookieless ?? cookielessMode;
15
- if (useCookieless) return "";
14
+ if (cookieless ?? isCookielessMode()) return "";
16
15
  return getOrCreateAnonymousId();
17
16
  }
18
17
 
19
18
  export function getOrCreateAnonymousId(): string {
20
- if (!localStorage) return "";
21
- const existingId = localStorage.getItem(ANONYMOUS_ID_KEY);
22
- if (existingId) return existingId;
19
+ const storage = getLocalStorage();
20
+ if (!storage) return "";
21
+ try {
22
+ const existingId = storage.getItem(ANONYMOUS_ID_KEY);
23
+ if (existingId) return existingId;
24
+ } catch {
25
+ return "";
26
+ }
23
27
 
24
- const newId = crypto.randomUUID();
25
- localStorage.setItem(ANONYMOUS_ID_KEY, newId);
28
+ const newId = createId();
29
+ try {
30
+ storage.setItem(ANONYMOUS_ID_KEY, newId);
31
+ } catch {
32
+ return "";
33
+ }
26
34
  return newId;
27
35
  }
28
36
 
29
37
  export function resetAnonymousId(cookieless?: boolean): string {
30
38
  if (cookieless) return "";
31
-
32
- if (!localStorage) return "";
33
-
34
- localStorage.removeItem(ANONYMOUS_ID_KEY);
35
- return getOrCreateAnonymousId();
36
- }
37
-
38
- export function getOrCreateSessionId(): string {
39
- if (!sessionStorage) return "";
40
-
41
- const existingId = sessionStorage.getItem(SESSION_ID_KEY);
42
- const sessionTimestamp = sessionStorage.getItem(SESSION_TIMESTAMP_KEY);
43
-
44
- if (existingId && sessionTimestamp) {
45
- const sessionAge = Date.now() - Number.parseInt(sessionTimestamp, 10);
46
- if (sessionAge < SESSION_TIMEOUT) {
47
- sessionStorage.setItem(SESSION_TIMESTAMP_KEY, Date.now().toString());
48
- return existingId;
49
- }
50
- clearSession(sessionStorage);
39
+ try {
40
+ const storage = getLocalStorage();
41
+ if (!storage) return "";
42
+ storage.removeItem(ANONYMOUS_ID_KEY);
43
+ } catch {
44
+ return "";
51
45
  }
52
-
53
- const now = Date.now().toString();
54
- const newId = crypto.randomUUID();
55
- sessionStorage.setItem(SESSION_ID_KEY, newId);
56
- sessionStorage.setItem(SESSION_TIMESTAMP_KEY, now);
57
- sessionStorage.setItem(SESSION_START_KEY, now);
58
- return newId;
59
- }
60
-
61
- export function resetSessionId(): string {
62
- if (!sessionStorage) return "";
63
-
64
- clearSession(sessionStorage);
65
- return getOrCreateSessionId();
66
- }
67
-
68
- export function refreshSessionTimestamp(): void {
69
- if (!sessionStorage) return;
70
-
71
- if (sessionStorage.getItem(SESSION_ID_KEY)) {
72
- sessionStorage.setItem(SESSION_TIMESTAMP_KEY, Date.now().toString());
73
- }
74
- }
75
-
76
- export function getSessionStart(): number {
77
- if (!sessionStorage) return Date.now();
78
-
79
- const start = sessionStorage.getItem(SESSION_START_KEY);
80
- if (start) return Number.parseInt(start, 10);
81
- const ts = sessionStorage.getItem(SESSION_TIMESTAMP_KEY);
82
- return ts ? Number.parseInt(ts, 10) : Date.now();
83
- }
84
-
85
- function clearSession(storage: Storage): void {
86
- storage.removeItem(SESSION_ID_KEY);
87
- storage.removeItem(SESSION_TIMESTAMP_KEY);
88
- storage.removeItem(SESSION_START_KEY);
46
+ return getOrCreateAnonymousId();
89
47
  }