@faststats/web 0.2.12 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/REPLAY_PAYLOAD.md +64 -0
  3. package/dist/chunks/api-urls-DaeYkG0_.js +1 -0
  4. package/dist/chunks/error-Cd9PTS5v.js +2 -0
  5. package/dist/chunks/{feature-flags-BxZz_lNm.d.ts → feature-flags-BClx56v5.d.ts} +1 -0
  6. package/dist/chunks/feature-flags-DSOCIZHK.js +1 -0
  7. package/dist/chunks/{replay-BuX3_0xs.d.ts → replay-DvJYurEC.d.ts} +10 -2
  8. package/dist/chunks/replay-rTqcjOo2.js +1 -0
  9. package/dist/chunks/send-data-B2fYGj6v.js +1 -0
  10. package/dist/chunks/session-manager-Cy63ptPF.js +1 -0
  11. package/dist/chunks/web-vitals-Be-Cg4Po.js +1 -0
  12. package/dist/error.d.ts +5 -8
  13. package/dist/error.js +1 -1
  14. package/dist/feature-flags.d.ts +1 -1
  15. package/dist/feature-flags.js +1 -1
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +1 -1
  18. package/dist/replay.d.ts +1 -1
  19. package/dist/replay.js +1 -1
  20. package/dist/web-vitals.d.ts +7 -15
  21. package/dist/web-vitals.js +1 -1
  22. package/package.json +4 -7
  23. package/src/analytics.ts +25 -14
  24. package/src/error.ts +110 -158
  25. package/src/feature-flags.ts +5 -2
  26. package/src/replay.ts +179 -52
  27. package/src/utils/api-urls.ts +7 -20
  28. package/src/utils/identifiers.ts +30 -72
  29. package/src/utils/session-manager.ts +416 -0
  30. package/src/web-vitals.ts +66 -168
  31. package/tests/analytics.test.ts +8 -4
  32. package/tests/identifiers.test.ts +15 -32
  33. package/tests/replay.test.ts +180 -10
  34. package/tests/session-manager.test.ts +161 -0
  35. package/dist/chunks/api-urls-BrkcoElX.js +0 -1
  36. package/dist/chunks/error-CttYL43D.js +0 -2
  37. package/dist/chunks/feature-flags-CjnLZGxp.js +0 -1
  38. package/dist/chunks/identifiers-CQeWm7wi.js +0 -1
  39. package/dist/chunks/replay-BrMLCiBF.js +0 -1
  40. package/dist/chunks/send-data-DL_GlsQw.js +0 -1
  41. package/dist/chunks/web-vitals-CjA1bFLG.js +0 -1
  42. package/wrangler.toml +0 -8
package/src/web-vitals.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Metric, MetricWithAttribution } from "web-vitals";
2
- import { webVitalsEventsUrl } from "./utils/api-urls";
3
- import { getOrCreateSessionId } from "./utils/identifiers";
2
+ import { normalizeAnalyticsBaseUrl, URLS } from "./utils/api-urls";
3
+ import { getOrCreateSessionId } from "./utils/session-manager";
4
4
  import { normalizeSamplingPercentage } from "./utils/types";
5
5
 
