@faststats/web 0.2.12 → 0.2.14

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/REPLAY_PAYLOAD.md +64 -0
  3. package/dist/chunks/api-urls-DaeYkG0_.js +1 -0
  4. package/dist/chunks/error-Cd9PTS5v.js +2 -0
  5. package/dist/chunks/{feature-flags-BxZz_lNm.d.ts → feature-flags-BClx56v5.d.ts} +1 -0
  6. package/dist/chunks/feature-flags-DSOCIZHK.js +1 -0
  7. package/dist/chunks/{replay-BuX3_0xs.d.ts → replay-DvJYurEC.d.ts} +10 -2
  8. package/dist/chunks/replay-rTqcjOo2.js +1 -0
  9. package/dist/chunks/send-data-B2fYGj6v.js +1 -0
  10. package/dist/chunks/session-manager-Cy63ptPF.js +1 -0
  11. package/dist/chunks/web-vitals-Be-Cg4Po.js +1 -0
  12. package/dist/error.d.ts +5 -8
  13. package/dist/error.js +1 -1
  14. package/dist/feature-flags.d.ts +1 -1
  15. package/dist/feature-flags.js +1 -1
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +1 -1
  18. package/dist/replay.d.ts +1 -1
  19. package/dist/replay.js +1 -1
  20. package/dist/web-vitals.d.ts +7 -15
  21. package/dist/web-vitals.js +1 -1
  22. package/package.json +4 -7
  23. package/src/analytics.ts +25 -14
  24. package/src/error.ts +110 -158
  25. package/src/feature-flags.ts +5 -2
  26. package/src/replay.ts +179 -52
  27. package/src/utils/api-urls.ts +7 -20
  28. package/src/utils/identifiers.ts +30 -72
  29. package/src/utils/session-manager.ts +416 -0
  30. package/src/web-vitals.ts +66 -168
  31. package/tests/analytics.test.ts +8 -4
  32. package/tests/identifiers.test.ts +15 -32
  33. package/tests/replay.test.ts +180 -10
  34. package/tests/session-manager.test.ts +161 -0
  35. package/dist/chunks/api-urls-BrkcoElX.js +0 -1
  36. package/dist/chunks/error-CttYL43D.js +0 -2
  37. package/dist/chunks/feature-flags-CjnLZGxp.js +0 -1
  38. package/dist/chunks/identifiers-CQeWm7wi.js +0 -1
  39. package/dist/chunks/replay-BrMLCiBF.js +0 -1
  40. package/dist/chunks/send-data-DL_GlsQw.js +0 -1
  41. package/dist/chunks/web-vitals-CjA1bFLG.js +0 -1
  42. package/wrangler.toml +0 -8
package/src/error.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { SDK_NAME, SDK_VERSION } from "./sdk";
2
- import { webEventsUrl } from "./utils/api-urls";
3
- import { getAnonymousId, getOrCreateSessionId } from "./utils/identifiers";
2
+ import { normalizeAnalyticsBaseUrl, URLS } from "./utils/api-urls";
3
+ import { getAnonymousId } from "./utils/identifiers";
4
4
  import { sendData } from "./utils/send-data";
5
+ import {
6
+ getOrCreateSessionId,
7
+ getOrCreateWindowId,
8
+ } from "./utils/session-manager";
5
9
 
