@faststats/web 0.2.11 → 0.2.13

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,5 +1,5 @@
1
1
  import { SDK_NAME, SDK_VERSION } from "./sdk";
2
- import { webEventsUrl } from "./utils/api-urls";
2
+ import { normalizeAnalyticsBaseUrl, URLS } from "./utils/api-urls";
3
3
  import { getAnonymousId, getOrCreateSessionId } from "./utils/identifiers";
4
4
  import { sendData } from "./utils/send-data";
5
5
 
@@ -16,18 +16,6 @@ export type ErrorTracking = ErrorEntry & {
16
16
  count: number;
17
17
  };
18
18
 
19
- type ErrorKind = "error" | "unhandledrejection";
20
-
21
- type NormalizedError = {
22
- kind: ErrorKind;
23
- message: string;
24
- stack?: string;
25
- filename?: string;
26
- lineno?: number;
27
- handled: boolean;
28
- cause?: ErrorEntry;
29
- };
30
-
31
19
  export interface ErrorTrackingOptions {
32
20
  siteKey: string;
33
21
  baseUrl?: string;
@@ -38,214 +26,176 @@ export interface ErrorTrackingOptions {
38
26
  sdkVersion?: string;
39
27
  }
40
28
 
41
- const EXTENSION_URL =
29
+ type Kind = "error" | "unhandledrejection";
30
+
31
+ const EXTENSION_RE =
42
32
  /(?:^|\()(chrome|moz|ms-browser|safari-web)-extension:\/\//;
43
33
 
44
34
  const parseStack = (stack?: string): string[] | undefined =>
45
35
  stack
46
36
  ?.split("\n")
47
- .map((line) => line.trim())
37
+ .map((l) => l.trim())
48
38
  .filter(Boolean);
49
39
 
50
- function serializeCause(cause: unknown): ErrorEntry | undefined {
51
- if (cause instanceof Error) {
40
+ function serializeCause(value: unknown): ErrorEntry | undefined {
41
+ if (value instanceof Error) {
52
42
  return {
53
- error: cause.name?.trim() || "Error",
54
- message: cause.message,
55
- stack: parseStack(cause.stack),
56
- cause: serializeCause(cause.cause),
43
+ error: value.name?.trim() || "Error",
44
+ message: value.message,
45
+ stack: parseStack(value.stack),
46
+ cause: serializeCause(value.cause),
57
47
  };
58
48
  }
59
-
60
- if (typeof cause === "string") {
61
- return { error: "Error", message: cause };
62
- }
49
+ if (typeof value === "string") return { error: "Error", message: value };
63
50
  }
64
51
 
65
52
  function fingerprintCause(cause?: ErrorEntry): string {
66
- return cause
67
- ? `${cause.error}\0${cause.message ?? ""}\0${fingerprintCause(cause.cause)}`
68
- : "";
53
+ let s = "";
54
+ for (let c = cause; c; c = c.cause) {
55
+ s += `${c.error}\0${c.message ?? ""}\0`;
56
+ }
57
+ return s;
69
58
  }
70
59
 
71
- function hashError(data: NormalizedError): string {
72
- const key = [
73
- data.kind,
74
- data.handled ? "handled" : "unhandled",
75
- data.message,
76
- data.filename ?? "",
77
- data.lineno ?? "",
78
- fingerprintCause(data.cause),
79
- ].join("\0");
80
-
60
+ function hashKey(key: string): string {
81
61
  let a = 2166136261;
82
62
  let b = 3598710387;
83
-
84
63
  for (let i = 0; i < key.length; i++) {
85
64
  const c = key.charCodeAt(i);
86
65
  a = Math.imul(a ^ c, 16777619);
87
66
  b = Math.imul(b ^ c, 2246822519);
88
67
  }
68
+ return `err_${(a >>> 0).toString(16).padStart(8, "0")}${(b >>> 0).toString(16).padStart(8, "0")}`;
69
+ }
89
70
 
90
- return `err_${(a >>> 0).toString(16).padStart(8, "0")}${(b >>> 0)
91
- .toString(16)
92
- .padStart(8, "0")}`;
71
+ function readBuildId(): string | undefined {
72
+ const v = (globalThis as { __SOURCEMAPS_BUILD__?: { buildId?: unknown } })
73
+ .__SOURCEMAPS_BUILD__?.buildId;
74
+ return typeof v === "string" && v.trim() ? v : undefined;
93
75
  }
94
76
 
95
77
  export default class ErrorTracker {
96
78
  private readonly endpoint: string;
97
- private readonly handled = new WeakSet<Error>();
79
+ private readonly seen = new WeakSet<object>();
98
80
  private readonly queue = new Map<string, ErrorTracking>();
99
81
  private timer: ReturnType<typeof setInterval> | null = null;
100
82
  private started = false;
101
83
  private flushing = false;
102
84
 
103
85
  constructor(private readonly options: ErrorTrackingOptions) {
104
- this.endpoint = webEventsUrl(options.baseUrl);
105
- }
106
-
107
- private get debug(): boolean {
108
- return this.options.debug ?? false;
109
- }
110
-
111
- private get flushInterval(): number {
112
- return this.options.flushInterval ?? 5000;
113
- }
114
-
115
- private get maxQueueSize(): number {
116
- return this.options.maxQueueSize ?? 50;
86
+ this.endpoint = `${normalizeAnalyticsBaseUrl(options.baseUrl)}${URLS.events}`;
117
87
  }
118
88
 
119
89
  private log(...args: unknown[]): void {
120
- if (this.debug) console.log("[ErrorTracker]", ...args);
90
+ if (this.options.debug) console.log("[ErrorTracker]", ...args);
121
91
  }
122
92
 
123
93
  start(): void {
124
94
  if (this.started || typeof window === "undefined") return;
125
95
  this.started = true;
126
-
127
96
  window.addEventListener("error", this.onError);
128
97
  window.addEventListener("unhandledrejection", this.onRejection);
129
- document.addEventListener("visibilitychange", this.onVisibilityChange);
130
- window.addEventListener("pagehide", this.requestFlush);
131
-
132
- this.timer = setInterval(this.requestFlush, this.flushInterval);
133
- this.log("Started listening for errors");
98
+ window.addEventListener("pagehide", this.scheduleFlush);
99
+ document.addEventListener("visibilitychange", this.onVisibility);
100
+ this.timer = setInterval(
101
+ this.scheduleFlush,
102
+ this.options.flushInterval ?? 5000,
103
+ );
104
+ this.log("Started");
134
105
  }
135
106
 
136
107
  stop(): void {
137
108
  if (!this.started || typeof window === "undefined") return;
138
109
  this.started = false;
139
-
140
110
  window.removeEventListener("error", this.onError);
141
111
  window.removeEventListener("unhandledrejection", this.onRejection);
142
- document.removeEventListener("visibilitychange", this.onVisibilityChange);
143
- window.removeEventListener("pagehide", this.requestFlush);
144
-
112
+ window.removeEventListener("pagehide", this.scheduleFlush);
113
+ document.removeEventListener("visibilitychange", this.onVisibility);
145
114
  if (this.timer) clearInterval(this.timer);
146
115
  this.timer = null;
147
-
148
- this.requestFlush();
149
- this.log("Stopped listening for errors");
116
+ this.scheduleFlush();
117
+ this.log("Stopped");
150
118
  }
151
119
 
152
120
  captureError(error: Error): void {
153
- this.record(
154
- {
155
- kind: "error",
156
- message: error.message,
157
- stack: error.stack,
158
- handled: true,
159
- cause: serializeCause(error.cause),
160
- },
161
- error,
162
- );
121
+ this.capture("error", error.message, true, error);
163
122
  }
164
123
 
165
124
  private onError = (event: ErrorEvent): void => {
166
- const err = event.error;
167
-
168
- this.record(
169
- {
170
- kind: "error",
171
- message: event.message || "Unknown error",
172
- filename: event.filename || undefined,
173
- lineno: event.lineno || undefined,
174
- stack: err instanceof Error ? err.stack : undefined,
175
- handled: false,
176
- cause: err instanceof Error ? serializeCause(err.cause) : undefined,
177
- },
178
- err,
125
+ this.capture(
126
+ "error",
127
+ event.message || "Unknown error",
128
+ false,
129
+ event.error,
130
+ event.filename || "",
131
+ event.lineno,
179
132
  );
180
133
  };
181
134
 
182
135
  private onRejection = (event: PromiseRejectionEvent): void => {
183
136
  const reason = event.reason;
184
-
185
- this.record(
186
- {
187
- kind: "unhandledrejection",
188
- message: reason.message || "Unhandled promise rejection",
189
- stack: reason.stack || undefined,
190
- handled: false,
191
- cause: serializeCause(reason.cause),
192
- },
193
- reason,
194
- );
137
+ const message =
138
+ reason instanceof Error
139
+ ? reason.message
140
+ : typeof reason === "string"
141
+ ? reason
142
+ : "Unhandled promise rejection";
143
+ this.capture("unhandledrejection", message, false, reason);
195
144
  };
196
145
 
197
- private onVisibilityChange = (): void => {
198
- if (document.visibilityState === "hidden") this.requestFlush();
146
+ private onVisibility = (): void => {
147
+ if (document.visibilityState === "hidden") this.scheduleFlush();
199
148
  };
200
149
 
201
- private requestFlush = (): void => {
150
+ private scheduleFlush = (): void => {
202
151
  void this.flush();
203
152
  };
204
153
 
205
- private restoreErrors(errors: ErrorTracking[]): void {
206
- for (const error of errors) {
207
- this.queue.set(error.hash, {
208
- ...error,
209
- count: error.count + (this.queue.get(error.hash)?.count ?? 0),
210
- });
211
- }
212
- }
213
-
214
- private record(data: NormalizedError, original?: unknown): void {
215
- if (original instanceof Error) {
216
- if (this.handled.has(original)) {
217
- this.log("Skipping duplicate:", original.message);
154
+ private capture(
155
+ kind: Kind,
156
+ message: string,
157
+ handled: boolean,
158
+ source: unknown,
159
+ filename = "",
160
+ lineno: number | "" = "",
161
+ ): void {
162
+ const stackText = source instanceof Error ? (source.stack ?? "") : "";
163
+ if (EXTENSION_RE.test(`${filename}\n${stackText}`)) return;
164
+
165
+ if (typeof source === "object" && source !== null) {
166
+ if (this.seen.has(source)) {
167
+ this.log("Skipping duplicate:", message);
218
168
  return;
219
169
  }
220
- this.handled.add(original);
170
+ this.seen.add(source);
221
171
  }
222
172
 
223
- if (EXTENSION_URL.test(`${data.filename ?? ""}\n${data.stack ?? ""}`)) {
224
- return;
173
+ const entry: ErrorEntry = {
174
+ error: kind === "unhandledrejection" ? "UnhandledRejection" : "Error",
175
+ message,
176
+ handled,
177
+ };
178
+ if (source instanceof Error) {
179
+ entry.stack = parseStack(source.stack);
180
+ const cause = serializeCause(source.cause);
181
+ if (cause) entry.cause = cause;
225
182
  }
226
183
 
227
- const hash = hashError(data);
228
- const existing = this.queue.get(hash);
184
+ const key = hashKey(
185
+ `${kind}\0${handled ? "handled" : "unhandled"}\0${message}\0${filename}\0${lineno}\0${fingerprintCause(entry.cause)}`,
186
+ );
229
187
 
188
+ const existing = this.queue.get(key);
230
189
  if (existing) {
231
- existing.count += 1;
190
+ existing.count++;
232
191
  } else {
233
- this.queue.set(hash, {
234
- hash,
235
- count: 1,
236
- error:
237
- data.kind === "unhandledrejection" ? "UnhandledRejection" : "Error",
238
- message: data.message,
239
- stack: parseStack(data.stack),
240
- handled: data.handled,
241
- ...(data.cause ? { cause: data.cause } : {}),
242
- });
192
+ this.queue.set(key, { hash: key, count: 1, ...entry });
243
193
  }
244
194
 
245
- this.log("Captured error:", data);
195
+ this.log("Captured:", entry);
246
196
 
247
- if (this.queue.size >= this.maxQueueSize) {
248
- this.requestFlush();
197
+ if (this.queue.size >= (this.options.maxQueueSize ?? 50)) {
198
+ this.scheduleFlush();
249
199
  }
250
200
  }
251
201
 
@@ -256,22 +206,12 @@ export default class ErrorTracker {
256
206
  this.queue.clear();
257
207
  this.flushing = true;
258
208
 
259
- const identifier = getAnonymousId();
260
- const sourcemapsBuild = (
261
- globalThis as {
262
- __SOURCEMAPS_BUILD__?: { buildId?: unknown };
263
- }
264
- ).__SOURCEMAPS_BUILD__;
265
-
266
- const buildId =
267
- typeof sourcemapsBuild?.buildId === "string" &&
268
- sourcemapsBuild.buildId.trim()
269
- ? sourcemapsBuild.buildId
270
- : undefined;
209
+ const userId = getAnonymousId();
210
+ const buildId = readBuildId();
271
211
 
272
212
  const payload = JSON.stringify({
273
213
  token: this.options.siteKey,
274
- ...(identifier ? { userId: identifier } : {}),
214
+ ...(userId ? { userId } : {}),
275
215
  sessionId: getOrCreateSessionId(),
276
216
  ...(buildId ? { buildId } : {}),
277
217
  sdkName: this.options.sdkName ?? SDK_NAME,
@@ -285,21 +225,28 @@ export default class ErrorTracker {
285
225
  errors,
286
226
  });
287
227
 
288
- this.log("Flushing errors:", errors);
289
- this.log("Payload:", payload);
228
+ this.log("Flushing:", errors);
290
229
 
291
230
  try {
292
231
  const sent = await sendData({
293
232
  url: this.endpoint,
294
233
  data: payload,
295
- debug: this.debug,
234
+ debug: this.options.debug,
296
235
  debugPrefix: "[ErrorTracker]",
297
236
  });
298
- if (!sent) this.restoreErrors(errors);
237
+ if (!sent) this.requeue(errors);
299
238
  } catch {
300
- this.restoreErrors(errors);
239
+ this.requeue(errors);
301
240
  } finally {
302
241
  this.flushing = false;
303
242
  }
304
243
  }
244
+
245
+ private requeue(errors: ErrorTracking[]): void {
246
+ for (const e of errors) {
247
+ const existing = this.queue.get(e.hash);
248
+ if (existing) existing.count += e.count;
249
+ else this.queue.set(e.hash, e);
250
+ }
251
+ }
305
252
  }
@@ -1,4 +1,4 @@
1
- import { featureFlagsCheckUrl } from "./utils/api-urls";
1
+ import { normalizeFeatureFlagsBaseUrl, URLS } from "./utils/api-urls";
2
2
 
3
3
  export type FeatureFlagEvaluation = {
4
4
  value: string;
@@ -12,6 +12,7 @@ export type FeatureFlagCheckContext = {
12
12
  projectId?: string;
13
13
  identifier?: string;
14
14
  externalId?: string;
15
+ sessionId?: string;
15
16
  attributes?: Record<string, unknown>;
16
17
  signal?: AbortSignal;
17
18
  };
@@ -28,6 +29,7 @@ export async function fetchFeatureFlagEvaluation(
28
29
  const projectId = context.projectId;
29
30
  const identifier = context.identifier;
30
31
  const externalId = context.externalId;
32
+ const sessionId = context.sessionId;
31
33
 
32
34
  if (!projectToken && !projectId) {
33
35
  throw new Error("feature flag check requires projectToken or projectId");
@@ -41,6 +43,7 @@ export async function fetchFeatureFlagEvaluation(
41
43
  ...(projectId ? { projectId } : {}),
42
44
  ...(identifier ? { identifier } : {}),
43
45
  ...(externalId ? { externalId } : {}),
46
+ ...(sessionId ? { sessionId } : {}),
44
47
  };
45
48
 
46
49
  if (context.attributes && Object.keys(context.attributes).length > 0) {
@@ -54,7 +57,7 @@ export async function fetchFeatureFlagEvaluation(
54
57
  headers.Authorization = `Bearer ${projectToken}`;
55
58
  }
56
59
 
57
- const url = featureFlagsCheckUrl(context.baseUrl);
60
+ const url = `${normalizeFeatureFlagsBaseUrl(context.baseUrl)}${URLS.flags}`;
58
61
  const res = await fetch(url, {
59
62
  method: "POST",
60
63
  headers,
package/src/replay.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  import { record } from "rrweb";
7
7
  import type { recordOptions } from "rrweb/typings/types";
8
8
  import type { SlimDOMOptions } from "rrweb-snapshot";
9
- import { replayEventsUrl } from "./utils/api-urls";
9
+ import { normalizeAnalyticsBaseUrl, URLS } from "./utils/api-urls";
10
10
  import {
11
11
  getAnonymousId,
12
12
  getOrCreateSessionId,
@@ -96,7 +96,7 @@ export default class ReplayTracker {
96
96
  private sending = false;
97
97
 
98
98
  constructor(private readonly options: ReplayTrackerOptions) {
99
- this.endpoint = replayEventsUrl(options.baseUrl);
99
+ this.endpoint = `${normalizeAnalyticsBaseUrl(options.baseUrl)}${URLS.replay}`;
100
100
  this.sampled =
101
101
  Math.random() * 100 <
102
102
  normalizeSamplingPercentage(options.samplingPercentage);
@@ -10,28 +10,15 @@ export function normalizeAnalyticsBaseUrl(input?: string): string {
10
10
  return stripTrailingSlashes(input) || DEFAULT_BASE;
11
11
  }
12
12
 
13
- export function webEventsUrl(baseUrl?: string): string {
14
- return `${normalizeAnalyticsBaseUrl(baseUrl)}/v1/web`;
15
- }
16
-
17
- export function identifyEventsUrl(baseUrl?: string): string {
18
- return `${normalizeAnalyticsBaseUrl(baseUrl)}/v1/identify`;
19
- }
20
-
21
- export function replayEventsUrl(baseUrl?: string): string {
22
- return `${normalizeAnalyticsBaseUrl(baseUrl)}/v1/replay`;
23
- }
24
-
25
- export function webVitalsEventsUrl(baseUrl?: string): string {
26
- return `${normalizeAnalyticsBaseUrl(baseUrl)}/v1/vitals`;
27
- }
13
+ export const URLS = {
14
+ events: "/v1/web",
15
+ identify: "/v1/identify",
16
+ replay: "/v1/replay",
17
+ vitals: "/v1/vitals",
18
+ flags: "/v1/check",
19
+ } as const;
28
20
 
29
21
  export function normalizeFeatureFlagsBaseUrl(input?: string): string {
30
22
  if (input === undefined || input === "") return DEFAULT_FEATURE_FLAGS_BASE;
31
23
  return stripTrailingSlashes(input) || DEFAULT_FEATURE_FLAGS_BASE;
32
24
  }
33
-
34
- export function featureFlagsCheckUrl(baseUrl?: string): string {
35
- const base = normalizeFeatureFlagsBaseUrl(baseUrl);
36
- return `${base}/v1/check`;
37
- }
@@ -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 (typeof localStorage === "undefined") return "";
17
- const existingId = localStorage.getItem("faststats_anon_id");
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("faststats_anon_id", newId);
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 || typeof localStorage === "undefined") return "";
27
- localStorage.removeItem("faststats_anon_id");
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 (typeof sessionStorage === "undefined") return "";
33
- const existingId = sessionStorage.getItem("session_id");
34
- const sessionTimestamp = sessionStorage.getItem("session_timestamp");
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("session_timestamp", Date.now().toString());
47
+ sessionStorage.setItem(SESSION_TIMESTAMP_KEY, Date.now().toString());
40
48
  return existingId;
41
49
  }
42
- sessionStorage.removeItem("session_id");
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("session_id", newId);
50
- sessionStorage.setItem("session_timestamp", now);
51
- sessionStorage.setItem("session_start", now);
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 (typeof sessionStorage === "undefined") return "";
57
- sessionStorage.removeItem("session_id");
58
- sessionStorage.removeItem("session_timestamp");
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 (typeof sessionStorage === "undefined") return;
65
- const existingId = sessionStorage.getItem("session_id");
66
- if (existingId) {
67
- sessionStorage.setItem("session_timestamp", Date.now().toString());
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 (typeof sessionStorage === "undefined") return Date.now();
73
- const start = sessionStorage.getItem("session_start");
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("session_timestamp");
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
+ }