@faststats/web 0.0.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/README.md +4 -0
- package/dist/error.iife.js +3 -0
- package/dist/error.js +3 -0
- package/dist/module.d.ts +502 -0
- package/dist/module.js +403 -0
- package/dist/replay.iife.js +29 -0
- package/dist/replay.js +29 -0
- package/dist/script.iife.js +1 -0
- package/dist/script.js +1 -0
- package/dist/web-vitals.iife.js +1 -0
- package/dist/web-vitals.js +1 -0
- package/package.json +38 -0
- package/src/analytics.ts +544 -0
- package/src/error.ts +324 -0
- package/src/index.ts +95 -0
- package/src/module.ts +6 -0
- package/src/replay.ts +488 -0
- package/src/utils/identifiers.ts +70 -0
- package/src/utils/types.ts +13 -0
- package/src/web-vitals.ts +207 -0
- package/tsconfig.json +30 -0
- package/tsdown.config.ts +51 -0
- package/worker/index.ts +22 -0
- package/wrangler.toml +8 -0
package/src/replay.ts
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { getRecordConsolePlugin } from "@rrweb/rrweb-plugin-console-record";
|
|
2
|
+
import type { eventWithTime, listenerHandler } from "@rrweb/types";
|
|
3
|
+
import { record } from "rrweb";
|
|
4
|
+
import type { recordOptions } from "rrweb/typings/types";
|
|
5
|
+
import type { SlimDOMOptions } from "rrweb-snapshot";
|
|
6
|
+
import {
|
|
7
|
+
normalizeSamplingPercentage,
|
|
8
|
+
type SendDataOptions,
|
|
9
|
+
} from "./utils/types";
|
|
10
|
+
|
|
11
|
+
export interface ReplayTrackerOptions {
|
|
12
|
+
siteKey: string;
|
|
13
|
+
endpoint?: string;
|
|
14
|
+
debug?: boolean;
|
|
15
|
+
samplingPercentage?: number;
|
|
16
|
+
flushInterval?: number;
|
|
17
|
+
maxEvents?: number;
|
|
18
|
+
sampling?: recordOptions<eventWithTime>["sampling"];
|
|
19
|
+
slimDOMOptions?: SlimDOMOptions;
|
|
20
|
+
maskAllInputs?: boolean;
|
|
21
|
+
maskInputOptions?: recordOptions<eventWithTime>["maskInputOptions"];
|
|
22
|
+
blockClass?: string;
|
|
23
|
+
blockSelector?: string;
|
|
24
|
+
maskTextClass?: string;
|
|
25
|
+
maskTextSelector?: string;
|
|
26
|
+
checkoutEveryNms?: number;
|
|
27
|
+
checkoutEveryNth?: number;
|
|
28
|
+
recordConsole?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ReplayTrackerPublicInstance = {
|
|
32
|
+
start(): void;
|
|
33
|
+
stop(): void;
|
|
34
|
+
getSessionId(): string | undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type ReplayTrackerConstructor = new (
|
|
38
|
+
options: ReplayTrackerOptions,
|
|
39
|
+
) => ReplayTrackerPublicInstance;
|
|
40
|
+
|
|
41
|
+
interface PendingBatch {
|
|
42
|
+
batch: {
|
|
43
|
+
token: string;
|
|
44
|
+
sessionId: string | undefined;
|
|
45
|
+
sequence: number;
|
|
46
|
+
timestamp: number;
|
|
47
|
+
url: string;
|
|
48
|
+
events: eventWithTime[];
|
|
49
|
+
};
|
|
50
|
+
isCompressed: boolean;
|
|
51
|
+
retries: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class ReplayTracker {
|
|
55
|
+
private readonly endpoint: string;
|
|
56
|
+
private readonly siteKey: string;
|
|
57
|
+
private readonly debug: boolean;
|
|
58
|
+
private readonly flushInterval: number;
|
|
59
|
+
private readonly maxEvents: number;
|
|
60
|
+
private readonly sampling: recordOptions<eventWithTime>["sampling"];
|
|
61
|
+
private readonly slimDOMOptions: SlimDOMOptions;
|
|
62
|
+
private readonly maskAllInputs: boolean;
|
|
63
|
+
private readonly maskInputOptions?: recordOptions<eventWithTime>["maskInputOptions"];
|
|
64
|
+
private readonly blockClass?: string;
|
|
65
|
+
private readonly blockSelector?: string;
|
|
66
|
+
private readonly maskTextClass?: string;
|
|
67
|
+
private readonly maskTextSelector?: string;
|
|
68
|
+
private readonly checkoutEveryNms: number;
|
|
69
|
+
private readonly checkoutEveryNth?: number;
|
|
70
|
+
private readonly samplingPercentage: number;
|
|
71
|
+
private readonly recordConsole: boolean;
|
|
72
|
+
|
|
73
|
+
private events: eventWithTime[] = [];
|
|
74
|
+
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
75
|
+
private stopRecording: listenerHandler | undefined = undefined;
|
|
76
|
+
private started = false;
|
|
77
|
+
private startTime: number = 0;
|
|
78
|
+
private sequenceNumber = 0;
|
|
79
|
+
private pendingBatches: PendingBatch[] = [];
|
|
80
|
+
private isFlushing = false;
|
|
81
|
+
private compressionSupported = false;
|
|
82
|
+
private readonly sessionSamplingSeed: number;
|
|
83
|
+
private readonly minReplayLengthMs = 3000;
|
|
84
|
+
|
|
85
|
+
constructor(options: ReplayTrackerOptions) {
|
|
86
|
+
this.siteKey = options.siteKey;
|
|
87
|
+
this.endpoint =
|
|
88
|
+
options.endpoint?.replace(/\/v1\/web$/, "/v1/replay") ??
|
|
89
|
+
"https://metrics.faststats.dev/v1/replay";
|
|
90
|
+
this.debug = options.debug ?? false;
|
|
91
|
+
this.samplingPercentage = normalizeSamplingPercentage(
|
|
92
|
+
options.samplingPercentage,
|
|
93
|
+
);
|
|
94
|
+
this.flushInterval = options.flushInterval ?? 10_000;
|
|
95
|
+
this.maxEvents = options.maxEvents ?? 500;
|
|
96
|
+
this.sampling = options.sampling ?? {
|
|
97
|
+
mousemove: 50,
|
|
98
|
+
mouseInteraction: true,
|
|
99
|
+
scroll: 150,
|
|
100
|
+
media: 800,
|
|
101
|
+
input: "last",
|
|
102
|
+
};
|
|
103
|
+
this.slimDOMOptions = options.slimDOMOptions ?? {
|
|
104
|
+
script: true,
|
|
105
|
+
comment: true,
|
|
106
|
+
headFavicon: true,
|
|
107
|
+
headWhitespace: true,
|
|
108
|
+
headMetaDescKeywords: true,
|
|
109
|
+
headMetaSocial: true,
|
|
110
|
+
headMetaRobots: true,
|
|
111
|
+
headMetaHttpEquiv: true,
|
|
112
|
+
headMetaAuthorship: true,
|
|
113
|
+
};
|
|
114
|
+
this.maskAllInputs = options.maskAllInputs ?? true;
|
|
115
|
+
this.maskInputOptions = options.maskInputOptions ?? {
|
|
116
|
+
password: true,
|
|
117
|
+
email: true,
|
|
118
|
+
tel: true,
|
|
119
|
+
};
|
|
120
|
+
this.blockClass = options.blockClass;
|
|
121
|
+
this.blockSelector = options.blockSelector;
|
|
122
|
+
this.maskTextClass = options.maskTextClass;
|
|
123
|
+
this.maskTextSelector = options.maskTextSelector;
|
|
124
|
+
this.checkoutEveryNms = options.checkoutEveryNms ?? 60_000;
|
|
125
|
+
this.checkoutEveryNth = options.checkoutEveryNth;
|
|
126
|
+
this.recordConsole = options.recordConsole ?? true;
|
|
127
|
+
this.sessionSamplingSeed = Math.random() * 100;
|
|
128
|
+
|
|
129
|
+
if (typeof window !== "undefined") {
|
|
130
|
+
this.compressionSupported = "CompressionStream" in window;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
start(): void {
|
|
135
|
+
if (this.started || typeof window === "undefined") return;
|
|
136
|
+
if (window.__FA_isTrackingDisabled?.()) {
|
|
137
|
+
if (this.debug) {
|
|
138
|
+
console.log("[Replay] Tracking disabled via localStorage");
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (this.samplingPercentage < 100) {
|
|
144
|
+
if (this.sessionSamplingSeed >= this.samplingPercentage) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.started = true;
|
|
150
|
+
this.startTime = Date.now();
|
|
151
|
+
|
|
152
|
+
if (this.debug) {
|
|
153
|
+
console.log("[Replay] Recording started");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const recordOptions: recordOptions<eventWithTime> = {
|
|
157
|
+
emit: (event, isCheckout) => this.handleEvent(event, isCheckout),
|
|
158
|
+
sampling: this.sampling,
|
|
159
|
+
slimDOMOptions: this.slimDOMOptions,
|
|
160
|
+
maskAllInputs: this.maskAllInputs,
|
|
161
|
+
checkoutEveryNms: this.checkoutEveryNms,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (this.maskInputOptions) {
|
|
165
|
+
recordOptions.maskInputOptions = this.maskInputOptions;
|
|
166
|
+
}
|
|
167
|
+
if (this.blockClass) {
|
|
168
|
+
recordOptions.blockClass = this.blockClass;
|
|
169
|
+
}
|
|
170
|
+
if (this.blockSelector) {
|
|
171
|
+
recordOptions.blockSelector = this.blockSelector;
|
|
172
|
+
}
|
|
173
|
+
if (this.maskTextClass) {
|
|
174
|
+
recordOptions.maskTextClass = this.maskTextClass;
|
|
175
|
+
}
|
|
176
|
+
if (this.maskTextSelector) {
|
|
177
|
+
recordOptions.maskTextSelector = this.maskTextSelector;
|
|
178
|
+
}
|
|
179
|
+
if (this.checkoutEveryNth) {
|
|
180
|
+
recordOptions.checkoutEveryNth = this.checkoutEveryNth;
|
|
181
|
+
}
|
|
182
|
+
if (this.recordConsole) {
|
|
183
|
+
recordOptions.plugins = [getRecordConsolePlugin()];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.stopRecording = record(recordOptions);
|
|
187
|
+
|
|
188
|
+
this.flushTimer = setInterval(() => {
|
|
189
|
+
this.scheduleFlush();
|
|
190
|
+
}, this.flushInterval);
|
|
191
|
+
|
|
192
|
+
window.addEventListener("beforeunload", this.handleUnload);
|
|
193
|
+
window.addEventListener("pagehide", this.handleUnload);
|
|
194
|
+
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
stop(): void {
|
|
198
|
+
if (!this.started) return;
|
|
199
|
+
this.started = false;
|
|
200
|
+
|
|
201
|
+
if (this.debug) {
|
|
202
|
+
console.log("[Replay] Recording stopped");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
this.stopRecording?.();
|
|
206
|
+
this.stopRecording = undefined;
|
|
207
|
+
|
|
208
|
+
if (this.flushTimer) {
|
|
209
|
+
clearInterval(this.flushTimer);
|
|
210
|
+
this.flushTimer = null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
window.removeEventListener("beforeunload", this.handleUnload);
|
|
214
|
+
window.removeEventListener("pagehide", this.handleUnload);
|
|
215
|
+
document.removeEventListener(
|
|
216
|
+
"visibilitychange",
|
|
217
|
+
this.handleVisibilityChange,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const elapsedTime = Date.now() - this.startTime;
|
|
221
|
+
if (elapsedTime < this.minReplayLengthMs) {
|
|
222
|
+
this.events = [];
|
|
223
|
+
if (this.debug) {
|
|
224
|
+
console.log(
|
|
225
|
+
`[Replay] Session too short (${elapsedTime}ms), discarding events`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.flush();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private handleEvent(event: eventWithTime, isCheckout?: boolean): void {
|
|
235
|
+
this.events.push(event);
|
|
236
|
+
|
|
237
|
+
if (isCheckout) {
|
|
238
|
+
this.scheduleFlush();
|
|
239
|
+
} else if (this.events.length >= this.maxEvents) {
|
|
240
|
+
this.scheduleFlush();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private scheduleFlush(): void {
|
|
245
|
+
if (this.isFlushing || this.events.length === 0) return;
|
|
246
|
+
|
|
247
|
+
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
|
|
248
|
+
window.requestIdleCallback(() => this.flush(), { timeout: 2000 });
|
|
249
|
+
} else {
|
|
250
|
+
setTimeout(() => this.flush(), 0);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private handleUnload = (): void => {
|
|
255
|
+
this.flush();
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
private handleVisibilityChange = (): void => {
|
|
259
|
+
if (document.visibilityState === "hidden") {
|
|
260
|
+
if (this.flushTimer) {
|
|
261
|
+
clearInterval(this.flushTimer);
|
|
262
|
+
this.flushTimer = null;
|
|
263
|
+
}
|
|
264
|
+
this.flush();
|
|
265
|
+
} else if (document.visibilityState === "visible" && this.started) {
|
|
266
|
+
if (!this.flushTimer) {
|
|
267
|
+
this.flushTimer = setInterval(() => {
|
|
268
|
+
this.scheduleFlush();
|
|
269
|
+
}, this.flushInterval);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
private async flush(): Promise<void> {
|
|
275
|
+
if (this.events.length === 0) {
|
|
276
|
+
this.processPendingBatches();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const elapsedTime = Date.now() - this.startTime;
|
|
281
|
+
if (elapsedTime < this.minReplayLengthMs) {
|
|
282
|
+
if (this.debug) {
|
|
283
|
+
console.log(`[Replay] Too short (${elapsedTime}ms), skipping`);
|
|
284
|
+
}
|
|
285
|
+
this.processPendingBatches();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (this.isFlushing) return;
|
|
290
|
+
this.isFlushing = true;
|
|
291
|
+
|
|
292
|
+
const eventsToSend = this.events;
|
|
293
|
+
this.events = [];
|
|
294
|
+
|
|
295
|
+
const anonymousId = window.__FA_getAnonymousId?.();
|
|
296
|
+
const batch = {
|
|
297
|
+
token: this.siteKey,
|
|
298
|
+
sessionId: window.__FA_getSessionId?.(),
|
|
299
|
+
...(anonymousId ? { identifier: anonymousId } : {}),
|
|
300
|
+
sequence: this.sequenceNumber++,
|
|
301
|
+
timestamp: Date.now(),
|
|
302
|
+
url: window.location.href,
|
|
303
|
+
events: eventsToSend,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
if (this.debug) {
|
|
307
|
+
console.log(
|
|
308
|
+
`[Replay] Sending ${eventsToSend.length} events (seq: ${batch.sequence})`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
let compressed: Blob;
|
|
314
|
+
let isCompressed = false;
|
|
315
|
+
|
|
316
|
+
if (this.compressionSupported) {
|
|
317
|
+
try {
|
|
318
|
+
compressed = await this.compress(JSON.stringify(batch));
|
|
319
|
+
isCompressed = true;
|
|
320
|
+
} catch {
|
|
321
|
+
if (this.debug) {
|
|
322
|
+
console.warn("[Replay] Compression failed, using uncompressed");
|
|
323
|
+
}
|
|
324
|
+
compressed = new Blob([JSON.stringify(batch)], {
|
|
325
|
+
type: "application/json",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
compressed = new Blob([JSON.stringify(batch)], {
|
|
330
|
+
type: "application/json",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const success = await this.send(compressed, isCompressed);
|
|
335
|
+
if (!success) {
|
|
336
|
+
this.pendingBatches.push({
|
|
337
|
+
batch,
|
|
338
|
+
isCompressed,
|
|
339
|
+
retries: 0,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (this.debug) {
|
|
344
|
+
console.warn("[Replay] Flush error:", error);
|
|
345
|
+
}
|
|
346
|
+
this.pendingBatches.push({
|
|
347
|
+
batch,
|
|
348
|
+
isCompressed: false,
|
|
349
|
+
retries: 0,
|
|
350
|
+
});
|
|
351
|
+
} finally {
|
|
352
|
+
this.isFlushing = false;
|
|
353
|
+
this.processPendingBatches();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private async processPendingBatches(): Promise<void> {
|
|
358
|
+
if (this.pendingBatches.length === 0) return;
|
|
359
|
+
|
|
360
|
+
const batch = this.pendingBatches.shift();
|
|
361
|
+
if (!batch) return;
|
|
362
|
+
|
|
363
|
+
if (batch.retries >= 3) {
|
|
364
|
+
if (this.debug) {
|
|
365
|
+
console.warn(
|
|
366
|
+
`[Replay] Max retries reached, dropping batch ${batch.batch.sequence}`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
this.processPendingBatches();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
batch.retries++;
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
let blob: Blob;
|
|
377
|
+
if (batch.isCompressed && this.compressionSupported) {
|
|
378
|
+
try {
|
|
379
|
+
blob = await this.compress(JSON.stringify(batch.batch));
|
|
380
|
+
} catch {
|
|
381
|
+
blob = new Blob([JSON.stringify(batch.batch)], {
|
|
382
|
+
type: "application/json",
|
|
383
|
+
});
|
|
384
|
+
batch.isCompressed = false;
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
blob = new Blob([JSON.stringify(batch.batch)], {
|
|
388
|
+
type: "application/json",
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const success = await this.send(blob, batch.isCompressed);
|
|
393
|
+
if (!success) {
|
|
394
|
+
this.pendingBatches.push(batch);
|
|
395
|
+
setTimeout(() => this.processPendingBatches(), 1000 * batch.retries);
|
|
396
|
+
} else {
|
|
397
|
+
this.processPendingBatches();
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
if (this.debug) {
|
|
401
|
+
console.warn(`[Replay] Retry ${batch.retries} failed`);
|
|
402
|
+
}
|
|
403
|
+
this.pendingBatches.push(batch);
|
|
404
|
+
setTimeout(() => this.processPendingBatches(), 1000 * batch.retries);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private async compress(data: string): Promise<Blob> {
|
|
409
|
+
if (!this.compressionSupported) {
|
|
410
|
+
throw new Error("Compression not supported");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const encoder = new TextEncoder();
|
|
414
|
+
const inputData = encoder.encode(data);
|
|
415
|
+
|
|
416
|
+
const cs = new CompressionStream("gzip");
|
|
417
|
+
const writer = cs.writable.getWriter();
|
|
418
|
+
writer.write(inputData);
|
|
419
|
+
writer.close();
|
|
420
|
+
|
|
421
|
+
const compressedChunks: Uint8Array[] = [];
|
|
422
|
+
const reader = cs.readable.getReader();
|
|
423
|
+
|
|
424
|
+
while (true) {
|
|
425
|
+
const { done, value } = await reader.read();
|
|
426
|
+
if (done) break;
|
|
427
|
+
if (value) compressedChunks.push(value);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const totalLength = compressedChunks.reduce(
|
|
431
|
+
(acc, chunk) => acc + chunk.length,
|
|
432
|
+
0,
|
|
433
|
+
);
|
|
434
|
+
const compressed = new Uint8Array(totalLength);
|
|
435
|
+
let offset = 0;
|
|
436
|
+
for (const chunk of compressedChunks) {
|
|
437
|
+
compressed.set(chunk, offset);
|
|
438
|
+
offset += chunk.length;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (this.debug) {
|
|
442
|
+
const ratio = ((compressed.length / inputData.length) * 100).toFixed(1);
|
|
443
|
+
console.log(
|
|
444
|
+
`[Replay] Compressed: ${inputData.length} → ${compressed.length} bytes (${ratio}%)`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return new Blob([compressed], { type: "application/octet-stream" });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private async send(data: Blob, isCompressed: boolean): Promise<boolean> {
|
|
452
|
+
const url = isCompressed ? `${this.endpoint}?encoding=gzip` : this.endpoint;
|
|
453
|
+
const sizeKB = (data.size / 1024).toFixed(1);
|
|
454
|
+
|
|
455
|
+
return (
|
|
456
|
+
window.__FA_sendData?.({
|
|
457
|
+
url,
|
|
458
|
+
data,
|
|
459
|
+
contentType: isCompressed
|
|
460
|
+
? "application/octet-stream"
|
|
461
|
+
: "application/json",
|
|
462
|
+
headers: isCompressed ? { "Content-Encoding": "gzip" } : undefined,
|
|
463
|
+
debug: this.debug,
|
|
464
|
+
debugPrefix: `[Replay] ${sizeKB}KB`,
|
|
465
|
+
}) ?? Promise.resolve(false)
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
getSessionId(): string | undefined {
|
|
470
|
+
return window.__FA_getSessionId?.();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export default ReplayTracker;
|
|
475
|
+
|
|
476
|
+
declare global {
|
|
477
|
+
interface Window {
|
|
478
|
+
__FA_ReplayTracker?: ReplayTrackerConstructor;
|
|
479
|
+
__FA_getAnonymousId?: () => string;
|
|
480
|
+
__FA_getSessionId?: () => string;
|
|
481
|
+
__FA_sendData?: (options: SendDataOptions) => Promise<boolean>;
|
|
482
|
+
__FA_isTrackingDisabled?: () => boolean;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (typeof window !== "undefined") {
|
|
487
|
+
window.__FA_ReplayTracker = ReplayTracker;
|
|
488
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const SESSION_TIMEOUT = 30 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
export function getAnonymousId(cookieless?: boolean): string {
|
|
4
|
+
if (cookieless) return "";
|
|
5
|
+
return getOrCreateAnonymousId();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getOrCreateAnonymousId(): string {
|
|
9
|
+
if (typeof localStorage === "undefined") return "";
|
|
10
|
+
const existingId = localStorage.getItem("faststats_anon_id");
|
|
11
|
+
if (existingId) return existingId;
|
|
12
|
+
|
|
13
|
+
const newId = crypto.randomUUID();
|
|
14
|
+
localStorage.setItem("faststats_anon_id", newId);
|
|
15
|
+
return newId;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resetAnonymousId(cookieless?: boolean): string {
|
|
19
|
+
if (cookieless || typeof localStorage === "undefined") return "";
|
|
20
|
+
localStorage.removeItem("faststats_anon_id");
|
|
21
|
+
return getOrCreateAnonymousId();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getOrCreateSessionId(): string {
|
|
25
|
+
if (typeof sessionStorage === "undefined") return "";
|
|
26
|
+
const existingId = sessionStorage.getItem("session_id");
|
|
27
|
+
const sessionTimestamp = sessionStorage.getItem("session_timestamp");
|
|
28
|
+
|
|
29
|
+
if (existingId && sessionTimestamp) {
|
|
30
|
+
const sessionAge = Date.now() - Number.parseInt(sessionTimestamp, 10);
|
|
31
|
+
if (sessionAge < SESSION_TIMEOUT) {
|
|
32
|
+
sessionStorage.setItem("session_timestamp", Date.now().toString());
|
|
33
|
+
return existingId;
|
|
34
|
+
}
|
|
35
|
+
sessionStorage.removeItem("session_id");
|
|
36
|
+
sessionStorage.removeItem("session_timestamp");
|
|
37
|
+
sessionStorage.removeItem("session_start");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const now = Date.now().toString();
|
|
41
|
+
const newId = crypto.randomUUID();
|
|
42
|
+
sessionStorage.setItem("session_id", newId);
|
|
43
|
+
sessionStorage.setItem("session_timestamp", now);
|
|
44
|
+
sessionStorage.setItem("session_start", now);
|
|
45
|
+
return newId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resetSessionId(): string {
|
|
49
|
+
if (typeof sessionStorage === "undefined") return "";
|
|
50
|
+
sessionStorage.removeItem("session_id");
|
|
51
|
+
sessionStorage.removeItem("session_timestamp");
|
|
52
|
+
sessionStorage.removeItem("session_start");
|
|
53
|
+
return getOrCreateSessionId();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function refreshSessionTimestamp(): void {
|
|
57
|
+
if (typeof sessionStorage === "undefined") return;
|
|
58
|
+
const existingId = sessionStorage.getItem("session_id");
|
|
59
|
+
if (existingId) {
|
|
60
|
+
sessionStorage.setItem("session_timestamp", Date.now().toString());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getSessionStart(): number {
|
|
65
|
+
if (typeof sessionStorage === "undefined") return Date.now();
|
|
66
|
+
const start = sessionStorage.getItem("session_start");
|
|
67
|
+
if (start) return Number.parseInt(start, 10);
|
|
68
|
+
const ts = sessionStorage.getItem("session_timestamp");
|
|
69
|
+
return ts ? Number.parseInt(ts, 10) : Date.now();
|
|
70
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface SendDataOptions {
|
|
2
|
+
url: string;
|
|
3
|
+
data: string | Blob;
|
|
4
|
+
contentType?: string;
|
|
5
|
+
headers?: Record<string, string>;
|
|
6
|
+
debug?: boolean;
|
|
7
|
+
debugPrefix?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function normalizeSamplingPercentage(value: number | undefined): number {
|
|
11
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 100;
|
|
12
|
+
return Math.max(0, Math.min(100, value));
|
|
13
|
+
}
|