@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/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
- export type ErrorData = {
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
- endpoint?: string;
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; hash: string; count: number }
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.endpoint ?? "https://metrics.faststats.dev/v1/web";
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 error = event.error;
95
-
96
- if (error instanceof Error) {
97
- if (this.handledErrors.has(error)) {
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
- this.handledErrors.add(error);
162
+ return;
107
163
  }
108
164
 
109
165
  this.queueError({
110
- message: event.message || (error?.message ?? "Unknown error"),
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: error?.stack || undefined,
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
- if (reason instanceof Error) {
123
- if (this.handledErrors.has(reason)) {
124
- if (this.debug) {
125
- console.log(
126
- "[ErrorTracker] Skipping duplicate rejection:",
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
- this.handledErrors.add(reason);
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 isExtensionError(errorData: ErrorData): boolean {
148
- if (errorData.filename?.startsWith("chrome-extension://")) {
149
- return true;
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:", errorData);
212
+ console.log("[ErrorTracker] Captured error:", data);
195
213
  }
196
214
 
197
- const hash = await this.generateErrorHash(errorData);
198
- const existing = this.errorCounts.get(hash);
199
-
200
- if (existing) {
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 ${existing.count}`,
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
- errorData.type === "unhandledrejection"
211
- ? "UnhandledRejection"
212
- : "Error",
213
- message: errorData.message,
214
- stack: this.parseStack(errorData.stack),
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, hash, count: 1 });
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.handledErrors.has(error)) {
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, hash, count } of this.errorCounts.values()) {
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 payload: Record<string, unknown> = {
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: commonData.url as string,
287
- page: commonData.page as string,
288
- referrer: commonData.referrer as string,
289
- title: typeof document !== "undefined" ? document.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
@@ -7,6 +7,7 @@ export {
7
7
  logout,
8
8
  optIn,
9
9
  optOut,
10
+ reportError,
10
11
  sendData,
11
12
  setConsentMode,
12
13
  trackEvent,
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
- endpoint?: string;
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
- if (this.recordConsole) {
169
- recordOptions.plugins = [getRecordConsolePlugin()];
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
- endpoint?: string;
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 = this.getVitalsEndpoint(
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
- if (document.visibilityState === "hidden") {
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