@faststats/web 0.1.3 → 0.1.4
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 +6 -0
- package/dist/analytics-D0RCcMlv.js +1 -0
- package/dist/error-BNd3BvFI.js +2 -0
- package/dist/module.d.ts +34 -18
- package/dist/module.js +1 -1
- package/dist/replay-D9AlW2Jv.js +1 -0
- package/dist/types-8FXsUqbi.js +1 -0
- package/dist/web-vitals-Bj8FoiTR.js +1 -0
- package/package.json +5 -2
- package/src/analytics.ts +303 -129
- package/src/error.ts +120 -116
- package/src/module.ts +1 -0
- package/src/replay.ts +12 -7
- package/src/utils/api-urls.ts +26 -0
- package/src/web-vitals.ts +25 -20
- package/tests/analytics.test.ts +311 -0
- package/tsdown.config.ts +1 -1
- package/dist/analytics-p8pLmlA-.js +0 -1
- package/dist/error-CMM8PYcW.js +0 -3
- package/dist/replay-DeRZXYG3.js +0 -1
- package/dist/types-B5zbhOWK.js +0 -1
- package/dist/web-vitals-DU_i1Ejx.js +0 -1
package/src/error.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { sendData } from "./analytics";
|
|
2
|
+
import { webEventsUrl } from "./utils/api-urls";
|
|
2
3
|
import { getAnonymousId, getOrCreateSessionId } from "./utils/identifiers";
|
|
3
4
|
|
|
4
5
|
export type ErrorEntry = {
|
|
5
6
|
error: string;
|
|
6
7
|
message?: string;
|
|
7
8
|
stack?: string[];
|
|
9
|
+
handled?: boolean;
|
|
8
10
|
cause?: ErrorEntry;
|
|
9
11
|
};
|
|
10
12
|
|
|
@@ -13,23 +15,77 @@ export type ErrorTracking = {
|
|
|
13
15
|
count: number;
|
|
14
16
|
} & ErrorEntry;
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
type ErrorData = {
|
|
17
19
|
message: string;
|
|
18
20
|
filename?: string;
|
|
19
21
|
lineno?: number;
|
|
20
22
|
colno?: number;
|
|
21
23
|
stack?: string;
|
|
22
24
|
type: "error" | "unhandledrejection";
|
|
25
|
+
handled: boolean;
|
|
26
|
+
cause?: ErrorEntry;
|
|
23
27
|
};
|
|
24
28
|
|
|
25
29
|
export interface ErrorTrackingOptions {
|
|
26
30
|
siteKey: string;
|
|
27
|
-
|
|
31
|
+
baseUrl?: string;
|
|
28
32
|
debug?: boolean;
|
|
29
33
|
flushInterval?: number;
|
|
30
34
|
maxQueueSize?: number;
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
const EXTENSION_URL =
|
|
38
|
+
/(?:^|\()(chrome|moz|ms-browser|safari-web)-extension:\/\//;
|
|
39
|
+
|
|
40
|
+
function parseStack(stack?: string): string[] | undefined {
|
|
41
|
+
if (!stack) return undefined;
|
|
42
|
+
return stack
|
|
43
|
+
.split("\n")
|
|
44
|
+
.map((line) => line.trim())
|
|
45
|
+
.filter((line) => line.length > 0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function serializeCause(cause: unknown): ErrorEntry | undefined {
|
|
49
|
+
if (cause instanceof Error) {
|
|
50
|
+
const name = cause.name?.trim() || "Error";
|
|
51
|
+
return {
|
|
52
|
+
error: name,
|
|
53
|
+
message: cause.message,
|
|
54
|
+
stack: parseStack(cause.stack),
|
|
55
|
+
cause: serializeCause(cause.cause),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (typeof cause === "string") {
|
|
59
|
+
return { error: "Error", message: cause };
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function causeFingerprint(cause: ErrorEntry | undefined): string {
|
|
65
|
+
if (!cause) return "";
|
|
66
|
+
return `${cause.error}\0${cause.message ?? ""}\0${causeFingerprint(cause.cause)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function hashErrorData(data: ErrorData): string {
|
|
70
|
+
const key = [
|
|
71
|
+
data.type,
|
|
72
|
+
data.handled ? "handled" : "unhandled",
|
|
73
|
+
data.message,
|
|
74
|
+
data.filename ?? "",
|
|
75
|
+
data.lineno ?? "",
|
|
76
|
+
causeFingerprint(data.cause),
|
|
77
|
+
].join("\0");
|
|
78
|
+
|
|
79
|
+
let a = 2166136261;
|
|
80
|
+
let b = 3598710387;
|
|
81
|
+
for (let i = 0; i < key.length; i++) {
|
|
82
|
+
const c = key.charCodeAt(i);
|
|
83
|
+
a = Math.imul(a ^ c, 16777619);
|
|
84
|
+
b = Math.imul(b ^ c, 2246822519);
|
|
85
|
+
}
|
|
86
|
+
return `err_${(a >>> 0).toString(16).padStart(8, "0")}${(b >>> 0).toString(16).padStart(8, "0")}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
33
89
|
class ErrorTracker {
|
|
34
90
|
private readonly endpoint: string;
|
|
35
91
|
private readonly siteKey: string;
|
|
@@ -40,14 +96,14 @@ class ErrorTracker {
|
|
|
40
96
|
private readonly handledErrors = new WeakSet<Error>();
|
|
41
97
|
private readonly errorCounts = new Map<
|
|
42
98
|
string,
|
|
43
|
-
{ entry: ErrorEntry;
|
|
99
|
+
{ entry: ErrorEntry; count: number }
|
|
44
100
|
>();
|
|
45
101
|
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
46
102
|
private started = false;
|
|
47
103
|
|
|
48
104
|
constructor(options: ErrorTrackingOptions) {
|
|
49
105
|
this.siteKey = options.siteKey;
|
|
50
|
-
this.endpoint = options.
|
|
106
|
+
this.endpoint = webEventsUrl(options.baseUrl);
|
|
51
107
|
this.debug = options.debug ?? false;
|
|
52
108
|
this.flushInterval = options.flushInterval ?? 5000;
|
|
53
109
|
this.maxQueueSize = options.maxQueueSize ?? 50;
|
|
@@ -90,131 +146,91 @@ class ErrorTracker {
|
|
|
90
146
|
}
|
|
91
147
|
}
|
|
92
148
|
|
|
149
|
+
private skipDuplicate(err: unknown): boolean {
|
|
150
|
+
if (!(err instanceof Error)) return false;
|
|
151
|
+
if (this.handledErrors.has(err)) return true;
|
|
152
|
+
this.handledErrors.add(err);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
93
156
|
private handleErrorEvent = (event: ErrorEvent): void => {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (this.debug) {
|
|
99
|
-
console.log(
|
|
100
|
-
"[ErrorTracker] Skipping duplicate error:",
|
|
101
|
-
error.message,
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
return;
|
|
157
|
+
const err = event.error;
|
|
158
|
+
if (this.skipDuplicate(err)) {
|
|
159
|
+
if (this.debug) {
|
|
160
|
+
console.log("[ErrorTracker] Skipping duplicate error:", err.message);
|
|
105
161
|
}
|
|
106
|
-
|
|
162
|
+
return;
|
|
107
163
|
}
|
|
108
164
|
|
|
109
165
|
this.queueError({
|
|
110
|
-
message:
|
|
166
|
+
message:
|
|
167
|
+
event.message || (err instanceof Error ? err.message : "Unknown error"),
|
|
111
168
|
filename: event.filename || undefined,
|
|
112
169
|
lineno: event.lineno || undefined,
|
|
113
170
|
colno: event.colno || undefined,
|
|
114
|
-
stack:
|
|
171
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
115
172
|
type: "error",
|
|
173
|
+
handled: false,
|
|
174
|
+
cause: err instanceof Error ? serializeCause(err.cause) : undefined,
|
|
116
175
|
});
|
|
117
176
|
};
|
|
118
177
|
|
|
119
178
|
private handleRejection = (event: PromiseRejectionEvent): void => {
|
|
120
179
|
const reason = event.reason;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
reason.message,
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
return;
|
|
180
|
+
if (this.skipDuplicate(reason)) {
|
|
181
|
+
if (this.debug) {
|
|
182
|
+
console.log(
|
|
183
|
+
"[ErrorTracker] Skipping duplicate rejection:",
|
|
184
|
+
reason instanceof Error ? reason.message : reason,
|
|
185
|
+
);
|
|
131
186
|
}
|
|
132
|
-
|
|
187
|
+
return;
|
|
133
188
|
}
|
|
134
189
|
|
|
190
|
+
const message =
|
|
191
|
+
reason instanceof Error
|
|
192
|
+
? reason.message
|
|
193
|
+
: typeof reason === "string"
|
|
194
|
+
? reason
|
|
195
|
+
: "Unhandled promise rejection";
|
|
196
|
+
|
|
135
197
|
this.queueError({
|
|
136
|
-
message
|
|
137
|
-
reason instanceof Error
|
|
138
|
-
? reason.message
|
|
139
|
-
: typeof reason === "string"
|
|
140
|
-
? reason
|
|
141
|
-
: "Unhandled promise rejection",
|
|
198
|
+
message,
|
|
142
199
|
stack: reason instanceof Error ? reason.stack : undefined,
|
|
143
200
|
type: "unhandledrejection",
|
|
201
|
+
handled: false,
|
|
202
|
+
cause: reason instanceof Error ? serializeCause(reason.cause) : undefined,
|
|
144
203
|
});
|
|
145
204
|
};
|
|
146
205
|
|
|
147
|
-
private
|
|
148
|
-
if (
|
|
149
|
-
return
|
|
150
|
-
}
|
|
151
|
-
if (errorData.stack) {
|
|
152
|
-
const firstFrame = errorData.stack
|
|
153
|
-
.split("\n")
|
|
154
|
-
.find((line) => line.trim().startsWith("at "));
|
|
155
|
-
if (firstFrame?.includes("chrome-extension://")) {
|
|
156
|
-
return true;
|
|
157
|
-
}
|
|
206
|
+
private queueError(data: ErrorData): void {
|
|
207
|
+
if (EXTENSION_URL.test(`${data.filename ?? ""}\n${data.stack ?? ""}`)) {
|
|
208
|
+
return;
|
|
158
209
|
}
|
|
159
|
-
return false;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
private parseStack(stack?: string): string[] | undefined {
|
|
163
|
-
if (!stack) return undefined;
|
|
164
|
-
return stack
|
|
165
|
-
.split("\n")
|
|
166
|
-
.map((line) => line.trim())
|
|
167
|
-
.filter((line) => line.length > 0);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
private async generateErrorHash(errorData: ErrorData): Promise<string> {
|
|
171
|
-
const key = [
|
|
172
|
-
errorData.type,
|
|
173
|
-
errorData.message,
|
|
174
|
-
errorData.filename ?? "",
|
|
175
|
-
errorData.lineno ?? "",
|
|
176
|
-
].join(":");
|
|
177
|
-
|
|
178
|
-
const encoder = new TextEncoder();
|
|
179
|
-
const data = encoder.encode(key);
|
|
180
|
-
|
|
181
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
182
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
183
|
-
const hashHex = hashArray
|
|
184
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
185
|
-
.join("");
|
|
186
|
-
|
|
187
|
-
return `err_${hashHex}`;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
private async queueError(errorData: ErrorData): Promise<void> {
|
|
191
|
-
if (this.isExtensionError(errorData)) return;
|
|
192
210
|
|
|
193
211
|
if (this.debug) {
|
|
194
|
-
console.log("[ErrorTracker] Captured error:",
|
|
212
|
+
console.log("[ErrorTracker] Captured error:", data);
|
|
195
213
|
}
|
|
196
214
|
|
|
197
|
-
const hash =
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
existing.count++;
|
|
215
|
+
const hash = hashErrorData(data);
|
|
216
|
+
const bucket = this.errorCounts.get(hash);
|
|
217
|
+
if (bucket) {
|
|
218
|
+
bucket.count++;
|
|
202
219
|
if (this.debug) {
|
|
203
220
|
console.log(
|
|
204
|
-
`[ErrorTracker] Incremented count for ${hash} to ${
|
|
221
|
+
`[ErrorTracker] Incremented count for ${hash} to ${bucket.count}`,
|
|
205
222
|
);
|
|
206
223
|
}
|
|
207
224
|
} else {
|
|
208
225
|
const entry: ErrorEntry = {
|
|
209
226
|
error:
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
227
|
+
data.type === "unhandledrejection" ? "UnhandledRejection" : "Error",
|
|
228
|
+
message: data.message,
|
|
229
|
+
stack: parseStack(data.stack),
|
|
230
|
+
handled: data.handled,
|
|
231
|
+
...(data.cause ? { cause: data.cause } : {}),
|
|
215
232
|
};
|
|
216
|
-
this.errorCounts.set(hash, { entry,
|
|
217
|
-
|
|
233
|
+
this.errorCounts.set(hash, { entry, count: 1 });
|
|
218
234
|
if (this.debug) {
|
|
219
235
|
console.log(`[ErrorTracker] Queued new error: ${hash}`);
|
|
220
236
|
}
|
|
@@ -226,21 +242,19 @@ class ErrorTracker {
|
|
|
226
242
|
}
|
|
227
243
|
|
|
228
244
|
captureError(error: Error): void {
|
|
229
|
-
if (this.
|
|
245
|
+
if (this.skipDuplicate(error)) {
|
|
230
246
|
if (this.debug) {
|
|
231
|
-
console.log(
|
|
232
|
-
"[ErrorTracker] Skipping duplicate manual capture:",
|
|
233
|
-
error.message,
|
|
234
|
-
);
|
|
247
|
+
console.log("[ErrorTracker] Skipping duplicate:", error.message);
|
|
235
248
|
}
|
|
236
249
|
return;
|
|
237
250
|
}
|
|
238
|
-
this.handledErrors.add(error);
|
|
239
251
|
|
|
240
252
|
this.queueError({
|
|
241
253
|
message: error.message,
|
|
242
254
|
stack: error.stack,
|
|
243
255
|
type: "error",
|
|
256
|
+
handled: true,
|
|
257
|
+
cause: serializeCause(error.cause),
|
|
244
258
|
});
|
|
245
259
|
}
|
|
246
260
|
|
|
@@ -248,7 +262,7 @@ class ErrorTracker {
|
|
|
248
262
|
if (this.errorCounts.size === 0) return;
|
|
249
263
|
|
|
250
264
|
const errors: ErrorTracking[] = [];
|
|
251
|
-
for (const { entry,
|
|
265
|
+
for (const [hash, { entry, count }] of this.errorCounts) {
|
|
252
266
|
errors.push({ ...entry, hash, count });
|
|
253
267
|
}
|
|
254
268
|
this.errorCounts.clear();
|
|
@@ -258,14 +272,6 @@ class ErrorTracker {
|
|
|
258
272
|
}
|
|
259
273
|
|
|
260
274
|
const identifier = getAnonymousId();
|
|
261
|
-
const commonData =
|
|
262
|
-
typeof document !== "undefined"
|
|
263
|
-
? {
|
|
264
|
-
url: location.href,
|
|
265
|
-
page: location.pathname,
|
|
266
|
-
referrer: document.referrer || null,
|
|
267
|
-
}
|
|
268
|
-
: { url: "", page: "", referrer: null };
|
|
269
275
|
const sourcemapsBuild = (
|
|
270
276
|
globalThis as {
|
|
271
277
|
__SOURCEMAPS_BUILD__?: { buildId?: unknown };
|
|
@@ -277,21 +283,19 @@ class ErrorTracker {
|
|
|
277
283
|
? sourcemapsBuild.buildId
|
|
278
284
|
: undefined;
|
|
279
285
|
|
|
280
|
-
const
|
|
286
|
+
const payloadString = JSON.stringify({
|
|
281
287
|
token: this.siteKey,
|
|
282
288
|
...(identifier ? { userId: identifier } : {}),
|
|
283
289
|
sessionId: getOrCreateSessionId(),
|
|
284
290
|
...(buildId ? { buildId } : {}),
|
|
285
291
|
data: {
|
|
286
|
-
url:
|
|
287
|
-
page:
|
|
288
|
-
referrer:
|
|
289
|
-
title:
|
|
292
|
+
url: location.href,
|
|
293
|
+
page: location.pathname,
|
|
294
|
+
referrer: document.referrer || null,
|
|
295
|
+
title: document.title,
|
|
290
296
|
},
|
|
291
297
|
errors,
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const payloadString = JSON.stringify(payload);
|
|
298
|
+
});
|
|
295
299
|
|
|
296
300
|
if (this.debug) {
|
|
297
301
|
console.log("[ErrorTracker] Payload:", payloadString);
|
package/src/module.ts
CHANGED
package/src/replay.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { getRecordConsolePlugin } from "@rrweb/rrweb-plugin-console-record";
|
|
2
|
+
import { getRecordSequentialIdPlugin } from "@rrweb/rrweb-plugin-sequential-id-record";
|
|
2
3
|
import type { eventWithTime, listenerHandler } from "@rrweb/types";
|
|
3
4
|
import { record } from "rrweb";
|
|
4
5
|
import type { recordOptions } from "rrweb/typings/types";
|
|
5
6
|
import type { SlimDOMOptions } from "rrweb-snapshot";
|
|
6
7
|
import { sendData } from "./analytics";
|
|
8
|
+
import { replayEventsUrl } from "./utils/api-urls";
|
|
7
9
|
import { getAnonymousId, getOrCreateSessionId } from "./utils/identifiers";
|
|
8
10
|
import { normalizeSamplingPercentage } from "./utils/types";
|
|
9
11
|
|
|
12
|
+
const RRWEB_SEQUENTIAL_ID_KEY = "_faststatsSeqId";
|
|
13
|
+
|
|
10
14
|
export interface ReplayTrackerOptions {
|
|
11
15
|
siteKey: string;
|
|
12
|
-
|
|
16
|
+
baseUrl?: string;
|
|
13
17
|
debug?: boolean;
|
|
14
18
|
samplingPercentage?: number;
|
|
15
19
|
flushInterval?: number;
|
|
@@ -75,9 +79,7 @@ class ReplayTracker {
|
|
|
75
79
|
|
|
76
80
|
constructor(options: ReplayTrackerOptions) {
|
|
77
81
|
this.siteKey = options.siteKey;
|
|
78
|
-
this.endpoint =
|
|
79
|
-
options.endpoint?.replace(/\/v1\/web$/, "/v1/replay") ??
|
|
80
|
-
"https://metrics.faststats.dev/v1/replay";
|
|
82
|
+
this.endpoint = replayEventsUrl(options.baseUrl);
|
|
81
83
|
this.debug = options.debug ?? false;
|
|
82
84
|
this.samplingPercentage = normalizeSamplingPercentage(
|
|
83
85
|
options.samplingPercentage,
|
|
@@ -165,9 +167,12 @@ class ReplayTracker {
|
|
|
165
167
|
if (this.checkoutEveryNth) {
|
|
166
168
|
recordOptions.checkoutEveryNth = this.checkoutEveryNth;
|
|
167
169
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
recordOptions.plugins = [
|
|
171
|
+
getRecordSequentialIdPlugin({
|
|
172
|
+
key: RRWEB_SEQUENTIAL_ID_KEY,
|
|
173
|
+
}),
|
|
174
|
+
...(this.recordConsole ? [getRecordConsolePlugin()] : []),
|
|
175
|
+
];
|
|
171
176
|
|
|
172
177
|
this.stopRecording = record(recordOptions);
|
|
173
178
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const DEFAULT_BASE = "https://metrics.faststats.dev";
|
|
2
|
+
|
|
3
|
+
function stripTrailingSlashes(s: string): string {
|
|
4
|
+
return s.replace(/\/+$/, "");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function normalizeAnalyticsBaseUrl(input?: string): string {
|
|
8
|
+
if (input === undefined || input === "") return DEFAULT_BASE;
|
|
9
|
+
return stripTrailingSlashes(input) || DEFAULT_BASE;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function webEventsUrl(baseUrl?: string): string {
|
|
13
|
+
return `${normalizeAnalyticsBaseUrl(baseUrl)}/v1/web`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function identifyEventsUrl(baseUrl?: string): string {
|
|
17
|
+
return `${normalizeAnalyticsBaseUrl(baseUrl)}/v1/identify`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function replayEventsUrl(baseUrl?: string): string {
|
|
21
|
+
return `${normalizeAnalyticsBaseUrl(baseUrl)}/v1/replay`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function webVitalsEventsUrl(baseUrl?: string): string {
|
|
25
|
+
return `${normalizeAnalyticsBaseUrl(baseUrl)}/v1/vitals`;
|
|
26
|
+
}
|
package/src/web-vitals.ts
CHANGED
|
@@ -6,12 +6,13 @@ import {
|
|
|
6
6
|
onLCP,
|
|
7
7
|
onTTFB,
|
|
8
8
|
} from "web-vitals/attribution";
|
|
9
|
+
import { webVitalsEventsUrl } from "./utils/api-urls";
|
|
9
10
|
import { getOrCreateSessionId } from "./utils/identifiers";
|
|
10
11
|
import { normalizeSamplingPercentage } from "./utils/types";
|
|
11
12
|
|
|
12
13
|
export interface WebVitalsOptions {
|
|
13
14
|
siteKey: string;
|
|
14
|
-
|
|
15
|
+
baseUrl?: string;
|
|
15
16
|
debug?: boolean;
|
|
16
17
|
samplingPercentage?: number;
|
|
17
18
|
}
|
|
@@ -34,12 +35,18 @@ class WebVitalsTracker {
|
|
|
34
35
|
private readonly sessionSamplingSeed: number;
|
|
35
36
|
private readonly metricsMap = new Map<MetricName, MetricState>();
|
|
36
37
|
private flushed = false;
|
|
38
|
+
private readonly handleVisibilityChange = (): void => {
|
|
39
|
+
if (document.visibilityState === "hidden") {
|
|
40
|
+
this.finalizeAndFlush();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
private readonly handlePageHide = (): void => {
|
|
44
|
+
this.finalizeAndFlush();
|
|
45
|
+
};
|
|
37
46
|
|
|
38
47
|
constructor(options: WebVitalsOptions) {
|
|
39
48
|
this.siteKey = options.siteKey;
|
|
40
|
-
this.endpoint =
|
|
41
|
-
options.endpoint ?? "https://metrics.faststats.dev/v1/web",
|
|
42
|
-
);
|
|
49
|
+
this.endpoint = webVitalsEventsUrl(options.baseUrl);
|
|
43
50
|
this.debug = options.debug ?? false;
|
|
44
51
|
this.samplingPercentage = normalizeSamplingPercentage(
|
|
45
52
|
options.samplingPercentage,
|
|
@@ -47,27 +54,14 @@ class WebVitalsTracker {
|
|
|
47
54
|
this.sessionSamplingSeed = Math.random() * 100;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
private getVitalsEndpoint(baseEndpoint: string): string {
|
|
51
|
-
const url = new URL(baseEndpoint);
|
|
52
|
-
const pathParts = url.pathname.split("/");
|
|
53
|
-
pathParts[pathParts.length - 1] = "vitals";
|
|
54
|
-
url.pathname = pathParts.join("/");
|
|
55
|
-
return url.toString();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
57
|
start(): void {
|
|
59
58
|
if (this.started || typeof window === "undefined") return;
|
|
60
59
|
|
|
61
60
|
this.started = true;
|
|
61
|
+
this.flushed = false;
|
|
62
62
|
|
|
63
|
-
document.addEventListener("visibilitychange",
|
|
64
|
-
|
|
65
|
-
this.finalizeAndFlush();
|
|
66
|
-
}
|
|
67
|
-
});
|
|
68
|
-
window.addEventListener("pagehide", () => {
|
|
69
|
-
this.finalizeAndFlush();
|
|
70
|
-
});
|
|
63
|
+
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
|
64
|
+
window.addEventListener("pagehide", this.handlePageHide);
|
|
71
65
|
|
|
72
66
|
if (this.debug) {
|
|
73
67
|
console.log("[WebVitals] Tracking started");
|
|
@@ -80,6 +74,17 @@ class WebVitalsTracker {
|
|
|
80
74
|
onTTFB((metric) => this.captureMetric(metric));
|
|
81
75
|
}
|
|
82
76
|
|
|
77
|
+
stop(): void {
|
|
78
|
+
if (!this.started || typeof window === "undefined") return;
|
|
79
|
+
this.started = false;
|
|
80
|
+
document.removeEventListener(
|
|
81
|
+
"visibilitychange",
|
|
82
|
+
this.handleVisibilityChange,
|
|
83
|
+
);
|
|
84
|
+
window.removeEventListener("pagehide", this.handlePageHide);
|
|
85
|
+
this.finalizeAndFlush();
|
|
86
|
+
}
|
|
87
|
+
|
|
83
88
|
private captureMetric(metric: MetricWithAttribution): void {
|
|
84
89
|
if (this.flushed) return;
|
|
85
90
|
|