6
10
  export type ErrorEntry = {
7
11
  error: string;
@@ -16,18 +20,6 @@ export type ErrorTracking = ErrorEntry & {
16
20
  count: number;
17
21
  };
18
22
 
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
23
  export interface ErrorTrackingOptions {
32
24
  siteKey: string;
33
25
  baseUrl?: string;
@@ -38,214 +30,176 @@ export interface ErrorTrackingOptions {
38
30
  sdkVersion?: string;
39
31
  }
40
32
 
41
- const EXTENSION_URL =
33
+ type Kind = "error" | "unhandledrejection";
34
+
35
+ const EXTENSION_RE =
42
36
  /(?:^|\()(chrome|moz|ms-browser|safari-web)-extension:\/\//;
43
37
 
44
38
  const parseStack = (stack?: string): string[] | undefined =>
45
39
  stack
46
40
  ?.split("\n")
47
- .map((line) => line.trim())
41
+ .map((l) => l.trim())
48
42
  .filter(Boolean);
49
43
 
50
- function serializeCause(cause: unknown): ErrorEntry | undefined {
51
- if (cause instanceof Error) {
44
+ function serializeCause(value: unknown): ErrorEntry | undefined {
45
+ if (value instanceof Error) {
52
46
  return {
53
- error: cause.name?.trim() || "Error",
54
- message: cause.message,
55
- stack: parseStack(cause.stack),
56
- cause: serializeCause(cause.cause),
47
+ error: value.name?.trim() || "Error",
48
+ message: value.message,
49
+ stack: parseStack(value.stack),
50
+ cause: serializeCause(value.cause),
57
51
  };
58
52
  }
59
-
60
- if (typeof cause === "string") {
61
- return { error: "Error", message: cause };
62
- }
53
+ if (typeof value === "string") return { error: "Error", message: value };
63
54
  }
64
55
 
65
56
  function fingerprintCause(cause?: ErrorEntry): string {
66
- return cause
67
- ? `${cause.error}\0${cause.message ?? ""}\0${fingerprintCause(cause.cause)}`
68
- : "";
57
+ let s = "";
58
+ for (let c = cause; c; c = c.cause) {
59
+ s += `${c.error}\0${c.message ?? ""}\0`;
60
+ }
61
+ return s;
69
62
  }
70
63
 
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
-
64
+ function hashKey(key: string): string {
81
65
  let a = 2166136261;
82
66
  let b = 3598710387;
83
-
84
67
  for (let i = 0; i < key.length; i++) {
85
68
  const c = key.charCodeAt(i);
86
69
  a = Math.imul(a ^ c, 16777619);
87
70
  b = Math.imul(b ^ c, 2246822519);
88
71
  }
72
+ return `err_${(a >>> 0).toString(16).padStart(8, "0")}${(b >>> 0).toString(16).padStart(8, "0")}`;
73
+ }
89
74
 
90
- return `err_${(a >>> 0).toString(16).padStart(8, "0")}${(b >>> 0)
91
- .toString(16)
92
- .padStart(8, "0")}`;
75
+ function readBuildId(): string | undefined {
76
+ const v = (globalThis as { __SOURCEMAPS_BUILD__?: { buildId?: unknown } })
77
+ .__SOURCEMAPS_BUILD__?.buildId;
78
+ return typeof v === "string" && v.trim() ? v : undefined;
93
79
  }
94
80
 
95
81
  export default class ErrorTracker {
96
82
  private readonly endpoint: string;
97
- private readonly handled = new WeakSet<Error>();
83
+ private readonly seen = new WeakSet<object>();
98
84
  private readonly queue = new Map<string, ErrorTracking>();
99
85
  private timer: ReturnType<typeof setInterval> | null = null;
100
86
  private started = false;
101
87
  private flushing = false;
102
88
 
103
89
  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;
90
+ this.endpoint = `${normalizeAnalyticsBaseUrl(options.baseUrl)}${URLS.events}`;
117
91
  }
118
92
 
119
93
  private log(...args: unknown[]): void {
120
- if (this.debug) console.log("[ErrorTracker]", ...args);
94
+ if (this.options.debug) console.log("[ErrorTracker]", ...args);
121
95
  }
122
96
 
123
97
  start(): void {
124
98
  if (this.started || typeof window === "undefined") return;
125
99
  this.started = true;
126
-
127
100
  window.addEventListener("error", this.onError);
128
101
  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");
102
+ window.addEventListener("pagehide", this.scheduleFlush);
103
+ document.addEventListener("visibilitychange", this.onVisibility);
104
+ this.timer = setInterval(
105
+ this.scheduleFlush,
106
+ this.options.flushInterval ?? 5000,
107
+ );
108
+ this.log("Started");
134
109
  }
135
110
 
136
111
  stop(): void {
137
112
  if (!this.started || typeof window === "undefined") return;
138
113
  this.started = false;
139
-
140
114
  window.removeEventListener("error", this.onError);
141
115
  window.removeEventListener("unhandledrejection", this.onRejection);
142
- document.removeEventListener("visibilitychange", this.onVisibilityChange);
143
- window.removeEventListener("pagehide", this.requestFlush);
144
-
116
+ window.removeEventListener("pagehide", this.scheduleFlush);
117
+ document.removeEventListener("visibilitychange", this.onVisibility);
145
118
  if (this.timer) clearInterval(this.timer);
146
119
  this.timer = null;
147
-
148
- this.requestFlush();
149
- this.log("Stopped listening for errors");
120
+ this.scheduleFlush();
121
+ this.log("Stopped");
150
122
  }
151
123
 
152
124
  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
- );
125
+ this.capture("error", error.message, true, error);
163
126
  }
164
127
 
165
128
  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,
129
+ this.capture(
130
+ "error",
131
+ event.message || "Unknown error",
132
+ false,
133
+ event.error,
134
+ event.filename || "",
135
+ event.lineno,
179
136
  );
180
137
  };
181
138
 
182
139
  private onRejection = (event: PromiseRejectionEvent): void => {
183
140
  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
- );
141
+ const message =
142
+ reason instanceof Error
143
+ ? reason.message
144
+ : typeof reason === "string"
145
+ ? reason
146
+ : "Unhandled promise rejection";
147
+ this.capture("unhandledrejection", message, false, reason);
195
148
  };
196
149
 
197
- private onVisibilityChange = (): void => {
198
- if (document.visibilityState === "hidden") this.requestFlush();
150
+ private onVisibility = (): void => {
151
+ if (document.visibilityState === "hidden") this.scheduleFlush();
199
152
  };
200
153
 
201
- private requestFlush = (): void => {
154
+ private scheduleFlush = (): void => {
202
155
  void this.flush();
203
156
  };
204
157
 
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);
158
+ private capture(
159
+ kind: Kind,
160
+ message: string,
161
+ handled: boolean,
162
+ source: unknown,
163
+ filename = "",
164
+ lineno: number | "" = "",
165
+ ): void {
166
+ const stackText = source instanceof Error ? (source.stack ?? "") : "";
167
+ if (EXTENSION_RE.test(`${filename}\n${stackText}`)) return;
168
+
169
+ if (typeof source === "object" && source !== null) {
170
+ if (this.seen.has(source)) {
171
+ this.log("Skipping duplicate:", message);
218
172
  return;
219
173
  }
220
- this.handled.add(original);
174
+ this.seen.add(source);
221
175
  }
222
176
 
223
- if (EXTENSION_URL.test(`${data.filename ?? ""}\n${data.stack ?? ""}`)) {
224
- return;
177
+ const entry: ErrorEntry = {
178
+ error: kind === "unhandledrejection" ? "UnhandledRejection" : "Error",
179
+ message,
180
+ handled,
181
+ };
182
+ if (source instanceof Error) {
183
+ entry.stack = parseStack(source.stack);
184
+ const cause = serializeCause(source.cause);
185
+ if (cause) entry.cause = cause;
225
186
  }
226
187
 
227
- const hash = hashError(data);
228
- const existing = this.queue.get(hash);
188
+ const key = hashKey(
189
+ `${kind}\0${handled ? "handled" : "unhandled"}\0${message}\0${filename}\0${lineno}\0${fingerprintCause(entry.cause)}`,
190
+ );
229
191
 
192
+ const existing = this.queue.get(key);
230
193
  if (existing) {
231
- existing.count += 1;
194
+ existing.count++;
232
195
  } 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
- });
196
+ this.queue.set(key, { hash: key, count: 1, ...entry });
243
197
  }
244
198
 
245
- this.log("Captured error:", data);
199
+ this.log("Captured:", entry);
246
200
 
247
- if (this.queue.size >= this.maxQueueSize) {
248
- this.requestFlush();
201
+ if (this.queue.size >= (this.options.maxQueueSize ?? 50)) {
202
+ this.scheduleFlush();
249
203
  }
250
204
  }
251
205
 
@@ -256,23 +210,14 @@ export default class ErrorTracker {
256
210
  this.queue.clear();
257
211
  this.flushing = true;
258
212
 
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;
213
+ const userId = getAnonymousId();
214
+ const buildId = readBuildId();
271
215
 
272
216
  const payload = JSON.stringify({
273
217
  token: this.options.siteKey,
274
- ...(identifier ? { userId: identifier } : {}),
218
+ ...(userId ? { userId } : {}),
275
219
  sessionId: getOrCreateSessionId(),
220
+ windowId: getOrCreateWindowId(this.options.siteKey),
276
221
  ...(buildId ? { buildId } : {}),
277
222
  sdkName: this.options.sdkName ?? SDK_NAME,
278
223
  sdkVersion: this.options.sdkVersion ?? SDK_VERSION,
@@ -285,21 +230,28 @@ export default class ErrorTracker {
285
230
  errors,
286
231
  });
287
232
 
288
- this.log("Flushing errors:", errors);
289
- this.log("Payload:", payload);
233
+ this.log("Flushing:", errors);
290
234
 
291
235
  try {
292
236
  const sent = await sendData({
293
237
  url: this.endpoint,
294
238
  data: payload,
295
- debug: this.debug,
239
+ debug: this.options.debug,
296
240
  debugPrefix: "[ErrorTracker]",
297
241
  });
298
- if (!sent) this.restoreErrors(errors);
242
+ if (!sent) this.requeue(errors);
299
243
  } catch {
300
- this.restoreErrors(errors);
244
+ this.requeue(errors);
301
245
  } finally {
302
246
  this.flushing = false;
303
247
  }
304
248
  }
249
+
250
+ private requeue(errors: ErrorTracking[]): void {
251
+ for (const e of errors) {
252
+ const existing = this.queue.get(e.hash);
253
+ if (existing) existing.count += e.count;
254
+ else this.queue.set(e.hash, e);
255
+ }
256
+ }
305
257
  }
@@ -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,