6
6
  export interface WebVitalsOptions {
@@ -12,64 +12,23 @@ export interface WebVitalsOptions {
12
12
  }
13
13
 
14
14
  type MetricName = "CLS" | "INP" | "LCP" | "FCP" | "TTFB";
15
+ type AnyMetric = Metric | MetricWithAttribution;
15
16
 
16
- type MetricState = {
17
+ type MetricPayload = {
18
+ metric: MetricName;
17
19
  value: number;
18
20
  attributes: Record<string, unknown>;
19
21
  };
20
22
 
21
- type AnyMetric = Metric | MetricWithAttribution;
22
- type ObserverOptions = {
23
- reportAllChanges?: boolean;
24
- };
25
- type Observer = (
26
- cb: (metric: AnyMetric) => void,
27
- opts?: ObserverOptions,
28
- ) => void;
29
- type ObserverTuple = [Observer, Observer, Observer, Observer, Observer];
30
- type ObserverConfig = {
31
- observe: Observer;
32
- options?: ObserverOptions;
33
- };
34
-
35
- async function loadObservers(attribution: boolean): Promise<ObserverConfig[]> {
36
- const { onCLS, onFCP, onINP, onLCP, onTTFB } = attribution
37
- ? await import("web-vitals/attribution")
38
- : await import("web-vitals");
39
- return createObserverConfigs([onCLS, onFCP, onINP, onLCP, onTTFB]);
40
- }
41
-
42
- function createObserverConfigs([
43
- onCLS,
44
- onFCP,
45
- onINP,
46
- onLCP,
47
- onTTFB,
48
- ]: ObserverTuple): ObserverConfig[] {
49
- return [
50
- { observe: onCLS, options: { reportAllChanges: true } },
51
- { observe: onFCP },
52
- { observe: onINP, options: { reportAllChanges: true } },
53
- { observe: onLCP, options: { reportAllChanges: true } },
54
- { observe: onTTFB },
55
- ];
56
- }
57
-
58
23
  export default class WebVitalsTracker {
59
24
  private readonly endpoint: string;
60
- private readonly metricsByUrl = new Map<
61
- string,
62
- Map<MetricName, MetricState>
63
- >();
64
- private readonly pendingFlushUrls = new Set<string>();
65
25
  private readonly sampled: boolean;
26
+ private readonly metrics = new Map<MetricName, MetricPayload>();
66
27
  private started = false;
67
- private flushing = false;
68
- private finalFlushRequested = false;
69
- private currentUrl = "";
28
+ private url = "";
70
29
 
71
30
  constructor(private readonly options: WebVitalsOptions) {
72
- this.endpoint = webVitalsEventsUrl(options.baseUrl);
31
+ this.endpoint = `${normalizeAnalyticsBaseUrl(options.baseUrl)}${URLS.vitals}`;
73
32
  this.sampled =
74
33
  Math.random() * 100 <
75
34
  normalizeSamplingPercentage(options.samplingPercentage);
@@ -83,77 +42,68 @@ export default class WebVitalsTracker {
83
42
  if (this.debug) console.log("[WebVitals]", ...args);
84
43
  }
85
44
 
86
- start(): void {
45
+ async start(): Promise<void> {
87
46
  if (this.started || typeof window === "undefined") return;
88
47
 
89
48
  this.started = true;
90
- this.currentUrl = window.location.href;
49
+ this.url = window.location.href;
91
50
 
92
- document.addEventListener("visibilitychange", this.onVisibilityChange, {
51
+ document.addEventListener("visibilitychange", this.onHidden, {
93
52
  passive: true,
94
53
  });
95
54
  window.addEventListener("pagehide", this.onPageHide, { passive: true });
96
55
 
56
+ const mod = this.options.attribution
57
+ ? await import("web-vitals/attribution")
58
+ : await import("web-vitals");
59
+
60
+ if (!this.started) return;
61
+
62
+ mod.onCLS(this.capture, { reportAllChanges: true });
63
+ mod.onINP(this.capture, { reportAllChanges: true });
64
+ mod.onLCP(this.capture, { reportAllChanges: true });
65
+ mod.onFCP(this.capture);
66
+ mod.onTTFB(this.capture);
67
+
97
68
  this.log("Tracking started");
98
- void this.observe();
99
69
  }
100
70
 
101
71
  stop(): void {
102
72
  if (!this.started || typeof window === "undefined") return;
103
73
 
104
74
  this.started = false;
105
- this.cleanupListeners();
106
- this.queueFlush(this.currentUrl, true);
75
+ this.removeListeners();
76
+ void this.flush();
107
77
  }
108
78
 
109
79
  trackPageChange(url?: string): void {
110
80
  if (!this.started || typeof window === "undefined") return;
111
- const nextUrl = url ?? window.location.href;
112
- if (nextUrl === this.currentUrl) return;
113
81
 
114
- const previousUrl = this.currentUrl;
115
- this.currentUrl = nextUrl;
116
- this.queueFlush(previousUrl);
117
- }
82
+ const nextUrl = url ?? window.location.href;
83
+ if (nextUrl === this.url) return;
118
84
 
119
- private cleanupListeners(): void {
120
- document.removeEventListener("visibilitychange", this.onVisibilityChange);
121
- window.removeEventListener("pagehide", this.onPageHide);
85
+ void this.flush();
86
+ this.url = nextUrl;
122
87
  }
123
88
 
124
- private async observe(): Promise<void> {
125
- try {
126
- const observers = await loadObservers(this.options.attribution ?? false);
127
-
128
- if (!this.started) return;
129
-
130
- for (const { observe, options } of observers) {
131
- observe(this.captureMetric, options);
132
- }
133
- } catch (error) {
134
- this.log("Failed to load web-vitals", error);
135
- }
136
- }
137
-
138
- private onVisibilityChange = (): void => {
89
+ private onHidden = (): void => {
139
90
  if (document.visibilityState === "hidden") {
140
- this.queueFlush(this.currentUrl, true);
91
+ void this.flush();
141
92
  }
142
93
  };
143
94
 
144
95
  private onPageHide = (): void => {
145
- this.queueFlush(this.currentUrl, true);
96
+ void this.flush();
146
97
  };
147
98
 
148
- private captureMetric = (metric: AnyMetric): void => {
99
+ private capture = (metric: AnyMetric): void => {
149
100
  if (!this.started || !this.sampled) return;
150
101
 
151
102
  const name = metric.name as MetricName;
152
- const attribution =
153
- (metric as MetricWithAttribution).attribution ?? undefined;
154
- const metrics = this.metricsForUrl(this.currentUrl);
103
+ const attribution = (metric as MetricWithAttribution).attribution;
155
104
 
156
- metrics.set(name, {
105
+ this.metrics.set(name, {
106
+ metric: name,
157
107
  value: metric.value,
158
108
  attributes: {
159
109
  id: metric.id,
@@ -163,99 +113,47 @@ export default class WebVitalsTracker {
163
113
  ...(attribution ?? {}),
164
114
  },
165
115
  });
166
-
167
- this.log(`${name} captured: ${metric.value}`);
168
116
  };
169
117
 
170
- private metricsForUrl(url: string): Map<MetricName, MetricState> {
171
- let metrics = this.metricsByUrl.get(url);
172
- if (!metrics) {
173
- metrics = new Map();
174
- this.metricsByUrl.set(url, metrics);
175
- }
176
- return metrics;
177
- }
178
-
179
- private queueFlush(url: string, final = false): void {
180
- this.finalFlushRequested ||= final;
181
- this.pendingFlushUrls.add(url);
182
- void this.flushPending();
183
- }
184
-
185
- private async flushPending(): Promise<void> {
186
- if (this.flushing) return;
187
- if (typeof window === "undefined" || typeof fetch !== "function") {
188
- this.finishFinalFlushIfNeeded();
189
- return;
190
- }
191
-
192
- this.flushing = true;
193
-
194
- try {
195
- const urls = [...this.pendingFlushUrls];
196
- this.pendingFlushUrls.clear();
197
- for (const url of urls) {
198
- if (!(await this.sendMetricsForUrl(url)))
199
- this.pendingFlushUrls.add(url);
200
- }
201
- } finally {
202
- this.flushing = false;
203
- this.finishFinalFlushIfNeeded();
204
- }
205
- }
206
-
207
- private async sendMetricsForUrl(url: string): Promise<boolean> {
208
- const metrics = this.metricsByUrl.get(url);
209
- if (!metrics || metrics.size === 0) return true;
210
-
211
- const vitals = [...metrics.entries()].map(([metric, data]) => ({
212
- metric,
213
- value: data.value,
214
- attributes: data.attributes,
215
- }));
216
- const body = JSON.stringify({
217
- sessionId: getOrCreateSessionId(),
218
- vitals,
219
- metadata: { url },
220
- });
118
+ private async flush(): Promise<void> {
119
+ if (typeof window === "undefined" || typeof fetch !== "function") return;
120
+ if (!this.metrics.size) return;
221
121
 
222
- this.log(`Sending metrics for ${url} (${vitals.length})`);
122
+ const vitals = Array.from(this.metrics.values());
123
+ const url = this.url;
124
+ this.metrics.clear();
223
125
 
224
126
  try {
225
127
  const controller = new AbortController();
226
- const timeout = window.setTimeout(() => controller.abort(), 3000);
227
-
228
- try {
229
- const response = await fetch(this.endpoint, {
230
- method: "POST",
231
- body,
232
- headers: {
233
- "Content-Type": "application/json",
234
- Authorization: `Bearer ${this.options.siteKey}`,
235
- },
236
- keepalive: true,
237
- signal: controller.signal,
238
- });
239
-
240
- if (!response.ok) {
241
- throw new Error(`HTTP ${response.status}`);
242
- }
243
-
244
- this.metricsByUrl.delete(url);
245
- } finally {
246
- clearTimeout(timeout);
128
+ const timeoutId = window.setTimeout(() => controller.abort(), 3000);
129
+
130
+ const response = await fetch(this.endpoint, {
131
+ method: "POST",
132
+ body: JSON.stringify({
133
+ sessionId: getOrCreateSessionId(),
134
+ vitals,
135
+ metadata: { url },
136
+ }),
137
+ headers: {
138
+ "Content-Type": "application/json",
139
+ Authorization: `Bearer ${this.options.siteKey}`,
140
+ },
141
+ keepalive: true,
142
+ signal: controller.signal,
143
+ });
144
+
145
+ clearTimeout(timeoutId);
146
+
147
+ if (!response.ok) {
148
+ throw new Error(`HTTP ${response.status}`);
247
149
  }
248
- return true;
249
150
  } catch (error) {
250
151
  this.log("Failed to send metrics", error);
251
- return false;
252
152
  }
253
153
  }
254
154
 
255
- private finishFinalFlushIfNeeded(): void {
256
- if (!this.finalFlushRequested) return;
257
- this.finalFlushRequested = false;
258
- this.started = false;
259
- this.cleanupListeners();
155
+ private removeListeners(): void {
156
+ document.removeEventListener("visibilitychange", this.onHidden);
157
+ window.removeEventListener("pagehide", this.onPageHide);
260
158
  }
261
159
  }
@@ -362,6 +362,7 @@ describe("WebAnalytics lifecycle", () => {
362
362
  expect(checkCall?.body).toContain('"key":"beta"');
363
363
  expect(checkCall?.body).toContain('"externalId":"user_1"');
364
364
  expect(checkCall?.body).toContain('"identifier":');
365
+ expect(checkCall?.body).toContain('"sessionId":');
365
366
  });
366
367
 
367
368
  test("navigation sends page leave and navigation pageview", async () => {
@@ -398,10 +399,13 @@ describe("WebAnalytics lifecycle", () => {
398
399
  });
399
400
 
400
401
  await analytics.start();
401
- const storage = globalThis.sessionStorage as unknown as MockStorage;
402
- const sessionId = storage.getItem("session_id");
402
+ const storage = globalThis.localStorage as unknown as MockStorage;
403
+ const sessionId = storage.getItem("faststats_session_id");
403
404
  expect(sessionId).toBeTruthy();
404
- storage.setItem("session_timestamp", "0");
405
+ storage.setItem(
406
+ "faststats_session_activity",
407
+ (Date.now() - 31 * 60 * 1000).toString(),
408
+ );
405
409
  harness.fetchCalls.length = 0;
406
410
 
407
411
  Object.defineProperty(globalThis.document, "visibilityState", {
@@ -419,7 +423,7 @@ describe("WebAnalytics lifecycle", () => {
419
423
  }
420
424
  await wait(0);
421
425
 
422
- expect(storage.getItem("session_id")).toBe(sessionId);
426
+ expect(storage.getItem("faststats_session_id")).toBe(sessionId);
423
427
  const pageLeaveCall = harness.fetchCalls.find((call) =>
424
428
  call.body?.includes('"event":"page_leave"'),
425
429
  );
@@ -1,10 +1,10 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import {
3
+ getAnonymousId,
3
4
  getOrCreateAnonymousId,
4
- getOrCreateSessionId,
5
- getSessionStart,
6
- resetSessionId,
5
+ resetAnonymousId,
7
6
  } from "../src/utils/identifiers";
7
+ import { setCookielessMode } from "../src/utils/session-manager";
8
8
 
9
9
  class MockStorage {
10
10
  private readonly data = new Map<string, string>();
@@ -32,17 +32,16 @@ function setGlobal(name: keyof typeof globalThis, value: unknown): void {
32
32
 
33
33
  const original = {
34
34
  localStorage: globalThis.localStorage,
35
- sessionStorage: globalThis.sessionStorage,
36
35
  };
37
36
 
38
37
  beforeEach(() => {
39
38
  setGlobal("localStorage", new MockStorage());
40
- setGlobal("sessionStorage", new MockStorage());
39
+ setCookielessMode(false);
41
40
  });
42
41
 
43
42
  afterEach(() => {
44
43
  setGlobal("localStorage", original.localStorage);
45
- setGlobal("sessionStorage", original.sessionStorage);
44
+ setCookielessMode(false);
46
45
  });
47
46
 
48
47
  describe("identifiers", () => {
@@ -53,43 +52,27 @@ describe("identifiers", () => {
53
52
  expect(second).toBe(id);
54
53
  });
55
54
 
56
- test("session id is recreated when timestamp is invalid", () => {
57
- const storage = globalThis.sessionStorage as unknown as MockStorage;
58
- storage.setItem("session_id", "old-session");
59
- storage.setItem("session_timestamp", "NaN");
60
- const id = getOrCreateSessionId();
61
- expect(id).not.toBe("old-session");
62
- });
63
-
64
- test("session start falls back when stored value is invalid", () => {
65
- const storage = globalThis.sessionStorage as unknown as MockStorage;
66
- storage.setItem("session_start", "NaN");
67
- const start = getSessionStart();
68
- expect(Number.isNaN(start)).toBe(true);
69
- });
70
-
71
- test("resetSessionId creates a new session id", () => {
72
- const first = getOrCreateSessionId();
73
- const second = resetSessionId();
55
+ test("resetAnonymousId creates a new anonymous id", () => {
56
+ const first = getOrCreateAnonymousId();
57
+ const second = resetAnonymousId();
74
58
  expect(second.length).toBeGreaterThan(0);
75
59
  expect(second).not.toBe(first);
76
60
  });
77
61
 
78
- test("storage access failures disable persisted identifiers", () => {
62
+ test("cookieless mode skips anonymous id persistence", () => {
63
+ setCookielessMode(true);
64
+ expect(getAnonymousId()).toBe("");
65
+ });
66
+
67
+ test("storage access failures disable persisted anonymous id", () => {
79
68
  Object.defineProperty(globalThis, "localStorage", {
80
69
  configurable: true,
81
70
  get() {
82
71
  throw new Error("storage blocked");
83
72
  },
84
73
  });
85
- Object.defineProperty(globalThis, "sessionStorage", {
86
- configurable: true,
87
- get() {
88
- throw new Error("storage blocked");
89
- },
90
- });
91
74
 
92
75
  expect(getOrCreateAnonymousId()).toBe("");
93
- expect(getOrCreateSessionId()).toBe("");
76
+ expect(resetAnonymousId()).toBe("");
94
77
  });
95
78
  });
@@ -1,7 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { gunzipSync } from "node:zlib";
3
3
  import ReplayTracker from "../src/replay";
4
- import { getOrCreateSessionId } from "../src/utils/identifiers";
4
+ import {
5
+ getOrCreateSessionId,
6
+ type SessionContext,
7
+ setDefaultSiteKey,
8
+ } from "../src/utils/session-manager";
5
9
 
6
10
  function decodeBody(data: string | Uint8Array): string {
7
11
  if (typeof data === "string") return data;
@@ -32,12 +36,16 @@ type ReplayEvent = {
32
36
 
33
37
  type ReplayBatch = {
34
38
  token: string;
35
- sessionId?: string;
39
+ sessionId: string;
40
+ windowId: string;
41
+ viewId: string;
42
+ sessionStart: number;
36
43
  identifier?: string;
37
44
  batchId: string;
38
45
  sequence: number;
39
46
  timestamp: number;
40
47
  url: string;
48
+ isFinal?: boolean;
41
49
  events: ReplayEvent[];
42
50
  };
43
51
 
@@ -49,7 +57,12 @@ type ReplayTrackerInternals = {
49
57
  retryTask: ReturnType<typeof setTimeout> | null;
50
58
  startTime: number;
51
59
  onEvent: (event: ReplayEvent, isCheckout?: boolean) => void;
52
- flush: (lowLatency: boolean) => Promise<void>;
60
+ trackPageChange: (url?: string) => void;
61
+ flush: (
62
+ lowLatency: boolean,
63
+ contextOverride?: SessionContext,
64
+ isFinal?: boolean,
65
+ ) => Promise<void>;
53
66
  send: (
54
67
  data: string | Uint8Array,
55
68
  isCompressed: boolean,
@@ -89,6 +102,7 @@ beforeEach(() => {
89
102
  setGlobal("location", { href: "https://example.com/" } as Location);
90
103
  setGlobal("localStorage", new MockStorage());
91
104
  setGlobal("sessionStorage", new MockStorage());
105
+ setDefaultSiteKey("site_test");
92
106
  setGlobal("navigator", { sendBeacon: () => false });
93
107
  setGlobal(
94
108
  "fetch",
@@ -111,10 +125,25 @@ function createTracker(
111
125
  ): ReplayTrackerInternals {
112
126
  return new ReplayTracker({
113
127
  siteKey: "site_test",
128
+ samplingPercentage: 100,
114
129
  ...options,
115
130
  }) as unknown as ReplayTrackerInternals;
116
131
  }
117
132
 
133
+ async function captureReplayBatch(
134
+ tracker: ReplayTrackerInternals,
135
+ ): Promise<ReplayBatch> {
136
+ const payloads: ReplayBatch[] = [];
137
+ tracker.send = async (data) => {
138
+ payloads.push(JSON.parse(decodeBody(data)) as ReplayBatch);
139
+ return true;
140
+ };
141
+ tracker.startTime = Date.now() - 1000;
142
+ tracker.onEvent({ type: 2, timestamp: Date.now(), data: {} }, false);
143
+ await tracker.flush(false);
144
+ return payloads[0] as ReplayBatch;
145
+ }
146
+
118
147
  describe("ReplayTracker", () => {
119
148
  test("buffers replay events", () => {
120
149
  const tracker = createTracker({
@@ -192,6 +221,9 @@ describe("ReplayTracker", () => {
192
221
  expect(captured.payload).not.toBeNull();
193
222
  expect(captured.payload?.token).toBe("site_test");
194
223
  expect(captured.payload?.sessionId).toBeTruthy();
224
+ expect(captured.payload?.windowId).toBeTruthy();
225
+ expect(captured.payload?.viewId).toBeTruthy();
226
+ expect(captured.payload?.sessionStart).toBeGreaterThan(0);
195
227
  expect(captured.payload?.batchId).toBeTruthy();
196
228
  expect(captured.payload?.events).toHaveLength(1);
197
229
  expect(captured.lowLatency).toBe(true);
@@ -232,15 +264,10 @@ describe("ReplayTracker", () => {
232
264
  return true;
233
265
  };
234
266
 
235
- (tracker as unknown as { sessionId?: string }).sessionId =
236
- getOrCreateSessionId();
267
+ getOrCreateSessionId();
237
268
  tracker.startTime = Date.now() - 1000;
238
269
  tracker.onEvent({ type: 2, timestamp: Date.now(), data: {} }, false);
239
270
  await tracker.flush(false);
240
-
241
- const storage = globalThis.sessionStorage as unknown as MockStorage;
242
- storage.setItem("session_timestamp", "0");
243
-
244
271
  tracker.onEvent({ type: 3, timestamp: Date.now(), data: {} }, false);
245
272
  await tracker.flush(false);
246
273
 
@@ -249,6 +276,149 @@ describe("ReplayTracker", () => {
249
276
  expect(payloads[1]?.sessionId).toBe(payloads[0]?.sessionId);
250
277
  });
251
278
 
279
+ test("uses a new session id after idle timeout", async () => {
280
+ const tracker = createTracker({
281
+ minReplayLengthMs: 0,
282
+ });
283
+
284
+ const payloads: ReplayBatch[] = [];
285
+ tracker.send = async (data) => {
286
+ payloads.push(JSON.parse(decodeBody(data)) as ReplayBatch);
287
+ return true;
288
+ };
289
+
290
+ getOrCreateSessionId();
291
+ tracker.startTime = Date.now() - 1000;
292
+ tracker.onEvent({ type: 2, timestamp: Date.now(), data: {} }, false);
293
+ await tracker.flush(false);
294
+
295
+ const local = globalThis.localStorage as unknown as MockStorage;
296
+ local.setItem(
297
+ "faststats_session_activity",
298
+ (Date.now() - 31 * 60 * 1000).toString(),
299
+ );
300
+
301
+ tracker.onEvent({ type: 3, timestamp: Date.now(), data: {} }, false);
302
+ await tracker.flush(false);
303
+
304
+ tracker.onEvent({ type: 3, timestamp: Date.now(), data: {} }, false);
305
+ await tracker.flush(false);
306
+
307
+ expect(payloads).toHaveLength(3);
308
+ expect(payloads[1]?.sessionId).toBe(payloads[0]?.sessionId);
309
+ expect(payloads[1]?.isFinal).toBe(true);
310
+ expect(payloads[2]?.sessionId).not.toBe(payloads[0]?.sessionId);
311
+ });
312
+
313
+ test("finalizes queued events under previous session after idle timeout", async () => {
314
+ const tracker = createTracker({
315
+ minReplayLengthMs: 0,
316
+ });
317
+
318
+ const payloads: ReplayBatch[] = [];
319
+ tracker.send = async (data) => {
320
+ payloads.push(JSON.parse(decodeBody(data)) as ReplayBatch);
321
+ return true;
322
+ };
323
+
324
+ const previousSessionId = getOrCreateSessionId();
325
+ const local = globalThis.localStorage as unknown as MockStorage;
326
+ local.setItem(
327
+ "faststats_session_activity",
328
+ (Date.now() - 31 * 60 * 1000).toString(),
329
+ );
330
+
331
+ tracker.startTime = Date.now() - 1000;
332
+ tracker.onEvent({ type: 2, timestamp: Date.now(), data: {} }, false);
333
+ await tracker.flush(false);
334
+
335
+ expect(payloads).toHaveLength(1);
336
+ expect(payloads[0]?.sessionId).toBe(previousSessionId);
337
+ expect(payloads[0]?.isFinal).toBe(true);
338
+ });
339
+
340
+ test("keeps the same window and view ids across replay batches", async () => {
341
+ const tracker = createTracker({
342
+ minReplayLengthMs: 0,
343
+ });
344
+
345
+ const payloads: ReplayBatch[] = [];
346
+ tracker.send = async (data) => {
347
+ payloads.push(JSON.parse(decodeBody(data)) as ReplayBatch);
348
+ return true;
349
+ };
350
+ getOrCreateSessionId();
351
+ tracker.startTime = Date.now() - 1000;
352
+
353
+ tracker.onEvent({ type: 2, timestamp: Date.now(), data: {} }, false);
354
+ await tracker.flush(false);
355
+ tracker.onEvent({ type: 3, timestamp: Date.now(), data: {} }, false);
356
+ await tracker.flush(false);
357
+
358
+ expect(payloads).toHaveLength(2);
359
+ expect(payloads[1]?.windowId).toBe(payloads[0]?.windowId);
360
+ expect(payloads[1]?.viewId).toBe(payloads[0]?.viewId);
361
+ expect(payloads[1]?.sequence).toBe((payloads[0]?.sequence ?? 0) + 1);
362
+ });
363
+
364
+ test("window id is stable across replay tracker remounts", async () => {
365
+ const sharedSession = new MockStorage();
366
+ sharedSession.setItem("faststats_window_id_site_test", "stable-window");
367
+
368
+ setGlobal("sessionStorage", sharedSession);
369
+
370
+ const first = createTracker({ minReplayLengthMs: 0 });
371
+ const second = createTracker({ minReplayLengthMs: 0 });
372
+
373
+ const firstBatch = await captureReplayBatch(first);
374
+ const secondBatch = await captureReplayBatch(second);
375
+
376
+ expect(firstBatch.windowId).toBe("stable-window");
377
+ expect(secondBatch.windowId).toBe("stable-window");
378
+ });
379
+
380
+ test("same session across tabs with different window ids", async () => {
381
+ const sharedLocal = new MockStorage();
382
+ sharedLocal.setItem("faststats_session_id", "shared-session");
383
+ sharedLocal.setItem("faststats_session_activity", Date.now().toString());
384
+ sharedLocal.setItem("faststats_session_start", Date.now().toString());
385
+
386
+ setGlobal("localStorage", sharedLocal);
387
+ setGlobal("sessionStorage", new MockStorage());
388
+
389
+ const first = createTracker({ minReplayLengthMs: 0 });
390
+ const firstBatch = await captureReplayBatch(first);
391
+
392
+ setGlobal("sessionStorage", new MockStorage());
393
+ const second = createTracker({ minReplayLengthMs: 0 });
394
+ const secondBatch = await captureReplayBatch(second);
395
+
396
+ expect(firstBatch.sessionId).toBe("shared-session");
397
+ expect(secondBatch.sessionId).toBe("shared-session");
398
+ expect(firstBatch.windowId).not.toBe(secondBatch.windowId);
399
+ });
400
+
401
+ test("trackPageChange rotates viewId and emits meta event", () => {
402
+ const tracker = createTracker();
403
+ (tracker as unknown as { started: boolean }).started = true;
404
+ const firstViewId = (tracker as unknown as { viewId: string }).viewId;
405
+
406
+ tracker.trackPageChange("https://example.com/about");
407
+
408
+ const viewEvent = tracker.events.find(
409
+ (e) =>
410
+ (e.data as Record<string, unknown> | undefined)?.tag ===
411
+ "faststats:view",
412
+ );
413
+ expect(viewEvent).toBeDefined();
414
+ const payload = (viewEvent?.data as Record<string, unknown> | undefined)
415
+ ?.payload as Record<string, unknown> | undefined;
416
+ expect(payload?.href).toBe("https://example.com/about");
417
+ expect((tracker as unknown as { viewId: string }).viewId).not.toBe(
418
+ firstViewId,
419
+ );
420
+ });
421
+
252
422
  test("sends replay as plain JSON when CompressionStream is unavailable", async () => {
253
423
  const tracker = createTracker({
254
424
  minReplayLengthMs: 0,
@@ -332,7 +502,7 @@ describe("ReplayTracker", () => {
332
502
  tracker.startTime = Date.now() - 1000;
333
503
  tracker.onEvent({ type: 2, timestamp: Date.now(), data: {} }, false);
334
504
 
335
- await tracker.flush(true);
505
+ await tracker.flush(true, undefined, true);
336
506
 
337
507
  expect(compressedFlag).toBe(true);
338
508
  expect(lowLatencyFlag).toBe(true);