@faststats/web 0.2.10 → 0.2.12
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/dist/chunks/error-CttYL43D.js +2 -0
- package/dist/chunks/identifiers-CQeWm7wi.js +1 -0
- package/dist/chunks/replay-BrMLCiBF.js +1 -0
- package/dist/chunks/{replay-IhsP2Ab4.d.ts → replay-BuX3_0xs.d.ts} +1 -0
- package/dist/chunks/send-data-DL_GlsQw.js +1 -0
- package/dist/chunks/types-CYzR5xtT.js +1 -0
- package/dist/chunks/web-vitals-CjA1bFLG.js +1 -0
- package/dist/error.d.ts +3 -0
- 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 +6 -3
- 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 +13 -3
- package/dist/web-vitals.js +1 -1
- package/package.json +1 -1
- package/src/analytics.ts +57 -115
- package/src/error.ts +38 -16
- package/src/replay.ts +13 -2
- package/src/utils/identifiers.ts +38 -26
- package/src/utils/send-data.ts +52 -0
- package/src/web-vitals.ts +157 -49
- package/tests/analytics.test.ts +35 -0
- package/tests/identifiers.test.ts +18 -0
- package/tests/replay.test.ts +25 -0
- package/tests/web-vitals.test.ts +208 -0
- package/dist/chunks/analytics-ur06JW5D.js +0 -1
- package/dist/chunks/error-ClMugucs.js +0 -2
- package/dist/chunks/replay-DQHUrcod.js +0 -1
- package/dist/chunks/types-C3vW7XGe.js +0 -1
- package/dist/chunks/web-vitals-BooBPWJC.js +0 -1
- /package/dist/chunks/{feature-flags-6rZlmhfu.d.ts → feature-flags-BxZz_lNm.d.ts} +0 -0
- /package/dist/chunks/{feature-flags-CqVtrpX2.js → feature-flags-CjnLZGxp.js} +0 -0
package/src/utils/identifiers.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
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";
|
|
2
6
|
|
|
3
7
|
let cookielessMode = false;
|
|
4
8
|
|
|
@@ -13,65 +17,73 @@ export function getAnonymousId(cookieless?: boolean): string {
|
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export function getOrCreateAnonymousId(): string {
|
|
16
|
-
if (
|
|
17
|
-
const existingId = localStorage.getItem(
|
|
20
|
+
if (!localStorage) return "";
|
|
21
|
+
const existingId = localStorage.getItem(ANONYMOUS_ID_KEY);
|
|
18
22
|
if (existingId) return existingId;
|
|
19
23
|
|
|
20
24
|
const newId = crypto.randomUUID();
|
|
21
|
-
localStorage.setItem(
|
|
25
|
+
localStorage.setItem(ANONYMOUS_ID_KEY, newId);
|
|
22
26
|
return newId;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export function resetAnonymousId(cookieless?: boolean): string {
|
|
26
|
-
if (cookieless
|
|
27
|
-
|
|
30
|
+
if (cookieless) return "";
|
|
31
|
+
|
|
32
|
+
if (!localStorage) return "";
|
|
33
|
+
|
|
34
|
+
localStorage.removeItem(ANONYMOUS_ID_KEY);
|
|
28
35
|
return getOrCreateAnonymousId();
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export function getOrCreateSessionId(): string {
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
const
|
|
39
|
+
if (!sessionStorage) return "";
|
|
40
|
+
|
|
41
|
+
const existingId = sessionStorage.getItem(SESSION_ID_KEY);
|
|
42
|
+
const sessionTimestamp = sessionStorage.getItem(SESSION_TIMESTAMP_KEY);
|
|
35
43
|
|
|
36
44
|
if (existingId && sessionTimestamp) {
|
|
37
45
|
const sessionAge = Date.now() - Number.parseInt(sessionTimestamp, 10);
|
|
38
46
|
if (sessionAge < SESSION_TIMEOUT) {
|
|
39
|
-
sessionStorage.setItem(
|
|
47
|
+
sessionStorage.setItem(SESSION_TIMESTAMP_KEY, Date.now().toString());
|
|
40
48
|
return existingId;
|
|
41
49
|
}
|
|
42
|
-
sessionStorage
|
|
43
|
-
sessionStorage.removeItem("session_timestamp");
|
|
44
|
-
sessionStorage.removeItem("session_start");
|
|
50
|
+
clearSession(sessionStorage);
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
const now = Date.now().toString();
|
|
48
54
|
const newId = crypto.randomUUID();
|
|
49
|
-
sessionStorage.setItem(
|
|
50
|
-
sessionStorage.setItem(
|
|
51
|
-
sessionStorage.setItem(
|
|
55
|
+
sessionStorage.setItem(SESSION_ID_KEY, newId);
|
|
56
|
+
sessionStorage.setItem(SESSION_TIMESTAMP_KEY, now);
|
|
57
|
+
sessionStorage.setItem(SESSION_START_KEY, now);
|
|
52
58
|
return newId;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
export function resetSessionId(): string {
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
sessionStorage
|
|
59
|
-
sessionStorage.removeItem("session_start");
|
|
62
|
+
if (!sessionStorage) return "";
|
|
63
|
+
|
|
64
|
+
clearSession(sessionStorage);
|
|
60
65
|
return getOrCreateSessionId();
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
export function refreshSessionTimestamp(): void {
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
sessionStorage.setItem(
|
|
69
|
+
if (!sessionStorage) return;
|
|
70
|
+
|
|
71
|
+
if (sessionStorage.getItem(SESSION_ID_KEY)) {
|
|
72
|
+
sessionStorage.setItem(SESSION_TIMESTAMP_KEY, Date.now().toString());
|
|
68
73
|
}
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
export function getSessionStart(): number {
|
|
72
|
-
if (
|
|
73
|
-
|
|
77
|
+
if (!sessionStorage) return Date.now();
|
|
78
|
+
|
|
79
|
+
const start = sessionStorage.getItem(SESSION_START_KEY);
|
|
74
80
|
if (start) return Number.parseInt(start, 10);
|
|
75
|
-
const ts = sessionStorage.getItem(
|
|
81
|
+
const ts = sessionStorage.getItem(SESSION_TIMESTAMP_KEY);
|
|
76
82
|
return ts ? Number.parseInt(ts, 10) : Date.now();
|
|
77
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);
|
|
89
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { SendDataOptions } from "./types";
|
|
2
|
+
|
|
3
|
+
export async function sendData(options: SendDataOptions): Promise<boolean> {
|
|
4
|
+
const {
|
|
5
|
+
url,
|
|
6
|
+
data,
|
|
7
|
+
contentType = "application/json",
|
|
8
|
+
headers = {},
|
|
9
|
+
debug = false,
|
|
10
|
+
debugPrefix = "[Analytics]",
|
|
11
|
+
useBeacon = true,
|
|
12
|
+
keepalive = true,
|
|
13
|
+
} = options;
|
|
14
|
+
|
|
15
|
+
if (useBeacon && typeof globalThis.navigator?.sendBeacon === "function") {
|
|
16
|
+
try {
|
|
17
|
+
const beaconBody =
|
|
18
|
+
data instanceof Blob
|
|
19
|
+
? data
|
|
20
|
+
: typeof Blob !== "undefined"
|
|
21
|
+
? new Blob([data as BlobPart], { type: contentType })
|
|
22
|
+
: data;
|
|
23
|
+
if (globalThis.navigator.sendBeacon(url, beaconBody as BodyInit)) {
|
|
24
|
+
if (debug) console.log(`${debugPrefix} Sent via beacon`);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetch(url, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
body: data as BodyInit,
|
|
34
|
+
headers: {
|
|
35
|
+
"Content-Type": contentType,
|
|
36
|
+
...headers,
|
|
37
|
+
},
|
|
38
|
+
keepalive,
|
|
39
|
+
});
|
|
40
|
+
const success = response.ok;
|
|
41
|
+
if (debug) {
|
|
42
|
+
const message = success
|
|
43
|
+
? `${debugPrefix} Sent via fetch`
|
|
44
|
+
: `${debugPrefix} Failed: ${response.status}`;
|
|
45
|
+
console[success ? "log" : "warn"](message);
|
|
46
|
+
}
|
|
47
|
+
return success;
|
|
48
|
+
} catch {
|
|
49
|
+
if (debug) console.warn(`${debugPrefix} Failed to send`);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/web-vitals.ts
CHANGED
|
@@ -19,25 +19,54 @@ type MetricState = {
|
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
type AnyMetric = Metric | MetricWithAttribution;
|
|
22
|
-
type
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
];
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
export default class WebVitalsTracker {
|
|
36
59
|
private readonly endpoint: string;
|
|
37
|
-
private readonly
|
|
60
|
+
private readonly metricsByUrl = new Map<
|
|
61
|
+
string,
|
|
62
|
+
Map<MetricName, MetricState>
|
|
63
|
+
>();
|
|
64
|
+
private readonly pendingFlushUrls = new Set<string>();
|
|
38
65
|
private readonly sampled: boolean;
|
|
39
66
|
private started = false;
|
|
40
|
-
private
|
|
67
|
+
private flushing = false;
|
|
68
|
+
private finalFlushRequested = false;
|
|
69
|
+
private currentUrl = "";
|
|
41
70
|
|
|
42
71
|
constructor(private readonly options: WebVitalsOptions) {
|
|
43
72
|
this.endpoint = webVitalsEventsUrl(options.baseUrl);
|
|
@@ -56,33 +85,50 @@ export default class WebVitalsTracker {
|
|
|
56
85
|
|
|
57
86
|
start(): void {
|
|
58
87
|
if (this.started || typeof window === "undefined") return;
|
|
88
|
+
|
|
59
89
|
this.started = true;
|
|
60
|
-
this.
|
|
90
|
+
this.currentUrl = window.location.href;
|
|
61
91
|
|
|
62
|
-
document.addEventListener("visibilitychange", this.onVisibilityChange
|
|
63
|
-
|
|
92
|
+
document.addEventListener("visibilitychange", this.onVisibilityChange, {
|
|
93
|
+
passive: true,
|
|
94
|
+
});
|
|
95
|
+
window.addEventListener("pagehide", this.onPageHide, { passive: true });
|
|
64
96
|
|
|
65
97
|
this.log("Tracking started");
|
|
66
|
-
|
|
67
98
|
void this.observe();
|
|
68
99
|
}
|
|
69
100
|
|
|
70
101
|
stop(): void {
|
|
71
102
|
if (!this.started || typeof window === "undefined") return;
|
|
103
|
+
|
|
72
104
|
this.started = false;
|
|
105
|
+
this.cleanupListeners();
|
|
106
|
+
this.queueFlush(this.currentUrl, true);
|
|
107
|
+
}
|
|
73
108
|
|
|
74
|
-
|
|
75
|
-
window
|
|
109
|
+
trackPageChange(url?: string): void {
|
|
110
|
+
if (!this.started || typeof window === "undefined") return;
|
|
111
|
+
const nextUrl = url ?? window.location.href;
|
|
112
|
+
if (nextUrl === this.currentUrl) return;
|
|
76
113
|
|
|
77
|
-
this.
|
|
114
|
+
const previousUrl = this.currentUrl;
|
|
115
|
+
this.currentUrl = nextUrl;
|
|
116
|
+
this.queueFlush(previousUrl);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private cleanupListeners(): void {
|
|
120
|
+
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
121
|
+
window.removeEventListener("pagehide", this.onPageHide);
|
|
78
122
|
}
|
|
79
123
|
|
|
80
124
|
private async observe(): Promise<void> {
|
|
81
125
|
try {
|
|
82
126
|
const observers = await loadObservers(this.options.attribution ?? false);
|
|
127
|
+
|
|
83
128
|
if (!this.started) return;
|
|
84
|
-
|
|
85
|
-
|
|
129
|
+
|
|
130
|
+
for (const { observe, options } of observers) {
|
|
131
|
+
observe(this.captureMetric, options);
|
|
86
132
|
}
|
|
87
133
|
} catch (error) {
|
|
88
134
|
this.log("Failed to load web-vitals", error);
|
|
@@ -90,17 +136,24 @@ export default class WebVitalsTracker {
|
|
|
90
136
|
}
|
|
91
137
|
|
|
92
138
|
private onVisibilityChange = (): void => {
|
|
93
|
-
if (document.visibilityState === "hidden")
|
|
139
|
+
if (document.visibilityState === "hidden") {
|
|
140
|
+
this.queueFlush(this.currentUrl, true);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
private onPageHide = (): void => {
|
|
145
|
+
this.queueFlush(this.currentUrl, true);
|
|
94
146
|
};
|
|
95
147
|
|
|
96
148
|
private captureMetric = (metric: AnyMetric): void => {
|
|
97
|
-
if (this.
|
|
149
|
+
if (!this.started || !this.sampled) return;
|
|
98
150
|
|
|
99
151
|
const name = metric.name as MetricName;
|
|
100
152
|
const attribution =
|
|
101
153
|
(metric as MetricWithAttribution).attribution ?? undefined;
|
|
154
|
+
const metrics = this.metricsForUrl(this.currentUrl);
|
|
102
155
|
|
|
103
|
-
|
|
156
|
+
metrics.set(name, {
|
|
104
157
|
value: metric.value,
|
|
105
158
|
attributes: {
|
|
106
159
|
id: metric.id,
|
|
@@ -114,40 +167,95 @@ export default class WebVitalsTracker {
|
|
|
114
167
|
this.log(`${name} captured: ${metric.value}`);
|
|
115
168
|
};
|
|
116
169
|
|
|
117
|
-
private
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
}
|
|
120
184
|
|
|
121
|
-
|
|
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]) => ({
|
|
122
212
|
metric,
|
|
123
213
|
value: data.value,
|
|
124
214
|
attributes: data.attributes,
|
|
125
215
|
}));
|
|
126
|
-
const metricNames = vitals.map((v) => v.metric).join(", ");
|
|
127
|
-
this.metrics.clear();
|
|
128
|
-
|
|
129
216
|
const body = JSON.stringify({
|
|
130
217
|
sessionId: getOrCreateSessionId(),
|
|
131
218
|
vitals,
|
|
132
|
-
metadata: {
|
|
133
|
-
url: window.location.href,
|
|
134
|
-
},
|
|
219
|
+
metadata: { url },
|
|
135
220
|
});
|
|
136
221
|
|
|
137
|
-
this.log(`Sending
|
|
222
|
+
this.log(`Sending metrics for ${url} (${vitals.length})`);
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const controller = new AbortController();
|
|
226
|
+
const timeout = window.setTimeout(() => controller.abort(), 3000);
|
|
138
227
|
|
|
139
|
-
|
|
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
|
+
});
|
|
140
239
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
throw new Error(`HTTP ${response.status}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.metricsByUrl.delete(url);
|
|
245
|
+
} finally {
|
|
246
|
+
clearTimeout(timeout);
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
this.log("Failed to send metrics", error);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private finishFinalFlushIfNeeded(): void {
|
|
256
|
+
if (!this.finalFlushRequested) return;
|
|
257
|
+
this.finalFlushRequested = false;
|
|
258
|
+
this.started = false;
|
|
259
|
+
this.cleanupListeners();
|
|
260
|
+
}
|
|
153
261
|
}
|
package/tests/analytics.test.ts
CHANGED
|
@@ -391,6 +391,41 @@ describe("WebAnalytics lifecycle", () => {
|
|
|
391
391
|
).toBe(true);
|
|
392
392
|
});
|
|
393
393
|
|
|
394
|
+
test("visibility hide refreshes stale session before page leave", async () => {
|
|
395
|
+
const analytics = new WebAnalytics({
|
|
396
|
+
siteKey: "site_test",
|
|
397
|
+
autoTrack: false,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
await analytics.start();
|
|
401
|
+
const storage = globalThis.sessionStorage as unknown as MockStorage;
|
|
402
|
+
const sessionId = storage.getItem("session_id");
|
|
403
|
+
expect(sessionId).toBeTruthy();
|
|
404
|
+
storage.setItem("session_timestamp", "0");
|
|
405
|
+
harness.fetchCalls.length = 0;
|
|
406
|
+
|
|
407
|
+
Object.defineProperty(globalThis.document, "visibilityState", {
|
|
408
|
+
configurable: true,
|
|
409
|
+
value: "hidden",
|
|
410
|
+
});
|
|
411
|
+
for (const listener of harness.documentTarget.listeners.get(
|
|
412
|
+
"visibilitychange",
|
|
413
|
+
) ?? []) {
|
|
414
|
+
if (typeof listener === "function") {
|
|
415
|
+
listener(new Event("visibilitychange"));
|
|
416
|
+
} else {
|
|
417
|
+
listener.handleEvent(new Event("visibilitychange"));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
await wait(0);
|
|
421
|
+
|
|
422
|
+
expect(storage.getItem("session_id")).toBe(sessionId);
|
|
423
|
+
const pageLeaveCall = harness.fetchCalls.find((call) =>
|
|
424
|
+
call.body?.includes('"event":"page_leave"'),
|
|
425
|
+
);
|
|
426
|
+
expect(pageLeaveCall?.body).toContain(`"sessionId":"${sessionId}"`);
|
|
427
|
+
});
|
|
428
|
+
|
|
394
429
|
test("resolveReplayTrackerOptions honors sessionReplays config", () => {
|
|
395
430
|
const options = resolveReplayTrackerOptions(
|
|
396
431
|
{
|
|
@@ -74,4 +74,22 @@ describe("identifiers", () => {
|
|
|
74
74
|
expect(second.length).toBeGreaterThan(0);
|
|
75
75
|
expect(second).not.toBe(first);
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
test("storage access failures disable persisted identifiers", () => {
|
|
79
|
+
Object.defineProperty(globalThis, "localStorage", {
|
|
80
|
+
configurable: true,
|
|
81
|
+
get() {
|
|
82
|
+
throw new Error("storage blocked");
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
Object.defineProperty(globalThis, "sessionStorage", {
|
|
86
|
+
configurable: true,
|
|
87
|
+
get() {
|
|
88
|
+
throw new Error("storage blocked");
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(getOrCreateAnonymousId()).toBe("");
|
|
93
|
+
expect(getOrCreateSessionId()).toBe("");
|
|
94
|
+
});
|
|
77
95
|
});
|
package/tests/replay.test.ts
CHANGED
|
@@ -34,6 +34,7 @@ type ReplayBatch = {
|
|
|
34
34
|
token: string;
|
|
35
35
|
sessionId?: string;
|
|
36
36
|
identifier?: string;
|
|
37
|
+
batchId: string;
|
|
37
38
|
sequence: number;
|
|
38
39
|
timestamp: number;
|
|
39
40
|
url: string;
|
|
@@ -191,11 +192,35 @@ describe("ReplayTracker", () => {
|
|
|
191
192
|
expect(captured.payload).not.toBeNull();
|
|
192
193
|
expect(captured.payload?.token).toBe("site_test");
|
|
193
194
|
expect(captured.payload?.sessionId).toBeTruthy();
|
|
195
|
+
expect(captured.payload?.batchId).toBeTruthy();
|
|
194
196
|
expect(captured.payload?.events).toHaveLength(1);
|
|
195
197
|
expect(captured.lowLatency).toBe(true);
|
|
196
198
|
expect(tracker.pending.length).toBe(0);
|
|
197
199
|
});
|
|
198
200
|
|
|
201
|
+
test("emits stable batch ids while queued for retry", async () => {
|
|
202
|
+
const tracker = createTracker({
|
|
203
|
+
minReplayLengthMs: 0,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const payloads: ReplayBatch[] = [];
|
|
207
|
+
tracker.send = async (data) => {
|
|
208
|
+
payloads.push(JSON.parse(decodeBody(data)) as ReplayBatch);
|
|
209
|
+
return payloads.length > 1;
|
|
210
|
+
};
|
|
211
|
+
tracker.startTime = Date.now() - 1000;
|
|
212
|
+
tracker.onEvent({ type: 2, timestamp: Date.now(), data: {} }, false);
|
|
213
|
+
|
|
214
|
+
await tracker.flush(false);
|
|
215
|
+
await tracker.flush(false);
|
|
216
|
+
|
|
217
|
+
expect(payloads).toHaveLength(2);
|
|
218
|
+
expect(payloads[1]?.batchId).toBe(payloads[0]?.batchId);
|
|
219
|
+
expect(payloads[1]?.sequence).toBe(payloads[0]?.sequence);
|
|
220
|
+
|
|
221
|
+
if (tracker.retryTask) clearTimeout(tracker.retryTask);
|
|
222
|
+
});
|
|
223
|
+
|
|
199
224
|
test("keeps the same replay session id across later batches", async () => {
|
|
200
225
|
const tracker = createTracker({
|
|
201
226
|
minReplayLengthMs: 0,
|