@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.
- package/CHANGELOG.md +13 -0
- package/REPLAY_PAYLOAD.md +64 -0
- package/dist/chunks/api-urls-DaeYkG0_.js +1 -0
- package/dist/chunks/error-Cd9PTS5v.js +2 -0
- package/dist/chunks/{feature-flags-BxZz_lNm.d.ts → feature-flags-BClx56v5.d.ts} +1 -0
- package/dist/chunks/feature-flags-DSOCIZHK.js +1 -0
- package/dist/chunks/{replay-BuX3_0xs.d.ts → replay-DvJYurEC.d.ts} +10 -2
- package/dist/chunks/replay-rTqcjOo2.js +1 -0
- package/dist/chunks/send-data-B2fYGj6v.js +1 -0
- package/dist/chunks/session-manager-Cy63ptPF.js +1 -0
- package/dist/chunks/web-vitals-Be-Cg4Po.js +1 -0
- package/dist/error.d.ts +5 -8
- package/dist/error.js +1 -1
- package/dist/feature-flags.d.ts +1 -1
- package/dist/feature-flags.js +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/replay.d.ts +1 -1
- package/dist/replay.js +1 -1
- package/dist/web-vitals.d.ts +7 -15
- package/dist/web-vitals.js +1 -1
- package/package.json +4 -7
- package/src/analytics.ts +25 -14
- package/src/error.ts +110 -158
- package/src/feature-flags.ts +5 -2
- package/src/replay.ts +179 -52
- package/src/utils/api-urls.ts +7 -20
- package/src/utils/identifiers.ts +30 -72
- package/src/utils/session-manager.ts +416 -0
- package/src/web-vitals.ts +66 -168
- package/tests/analytics.test.ts +8 -4
- package/tests/identifiers.test.ts +15 -32
- package/tests/replay.test.ts +180 -10
- package/tests/session-manager.test.ts +161 -0
- package/dist/chunks/api-urls-BrkcoElX.js +0 -1
- package/dist/chunks/error-CttYL43D.js +0 -2
- package/dist/chunks/feature-flags-CjnLZGxp.js +0 -1
- package/dist/chunks/identifiers-CQeWm7wi.js +0 -1
- package/dist/chunks/replay-BrMLCiBF.js +0 -1
- package/dist/chunks/send-data-DL_GlsQw.js +0 -1
- package/dist/chunks/web-vitals-CjA1bFLG.js +0 -1
- 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 {
|
|
3
|
-
import { getOrCreateSessionId } from "./utils/
|
|
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
|
|
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
|
|
68
|
-
private finalFlushRequested = false;
|
|
69
|
-
private currentUrl = "";
|
|
28
|
+
private url = "";
|
|
70
29
|
|
|
71
30
|
constructor(private readonly options: WebVitalsOptions) {
|
|
72
|
-
this.endpoint =
|
|
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.
|
|
49
|
+
this.url = window.location.href;
|
|
91
50
|
|
|
92
|
-
document.addEventListener("visibilitychange", this.
|
|
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.
|
|
106
|
-
this.
|
|
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
|
|
115
|
-
this.
|
|
116
|
-
this.queueFlush(previousUrl);
|
|
117
|
-
}
|
|
82
|
+
const nextUrl = url ?? window.location.href;
|
|
83
|
+
if (nextUrl === this.url) return;
|
|
118
84
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
window.removeEventListener("pagehide", this.onPageHide);
|
|
85
|
+
void this.flush();
|
|
86
|
+
this.url = nextUrl;
|
|
122
87
|
}
|
|
123
88
|
|
|
124
|
-
private
|
|
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.
|
|
91
|
+
void this.flush();
|
|
141
92
|
}
|
|
142
93
|
};
|
|
143
94
|
|
|
144
95
|
private onPageHide = (): void => {
|
|
145
|
-
this.
|
|
96
|
+
void this.flush();
|
|
146
97
|
};
|
|
147
98
|
|
|
148
|
-
private
|
|
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
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
this.
|
|
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
|
}
|
package/tests/analytics.test.ts
CHANGED
|
@@ -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.
|
|
402
|
-
const sessionId = storage.getItem("
|
|
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(
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
setCookielessMode(false);
|
|
41
40
|
});
|
|
42
41
|
|
|
43
42
|
afterEach(() => {
|
|
44
43
|
setGlobal("localStorage", original.localStorage);
|
|
45
|
-
|
|
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("
|
|
57
|
-
const
|
|
58
|
-
|
|
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("
|
|
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(
|
|
76
|
+
expect(resetAnonymousId()).toBe("");
|
|
94
77
|
});
|
|
95
78
|
});
|
package/tests/replay.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
(
|
|
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);
|