@faststats/web 0.1.9 → 0.2.1
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 +16 -0
- package/dist/analytics-Bi40uydz.js +1 -0
- package/dist/error-i6Z4Acxj.js +2 -0
- package/dist/module.d.ts +29 -2
- package/dist/module.js +1 -1
- package/dist/replay-CLQirdnl.js +1 -0
- package/dist/types-Df70G0eM.js +1 -0
- package/dist/web-vitals-gcUR-TX_.js +1 -0
- package/package.json +1 -3
- package/src/analytics.ts +42 -5
- package/src/error.ts +131 -175
- package/src/feature-flags.ts +80 -0
- package/src/module.ts +9 -0
- package/src/replay.ts +242 -467
- package/src/utils/api-urls.ts +11 -0
- package/src/web-vitals.ts +58 -120
- package/dist/analytics-B1kgY_Yf.js +0 -1
- package/dist/error-Cs5wnTbZ.js +0 -2
- package/dist/replay-DWCkIH4r.js +0 -1
- package/dist/types-8FXsUqbi.js +0 -1
- package/dist/web-vitals-C7H69Xv3.js +0 -1
- package/tests/replay-stress.test.ts +0 -24
package/src/replay.ts
CHANGED
|
@@ -33,570 +33,345 @@ export interface ReplayTrackerOptions {
|
|
|
33
33
|
recordConsole?: boolean;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
36
|
+
type ReplayBatch = {
|
|
37
|
+
token: string;
|
|
38
|
+
sessionId: string | undefined;
|
|
39
|
+
identifier?: string;
|
|
40
|
+
sequence: number;
|
|
41
|
+
timestamp: number;
|
|
42
|
+
url: string;
|
|
43
|
+
events: eventWithTime[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const defaultSampling: recordOptions<eventWithTime>["sampling"] = {
|
|
47
|
+
mousemove: 50,
|
|
48
|
+
mouseInteraction: true,
|
|
49
|
+
scroll: 150,
|
|
50
|
+
media: 800,
|
|
51
|
+
input: "last",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const defaultSlimDOMOptions: SlimDOMOptions = {
|
|
55
|
+
script: true,
|
|
56
|
+
comment: true,
|
|
57
|
+
headFavicon: true,
|
|
58
|
+
headWhitespace: true,
|
|
59
|
+
headMetaDescKeywords: true,
|
|
60
|
+
headMetaSocial: true,
|
|
61
|
+
headMetaRobots: true,
|
|
62
|
+
headMetaHttpEquiv: true,
|
|
63
|
+
headMetaAuthorship: true,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export default class ReplayTracker {
|
|
50
67
|
private readonly endpoint: string;
|
|
51
|
-
private readonly
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
private readonly
|
|
55
|
-
private readonly
|
|
56
|
-
private readonly
|
|
57
|
-
|
|
58
|
-
private readonly maskInputOptions?: recordOptions<eventWithTime>["maskInputOptions"];
|
|
59
|
-
private readonly blockClass?: string;
|
|
60
|
-
private readonly blockSelector?: string;
|
|
61
|
-
private readonly maskTextClass?: string;
|
|
62
|
-
private readonly maskTextSelector?: string;
|
|
63
|
-
private readonly checkoutEveryNms: number;
|
|
64
|
-
private readonly checkoutEveryNth?: number;
|
|
65
|
-
private readonly samplingPercentage: number;
|
|
66
|
-
private readonly recordConsole: boolean;
|
|
67
|
-
|
|
68
|
-
private events: eventWithTime[] = [];
|
|
69
|
-
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
70
|
-
private stopRecording: listenerHandler | undefined = undefined;
|
|
68
|
+
private readonly compressionSupported =
|
|
69
|
+
typeof window !== "undefined" && "CompressionStream" in window;
|
|
70
|
+
|
|
71
|
+
private readonly sampled: boolean;
|
|
72
|
+
private readonly events: eventWithTime[] = [];
|
|
73
|
+
private readonly pending: ReplayBatch[] = [];
|
|
74
|
+
|
|
71
75
|
private started = false;
|
|
72
|
-
private startTime
|
|
73
|
-
private
|
|
74
|
-
private
|
|
75
|
-
private
|
|
76
|
-
private
|
|
77
|
-
private
|
|
78
|
-
private
|
|
79
|
-
|
|
80
|
-
private readonly
|
|
81
|
-
private flushScheduled = false;
|
|
82
|
-
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
83
|
-
private idleFlushId: number | null = null;
|
|
84
|
-
private minLengthFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
-
private pendingRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
86
|
-
private isProcessingPending = false;
|
|
87
|
-
|
|
88
|
-
constructor(options: ReplayTrackerOptions) {
|
|
89
|
-
this.siteKey = options.siteKey;
|
|
76
|
+
private startTime = 0;
|
|
77
|
+
private sequence = 0;
|
|
78
|
+
private intervalId: ReturnType<typeof setInterval> | null = null;
|
|
79
|
+
private flushTask: ReturnType<typeof setTimeout> | null = null;
|
|
80
|
+
private retryTask: ReturnType<typeof setTimeout> | null = null;
|
|
81
|
+
private stopRecording?: listenerHandler;
|
|
82
|
+
private sending = false;
|
|
83
|
+
|
|
84
|
+
constructor(private readonly options: ReplayTrackerOptions) {
|
|
90
85
|
this.endpoint = replayEventsUrl(options.baseUrl);
|
|
91
|
-
this.
|
|
92
|
-
|
|
93
|
-
options.samplingPercentage
|
|
94
|
-
);
|
|
95
|
-
this.flushInterval = options.flushInterval ?? 10_000;
|
|
96
|
-
this.maxEvents = options.maxEvents ?? 500;
|
|
97
|
-
this.sampling = options.sampling ?? {
|
|
98
|
-
mousemove: 50,
|
|
99
|
-
mouseInteraction: true,
|
|
100
|
-
scroll: 150,
|
|
101
|
-
media: 800,
|
|
102
|
-
input: "last",
|
|
103
|
-
};
|
|
104
|
-
this.slimDOMOptions = options.slimDOMOptions ?? {
|
|
105
|
-
script: true,
|
|
106
|
-
comment: true,
|
|
107
|
-
headFavicon: true,
|
|
108
|
-
headWhitespace: true,
|
|
109
|
-
headMetaDescKeywords: true,
|
|
110
|
-
headMetaSocial: true,
|
|
111
|
-
headMetaRobots: true,
|
|
112
|
-
headMetaHttpEquiv: true,
|
|
113
|
-
headMetaAuthorship: true,
|
|
114
|
-
};
|
|
115
|
-
this.maskAllInputs = options.maskAllInputs ?? true;
|
|
116
|
-
this.maskInputOptions = options.maskInputOptions ?? {
|
|
117
|
-
password: true,
|
|
118
|
-
email: true,
|
|
119
|
-
tel: true,
|
|
120
|
-
};
|
|
121
|
-
this.blockClass = options.blockClass;
|
|
122
|
-
this.blockSelector = options.blockSelector;
|
|
123
|
-
this.maskTextClass = options.maskTextClass;
|
|
124
|
-
this.maskTextSelector = options.maskTextSelector;
|
|
125
|
-
this.checkoutEveryNms = options.checkoutEveryNms ?? 60_000;
|
|
126
|
-
this.checkoutEveryNth = options.checkoutEveryNth;
|
|
127
|
-
this.recordConsole = options.recordConsole ?? true;
|
|
128
|
-
this.minReplayLengthMs = options.minReplayLengthMs ?? 3000;
|
|
129
|
-
this.maxPendingBatches = options.maxPendingBatches ?? 30;
|
|
130
|
-
this.sessionSamplingSeed = Math.random() * 100;
|
|
131
|
-
|
|
132
|
-
if (typeof window !== "undefined") {
|
|
133
|
-
this.compressionSupported = "CompressionStream" in window;
|
|
134
|
-
}
|
|
86
|
+
this.sampled =
|
|
87
|
+
Math.random() * 100 <
|
|
88
|
+
normalizeSamplingPercentage(options.samplingPercentage);
|
|
135
89
|
}
|
|
136
90
|
|
|
137
|
-
|
|
138
|
-
|
|
91
|
+
private get debug(): boolean {
|
|
92
|
+
return this.options.debug ?? false;
|
|
93
|
+
}
|
|
139
94
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
}
|
|
95
|
+
private get flushInterval(): number {
|
|
96
|
+
return this.options.flushInterval ?? 10_000;
|
|
97
|
+
}
|
|
145
98
|
|
|
146
|
-
|
|
147
|
-
this.
|
|
99
|
+
private get maxEvents(): number {
|
|
100
|
+
return this.options.maxEvents ?? 500;
|
|
101
|
+
}
|
|
148
102
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
103
|
+
private get maxPendingBatches(): number {
|
|
104
|
+
return this.options.maxPendingBatches ?? 30;
|
|
105
|
+
}
|
|
152
106
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
slimDOMOptions: this.slimDOMOptions,
|
|
157
|
-
maskAllInputs: this.maskAllInputs,
|
|
158
|
-
checkoutEveryNms: this.checkoutEveryNms,
|
|
159
|
-
};
|
|
107
|
+
private get minReplayLengthMs(): number {
|
|
108
|
+
return this.options.minReplayLengthMs ?? 3000;
|
|
109
|
+
}
|
|
160
110
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
this.
|
|
111
|
+
private log(...args: unknown[]): void {
|
|
112
|
+
if (this.debug) console.log("[Replay]", ...args);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
start(): void {
|
|
116
|
+
if (this.started || typeof window === "undefined" || !this.sampled) return;
|
|
117
|
+
|
|
118
|
+
this.started = true;
|
|
119
|
+
this.startTime = Date.now();
|
|
120
|
+
|
|
121
|
+
this.stopRecording = record({
|
|
122
|
+
emit: this.onEvent,
|
|
123
|
+
sampling: this.options.sampling ?? defaultSampling,
|
|
124
|
+
slimDOMOptions: this.options.slimDOMOptions ?? defaultSlimDOMOptions,
|
|
125
|
+
maskAllInputs: this.options.maskAllInputs ?? true,
|
|
126
|
+
maskInputOptions: this.options.maskInputOptions ?? {
|
|
127
|
+
password: true,
|
|
128
|
+
email: true,
|
|
129
|
+
tel: true,
|
|
130
|
+
},
|
|
131
|
+
blockClass: this.options.blockClass,
|
|
132
|
+
blockSelector: this.options.blockSelector,
|
|
133
|
+
maskTextClass: this.options.maskTextClass,
|
|
134
|
+
maskTextSelector: this.options.maskTextSelector,
|
|
135
|
+
checkoutEveryNms: this.options.checkoutEveryNms ?? 60_000,
|
|
136
|
+
checkoutEveryNth: this.options.checkoutEveryNth,
|
|
137
|
+
plugins: [
|
|
138
|
+
getRecordSequentialIdPlugin({ key: RRWEB_SEQUENTIAL_ID_KEY }),
|
|
139
|
+
...((this.options.recordConsole ?? true)
|
|
140
|
+
? [getRecordConsolePlugin()]
|
|
141
|
+
: []),
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
this.intervalId = setInterval(this.requestFlush, this.flushInterval);
|
|
146
|
+
|
|
147
|
+
window.addEventListener("beforeunload", this.onUnload);
|
|
148
|
+
window.addEventListener("pagehide", this.onUnload);
|
|
149
|
+
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
|
150
|
+
|
|
151
|
+
this.log("Recording started");
|
|
196
152
|
}
|
|
197
153
|
|
|
198
154
|
stop(): void {
|
|
199
155
|
if (!this.started) return;
|
|
200
156
|
this.started = false;
|
|
201
157
|
|
|
202
|
-
if (this.debug) {
|
|
203
|
-
console.log("[Replay] Recording stopped");
|
|
204
|
-
}
|
|
205
|
-
|
|
206
158
|
this.stopRecording?.();
|
|
207
159
|
this.stopRecording = undefined;
|
|
208
|
-
this.clearScheduledFlush();
|
|
209
|
-
this.clearMinLengthFlushTimer();
|
|
210
|
-
this.clearPendingRetryTimer();
|
|
211
160
|
|
|
212
|
-
if (this.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
161
|
+
if (this.intervalId) clearInterval(this.intervalId);
|
|
162
|
+
if (this.flushTask) clearTimeout(this.flushTask);
|
|
163
|
+
if (this.retryTask) clearTimeout(this.retryTask);
|
|
216
164
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
);
|
|
165
|
+
this.intervalId = null;
|
|
166
|
+
this.flushTask = null;
|
|
167
|
+
this.retryTask = null;
|
|
168
|
+
|
|
169
|
+
window.removeEventListener("beforeunload", this.onUnload);
|
|
170
|
+
window.removeEventListener("pagehide", this.onUnload);
|
|
171
|
+
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
223
172
|
|
|
224
173
|
if (!this.hasReachedMinLength()) {
|
|
225
|
-
this.events =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
);
|
|
230
|
-
}
|
|
174
|
+
this.events.length = 0;
|
|
175
|
+
this.log(
|
|
176
|
+
`Session too short (${Date.now() - this.startTime}ms), discarding events`,
|
|
177
|
+
);
|
|
231
178
|
return;
|
|
232
179
|
}
|
|
233
180
|
|
|
234
|
-
void this.flush();
|
|
181
|
+
void this.flush(true);
|
|
182
|
+
this.log("Recording stopped");
|
|
235
183
|
}
|
|
236
184
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (isCheckout) {
|
|
241
|
-
this.scheduleFlush();
|
|
242
|
-
} else if (this.events.length >= this.maxEvents) {
|
|
243
|
-
this.scheduleFlush();
|
|
244
|
-
} else if (
|
|
245
|
-
event.type === EventType.FullSnapshot &&
|
|
246
|
-
this.hasReachedMinLength()
|
|
247
|
-
) {
|
|
248
|
-
this.minLengthFlushScheduled = true;
|
|
249
|
-
this.scheduleFlush();
|
|
250
|
-
} else if (!this.minLengthFlushScheduled && this.hasReachedMinLength()) {
|
|
251
|
-
this.minLengthFlushScheduled = true;
|
|
252
|
-
this.scheduleFlush();
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private hasReachedMinLength(): boolean {
|
|
257
|
-
if (this.minReplayLengthMs <= 0) return true;
|
|
258
|
-
return Date.now() - this.startTime >= this.minReplayLengthMs;
|
|
185
|
+
getSessionId(): string | undefined {
|
|
186
|
+
return getOrCreateSessionId();
|
|
259
187
|
}
|
|
260
188
|
|
|
261
|
-
private
|
|
262
|
-
|
|
263
|
-
return;
|
|
264
|
-
|
|
265
|
-
this.flushScheduled = true;
|
|
266
|
-
const runFlush = () => {
|
|
267
|
-
this.flushScheduled = false;
|
|
268
|
-
this.flushTimeout = null;
|
|
269
|
-
this.idleFlushId = null;
|
|
270
|
-
void this.flush();
|
|
271
|
-
};
|
|
189
|
+
private onEvent = (event: eventWithTime, isCheckout?: boolean): void => {
|
|
190
|
+
this.events.push(event);
|
|
272
191
|
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
192
|
+
if (
|
|
193
|
+
isCheckout ||
|
|
194
|
+
this.events.length >= this.maxEvents ||
|
|
195
|
+
(event.type === EventType.FullSnapshot && this.hasReachedMinLength())
|
|
196
|
+
) {
|
|
197
|
+
this.requestFlush();
|
|
278
198
|
}
|
|
199
|
+
};
|
|
279
200
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
private handleUnload = (): void => {
|
|
284
|
-
this.clearScheduledFlush();
|
|
285
|
-
void this.flush({ lowLatency: true });
|
|
201
|
+
private onUnload = (): void => {
|
|
202
|
+
void this.flush(true);
|
|
286
203
|
};
|
|
287
204
|
|
|
288
|
-
private
|
|
205
|
+
private onVisibilityChange = (): void => {
|
|
289
206
|
if (document.visibilityState === "hidden") {
|
|
290
|
-
|
|
291
|
-
clearInterval(this.flushTimer);
|
|
292
|
-
this.flushTimer = null;
|
|
293
|
-
}
|
|
294
|
-
this.clearScheduledFlush();
|
|
295
|
-
void this.flush({ lowLatency: true });
|
|
296
|
-
} else if (document.visibilityState === "visible" && this.started) {
|
|
297
|
-
if (!this.flushTimer) {
|
|
298
|
-
this.flushTimer = setInterval(() => {
|
|
299
|
-
this.scheduleFlush();
|
|
300
|
-
}, this.flushInterval);
|
|
301
|
-
}
|
|
207
|
+
void this.flush(true);
|
|
302
208
|
}
|
|
303
209
|
};
|
|
304
210
|
|
|
305
|
-
private
|
|
306
|
-
|
|
307
|
-
this.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
) {
|
|
311
|
-
window.cancelIdleCallback(this.idleFlushId);
|
|
312
|
-
}
|
|
313
|
-
this.idleFlushId = null;
|
|
314
|
-
|
|
315
|
-
if (this.flushTimeout) {
|
|
316
|
-
clearTimeout(this.flushTimeout);
|
|
317
|
-
this.flushTimeout = null;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
this.flushScheduled = false;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
private scheduleMinLengthFlush(): void {
|
|
324
|
-
if (this.minReplayLengthMs <= 0) return;
|
|
325
|
-
|
|
326
|
-
this.clearMinLengthFlushTimer();
|
|
327
|
-
this.minLengthFlushTimer = setTimeout(() => {
|
|
328
|
-
this.minLengthFlushTimer = null;
|
|
329
|
-
if (this.events.length === 0) return;
|
|
330
|
-
|
|
331
|
-
this.minLengthFlushScheduled = true;
|
|
332
|
-
this.scheduleFlush();
|
|
333
|
-
}, this.minReplayLengthMs);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
private clearMinLengthFlushTimer(): void {
|
|
337
|
-
if (this.minLengthFlushTimer) {
|
|
338
|
-
clearTimeout(this.minLengthFlushTimer);
|
|
339
|
-
this.minLengthFlushTimer = null;
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
private clearPendingRetryTimer(): void {
|
|
344
|
-
if (this.pendingRetryTimer) {
|
|
345
|
-
clearTimeout(this.pendingRetryTimer);
|
|
346
|
-
this.pendingRetryTimer = null;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
private queuePendingBatch(batch: PendingBatch): void {
|
|
351
|
-
if (this.pendingBatches.length >= this.maxPendingBatches) {
|
|
352
|
-
if (this.debug) {
|
|
353
|
-
console.warn(
|
|
354
|
-
`[Replay] Pending batch buffer full, dropping batch ${batch.batch.sequence}`,
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
|
-
this.pendingBatches.push(batch);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
private schedulePendingRetry(delayMs: number): void {
|
|
363
|
-
if (this.pendingRetryTimer) return;
|
|
364
|
-
this.pendingRetryTimer = setTimeout(() => {
|
|
365
|
-
this.pendingRetryTimer = null;
|
|
366
|
-
void this.processPendingBatches();
|
|
367
|
-
}, delayMs);
|
|
211
|
+
private hasReachedMinLength(): boolean {
|
|
212
|
+
return (
|
|
213
|
+
this.minReplayLengthMs <= 0 ||
|
|
214
|
+
Date.now() - this.startTime >= this.minReplayLengthMs
|
|
215
|
+
);
|
|
368
216
|
}
|
|
369
217
|
|
|
370
|
-
private
|
|
371
|
-
if (this.events.length === 0)
|
|
372
|
-
void this.processPendingBatches();
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
218
|
+
private requestFlush = (): void => {
|
|
219
|
+
if (this.flushTask || this.sending || this.events.length === 0) return;
|
|
375
220
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
void this.processPendingBatches();
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
if (this.isFlushing) return;
|
|
387
|
-
this.isFlushing = true;
|
|
221
|
+
this.flushTask = setTimeout(() => {
|
|
222
|
+
this.flushTask = null;
|
|
223
|
+
void this.flush(false);
|
|
224
|
+
}, 0);
|
|
225
|
+
};
|
|
388
226
|
|
|
389
|
-
|
|
390
|
-
|
|
227
|
+
private createBatch(events: eventWithTime[]): ReplayBatch {
|
|
228
|
+
const identifier = getAnonymousId();
|
|
391
229
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
token: this.siteKey,
|
|
230
|
+
return {
|
|
231
|
+
token: this.options.siteKey,
|
|
395
232
|
sessionId: getOrCreateSessionId(),
|
|
396
|
-
...(
|
|
397
|
-
sequence: this.
|
|
233
|
+
...(identifier ? { identifier } : {}),
|
|
234
|
+
sequence: this.sequence++,
|
|
398
235
|
timestamp: Date.now(),
|
|
399
236
|
url: window.location.href,
|
|
400
|
-
events
|
|
237
|
+
events,
|
|
401
238
|
};
|
|
239
|
+
}
|
|
402
240
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
});
|
|
409
|
-
this.isFlushing = false;
|
|
410
|
-
void this.processPendingBatches();
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
241
|
+
private async encodeBatch(
|
|
242
|
+
batch: ReplayBatch,
|
|
243
|
+
lowLatency: boolean,
|
|
244
|
+
): Promise<{ data: Blob; isCompressed: boolean }> {
|
|
245
|
+
const json = JSON.stringify(batch);
|
|
413
246
|
|
|
414
|
-
if (this.
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
247
|
+
if (!this.compressionSupported || lowLatency) {
|
|
248
|
+
return {
|
|
249
|
+
data: new Blob([json], { type: "application/json" }),
|
|
250
|
+
isCompressed: false,
|
|
251
|
+
};
|
|
418
252
|
}
|
|
419
253
|
|
|
420
254
|
try {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (this.debug) {
|
|
430
|
-
console.warn("[Replay] Compression failed, using uncompressed");
|
|
431
|
-
}
|
|
432
|
-
compressed = new Blob([JSON.stringify(batch)], {
|
|
433
|
-
type: "application/json",
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
} else {
|
|
437
|
-
compressed = new Blob([JSON.stringify(batch)], {
|
|
438
|
-
type: "application/json",
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const success = await this.send(compressed, isCompressed, {
|
|
443
|
-
useBeacon: options.lowLatency === true,
|
|
444
|
-
});
|
|
445
|
-
if (!success) {
|
|
446
|
-
this.queuePendingBatch({
|
|
447
|
-
batch,
|
|
448
|
-
isCompressed,
|
|
449
|
-
retries: 0,
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
} catch (error) {
|
|
453
|
-
if (this.debug) {
|
|
454
|
-
console.warn("[Replay] Flush error:", error);
|
|
455
|
-
}
|
|
456
|
-
this.queuePendingBatch({
|
|
457
|
-
batch,
|
|
255
|
+
return {
|
|
256
|
+
data: await this.compress(json),
|
|
257
|
+
isCompressed: true,
|
|
258
|
+
};
|
|
259
|
+
} catch {
|
|
260
|
+
this.log("Compression failed, using uncompressed");
|
|
261
|
+
return {
|
|
262
|
+
data: new Blob([json], { type: "application/json" }),
|
|
458
263
|
isCompressed: false,
|
|
459
|
-
|
|
460
|
-
});
|
|
461
|
-
} finally {
|
|
462
|
-
this.isFlushing = false;
|
|
463
|
-
void this.processPendingBatches();
|
|
264
|
+
};
|
|
464
265
|
}
|
|
465
266
|
}
|
|
466
267
|
|
|
467
|
-
private async
|
|
468
|
-
if (this.
|
|
469
|
-
this.clearPendingRetryTimer();
|
|
470
|
-
this.isProcessingPending = true;
|
|
268
|
+
private async flush(lowLatency: boolean): Promise<void> {
|
|
269
|
+
if (this.sending) return;
|
|
471
270
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
271
|
+
if (this.events.length > 0) {
|
|
272
|
+
if (!this.hasReachedMinLength()) {
|
|
273
|
+
this.log(
|
|
274
|
+
`Too short (${Date.now() - this.startTime}ms), skipping flush`,
|
|
275
|
+
);
|
|
276
|
+
} else {
|
|
277
|
+
this.pending.push(this.createBatch(this.events.splice(0)));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
478
280
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
console.warn(
|
|
482
|
-
`[Replay] Max retries reached, restoring batch ${batch.batch.sequence} to buffer`,
|
|
483
|
-
);
|
|
484
|
-
}
|
|
485
|
-
this.pendingBatches.shift();
|
|
486
|
-
this.events = [...batch.batch.events, ...this.events];
|
|
487
|
-
this.scheduleFlush();
|
|
488
|
-
continue;
|
|
489
|
-
}
|
|
281
|
+
if (this.pending.length === 0) return;
|
|
282
|
+
this.sending = true;
|
|
490
283
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (batch
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
503
|
-
} else {
|
|
504
|
-
blob = new Blob([JSON.stringify(batch.batch)], {
|
|
505
|
-
type: "application/json",
|
|
506
|
-
});
|
|
507
|
-
}
|
|
284
|
+
try {
|
|
285
|
+
while (this.pending.length > 0) {
|
|
286
|
+
const batch = this.pending[0];
|
|
287
|
+
if (!batch) break;
|
|
288
|
+
|
|
289
|
+
const encoded = await this.encodeBatch(batch, lowLatency);
|
|
290
|
+
const ok = await this.send(
|
|
291
|
+
encoded.data,
|
|
292
|
+
encoded.isCompressed,
|
|
293
|
+
lowLatency,
|
|
294
|
+
);
|
|
508
295
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
296
|
+
if (!ok) {
|
|
297
|
+
if (this.pending.length >= this.maxPendingBatches) {
|
|
298
|
+
this.log(`Pending buffer full, dropping batch ${batch.sequence}`);
|
|
299
|
+
this.pending.shift();
|
|
300
|
+
}
|
|
301
|
+
this.scheduleRetry();
|
|
302
|
+
break;
|
|
515
303
|
}
|
|
516
304
|
|
|
517
|
-
this.
|
|
518
|
-
|
|
305
|
+
this.log(`Sent ${batch.events.length} events (seq: ${batch.sequence})`);
|
|
306
|
+
this.pending.shift();
|
|
307
|
+
lowLatency = false;
|
|
519
308
|
}
|
|
520
|
-
} catch {
|
|
521
|
-
const current = this.pendingBatches[0];
|
|
522
|
-
if (this.debug && current) {
|
|
523
|
-
console.warn(`[Replay] Retry ${current.retries} failed`);
|
|
524
|
-
}
|
|
525
|
-
this.schedulePendingRetry(current ? 1000 * current.retries : 1000);
|
|
526
309
|
} finally {
|
|
527
|
-
this.
|
|
310
|
+
this.sending = false;
|
|
528
311
|
}
|
|
529
312
|
}
|
|
530
313
|
|
|
531
|
-
private
|
|
532
|
-
if (
|
|
533
|
-
throw new Error("Compression not supported");
|
|
534
|
-
}
|
|
314
|
+
private scheduleRetry(): void {
|
|
315
|
+
if (this.retryTask) return;
|
|
535
316
|
|
|
536
|
-
|
|
537
|
-
|
|
317
|
+
this.retryTask = setTimeout(() => {
|
|
318
|
+
this.retryTask = null;
|
|
319
|
+
void this.flush(false);
|
|
320
|
+
}, 1000);
|
|
321
|
+
}
|
|
538
322
|
|
|
323
|
+
private async compress(data: string): Promise<Blob> {
|
|
324
|
+
const input = new TextEncoder().encode(data);
|
|
539
325
|
const cs = new CompressionStream("gzip");
|
|
540
326
|
const writer = cs.writable.getWriter();
|
|
541
|
-
writer.write(inputData);
|
|
542
|
-
writer.close();
|
|
543
327
|
|
|
544
|
-
|
|
328
|
+
await writer.write(input);
|
|
329
|
+
await writer.close();
|
|
330
|
+
|
|
331
|
+
const chunks: Uint8Array[] = [];
|
|
545
332
|
const reader = cs.readable.getReader();
|
|
546
333
|
|
|
547
334
|
while (true) {
|
|
548
335
|
const { done, value } = await reader.read();
|
|
549
336
|
if (done) break;
|
|
550
|
-
if (value)
|
|
337
|
+
if (value) chunks.push(value);
|
|
551
338
|
}
|
|
552
339
|
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
);
|
|
557
|
-
const compressed = new Uint8Array(totalLength);
|
|
340
|
+
const size = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
341
|
+
const output = new Uint8Array(size);
|
|
342
|
+
|
|
558
343
|
let offset = 0;
|
|
559
|
-
for (const chunk of
|
|
560
|
-
|
|
344
|
+
for (const chunk of chunks) {
|
|
345
|
+
output.set(chunk, offset);
|
|
561
346
|
offset += chunk.length;
|
|
562
347
|
}
|
|
563
348
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
}
|
|
349
|
+
this.log(
|
|
350
|
+
`Compressed: ${input.length} → ${output.length} bytes (${(
|
|
351
|
+
(output.length / input.length) * 100
|
|
352
|
+
).toFixed(1)}%)`,
|
|
353
|
+
);
|
|
570
354
|
|
|
571
|
-
return new Blob([
|
|
355
|
+
return new Blob([output], { type: "application/octet-stream" });
|
|
572
356
|
}
|
|
573
357
|
|
|
574
|
-
private
|
|
358
|
+
private send(
|
|
575
359
|
data: Blob,
|
|
576
360
|
isCompressed: boolean,
|
|
577
|
-
|
|
361
|
+
lowLatency: boolean,
|
|
578
362
|
): Promise<boolean> {
|
|
579
|
-
const url = isCompressed ? `${this.endpoint}?encoding=gzip` : this.endpoint;
|
|
580
|
-
const sizeKB = (data.size / 1024).toFixed(1);
|
|
581
|
-
|
|
582
363
|
return (
|
|
583
364
|
sendData({
|
|
584
|
-
url,
|
|
365
|
+
url: isCompressed ? `${this.endpoint}?encoding=gzip` : this.endpoint,
|
|
585
366
|
data,
|
|
586
367
|
contentType: isCompressed
|
|
587
368
|
? "application/octet-stream"
|
|
588
369
|
: "application/json",
|
|
589
370
|
debug: this.debug,
|
|
590
|
-
debugPrefix: `[Replay] ${
|
|
591
|
-
useBeacon:
|
|
592
|
-
keepalive:
|
|
371
|
+
debugPrefix: `[Replay] ${(data.size / 1024).toFixed(1)}KB`,
|
|
372
|
+
useBeacon: lowLatency,
|
|
373
|
+
keepalive: lowLatency,
|
|
593
374
|
}) ?? Promise.resolve(false)
|
|
594
375
|
);
|
|
595
376
|
}
|
|
596
|
-
|
|
597
|
-
getSessionId(): string | undefined {
|
|
598
|
-
return getOrCreateSessionId();
|
|
599
|
-
}
|
|
600
377
|
}
|
|
601
|
-
|
|
602
|
-
export default ReplayTracker;
|