@faststats/web 0.1.9 → 0.2.0

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
@@ -10,18 +10,19 @@ export type ErrorEntry = {
10
10
  cause?: ErrorEntry;
11
11
  };
12
12
 
13
- export type ErrorTracking = {
13
+ export type ErrorTracking = ErrorEntry & {
14
14
  hash: string;
15
15
  count: number;
16
- } & ErrorEntry;
16
+ };
17
+
18
+ type ErrorKind = "error" | "unhandledrejection";
17
19
 
18
- type ErrorData = {
20
+ type NormalizedError = {
21
+ kind: ErrorKind;
19
22
  message: string;
23
+ stack?: string;
20
24
  filename?: string;
21
25
  lineno?: number;
22
- colno?: number;
23
- stack?: string;
24
- type: "error" | "unhandledrejection";
25
26
  handled: boolean;
26
27
  cause?: ErrorEntry;
27
28
  };
@@ -37,248 +38,205 @@ export interface ErrorTrackingOptions {
37
38
  const EXTENSION_URL =
38
39
  /(?:^|\()(chrome|moz|ms-browser|safari-web)-extension:\/\//;
39
40
 
40
- function parseStack(stack?: string): string[] | undefined {
41
- if (!stack) return undefined;
42
- return stack
43
- .split("\n")
41
+ const parseStack = (stack?: string): string[] | undefined =>
42
+ stack
43
+ ?.split("\n")
44
44
  .map((line) => line.trim())
45
- .filter((line) => line.length > 0);
46
- }
45
+ .filter(Boolean);
47
46
 
48
47
  function serializeCause(cause: unknown): ErrorEntry | undefined {
49
48
  if (cause instanceof Error) {
50
- const name = cause.name?.trim() || "Error";
51
49
  return {
52
- error: name,
50
+ error: cause.name?.trim() || "Error",
53
51
  message: cause.message,
54
52
  stack: parseStack(cause.stack),
55
53
  cause: serializeCause(cause.cause),
56
54
  };
57
55
  }
56
+
58
57
  if (typeof cause === "string") {
59
58
  return { error: "Error", message: cause };
60
59
  }
61
- return undefined;
62
60
  }
63
61
 
64
- function causeFingerprint(cause: ErrorEntry | undefined): string {
65
- if (!cause) return "";
66
- return `${cause.error}\0${cause.message ?? ""}\0${causeFingerprint(cause.cause)}`;
62
+ function fingerprintCause(cause?: ErrorEntry): string {
63
+ return cause
64
+ ? `${cause.error}\0${cause.message ?? ""}\0${fingerprintCause(cause.cause)}`
65
+ : "";
67
66
  }
68
67
 
69
- function hashErrorData(data: ErrorData): string {
68
+ function hashError(data: NormalizedError): string {
70
69
  const key = [
71
- data.type,
70
+ data.kind,
72
71
  data.handled ? "handled" : "unhandled",
73
72
  data.message,
74
73
  data.filename ?? "",
75
74
  data.lineno ?? "",
76
- causeFingerprint(data.cause),
75
+ fingerprintCause(data.cause),
77
76
  ].join("\0");
78
77
 
79
78
  let a = 2166136261;
80
79
  let b = 3598710387;
80
+
81
81
  for (let i = 0; i < key.length; i++) {
82
82
  const c = key.charCodeAt(i);
83
83
  a = Math.imul(a ^ c, 16777619);
84
84
  b = Math.imul(b ^ c, 2246822519);
85
85
  }
86
- return `err_${(a >>> 0).toString(16).padStart(8, "0")}${(b >>> 0).toString(16).padStart(8, "0")}`;
86
+
87
+ return `err_${(a >>> 0).toString(16).padStart(8, "0")}${(b >>> 0)
88
+ .toString(16)
89
+ .padStart(8, "0")}`;
87
90
  }
88
91
 
89
- class ErrorTracker {
92
+ export default class ErrorTracker {
90
93
  private readonly endpoint: string;
91
- private readonly siteKey: string;
92
- private readonly debug: boolean;
93
- private readonly flushInterval: number;
94
- private readonly maxQueueSize: number;
95
-
96
- private readonly handledErrors = new WeakSet<Error>();
97
- private readonly errorCounts = new Map<
98
- string,
99
- { entry: ErrorEntry; count: number }
100
- >();
101
- private readonly handleVisibilityChange = (): void => {
102
- if (document.visibilityState === "hidden") this.flush();
103
- };
104
- private readonly handlePageHide = (): void => {
105
- this.flush();
106
- };
107
- private flushTimer: ReturnType<typeof setInterval> | null = null;
94
+ private readonly handled = new WeakSet<Error>();
95
+ private readonly queue = new Map<string, ErrorTracking>();
96
+ private timer: ReturnType<typeof setInterval> | null = null;
108
97
  private started = false;
109
98
 
110
- constructor(options: ErrorTrackingOptions) {
111
- this.siteKey = options.siteKey;
99
+ constructor(private readonly options: ErrorTrackingOptions) {
112
100
  this.endpoint = webEventsUrl(options.baseUrl);
113
- this.debug = options.debug ?? false;
114
- this.flushInterval = options.flushInterval ?? 5000;
115
- this.maxQueueSize = options.maxQueueSize ?? 50;
101
+ }
102
+
103
+ private get debug(): boolean {
104
+ return this.options.debug ?? false;
105
+ }
106
+
107
+ private get flushInterval(): number {
108
+ return this.options.flushInterval ?? 5000;
109
+ }
110
+
111
+ private get maxQueueSize(): number {
112
+ return this.options.maxQueueSize ?? 50;
113
+ }
114
+
115
+ private log(...args: unknown[]): void {
116
+ if (this.debug) console.log("[ErrorTracker]", ...args);
116
117
  }
117
118
 
118
119
  start(): void {
119
120
  if (this.started || typeof window === "undefined") return;
120
121
  this.started = true;
121
122
 
122
- window.addEventListener("error", this.handleErrorEvent);
123
- window.addEventListener("unhandledrejection", this.handleRejection);
123
+ window.addEventListener("error", this.onError);
124
+ window.addEventListener("unhandledrejection", this.onRejection);
125
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
126
+ window.addEventListener("pagehide", this.flush);
124
127
 
125
- this.flushTimer = setInterval(() => this.flush(), this.flushInterval);
126
- document.addEventListener("visibilitychange", this.handleVisibilityChange);
127
- window.addEventListener("pagehide", this.handlePageHide);
128
-
129
- if (this.debug) {
130
- console.log("[ErrorTracker] Started listening for errors");
131
- }
128
+ this.timer = setInterval(this.flush, this.flushInterval);
129
+ this.log("Started listening for errors");
132
130
  }
133
131
 
134
132
  stop(): void {
135
133
  if (!this.started || typeof window === "undefined") return;
136
134
  this.started = false;
137
135
 
138
- window.removeEventListener("error", this.handleErrorEvent);
139
- window.removeEventListener("unhandledrejection", this.handleRejection);
140
- document.removeEventListener(
141
- "visibilitychange",
142
- this.handleVisibilityChange,
143
- );
144
- window.removeEventListener("pagehide", this.handlePageHide);
136
+ window.removeEventListener("error", this.onError);
137
+ window.removeEventListener("unhandledrejection", this.onRejection);
138
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
139
+ window.removeEventListener("pagehide", this.flush);
145
140
 
146
- if (this.flushTimer) {
147
- clearInterval(this.flushTimer);
148
- this.flushTimer = null;
149
- }
141
+ if (this.timer) clearInterval(this.timer);
142
+ this.timer = null;
150
143
 
151
144
  this.flush();
152
-
153
- if (this.debug) {
154
- console.log("[ErrorTracker] Stopped listening for errors");
155
- }
145
+ this.log("Stopped listening for errors");
156
146
  }
157
147
 
158
- private skipDuplicate(err: unknown): boolean {
159
- if (!(err instanceof Error)) return false;
160
- if (this.handledErrors.has(err)) return true;
161
- this.handledErrors.add(err);
162
- return false;
148
+ captureError(error: Error): void {
149
+ this.record(
150
+ {
151
+ kind: "error",
152
+ message: error.message,
153
+ stack: error.stack,
154
+ handled: true,
155
+ cause: serializeCause(error.cause),
156
+ },
157
+ error,
158
+ );
163
159
  }
164
160
 
165
- private handleErrorEvent = (event: ErrorEvent): void => {
161
+ private onError = (event: ErrorEvent): void => {
166
162
  const err = event.error;
167
- if (this.skipDuplicate(err)) {
168
- if (this.debug) {
169
- console.log("[ErrorTracker] Skipping duplicate error:", err.message);
170
- }
171
- return;
172
- }
173
163
 
174
- this.queueError({
175
- message:
176
- event.message || (err instanceof Error ? err.message : "Unknown error"),
177
- filename: event.filename || undefined,
178
- lineno: event.lineno || undefined,
179
- colno: event.colno || undefined,
180
- stack: err instanceof Error ? err.stack : undefined,
181
- type: "error",
182
- handled: false,
183
- cause: err instanceof Error ? serializeCause(err.cause) : undefined,
184
- });
164
+ this.record(
165
+ {
166
+ kind: "error",
167
+ message: event.message || "Unknown error",
168
+ filename: event.filename || undefined,
169
+ lineno: event.lineno || undefined,
170
+ stack: err instanceof Error ? err.stack : undefined,
171
+ handled: false,
172
+ cause: err instanceof Error ? serializeCause(err.cause) : undefined,
173
+ },
174
+ err,
175
+ );
185
176
  };
186
177
 
187
- private handleRejection = (event: PromiseRejectionEvent): void => {
178
+ private onRejection = (event: PromiseRejectionEvent): void => {
188
179
  const reason = event.reason;
189
- if (this.skipDuplicate(reason)) {
190
- if (this.debug) {
191
- console.log(
192
- "[ErrorTracker] Skipping duplicate rejection:",
193
- reason instanceof Error ? reason.message : reason,
194
- );
195
- }
196
- return;
197
- }
198
180
 
199
- const message =
200
- reason instanceof Error
201
- ? reason.message
202
- : typeof reason === "string"
203
- ? reason
204
- : "Unhandled promise rejection";
205
-
206
- this.queueError({
207
- message,
208
- stack: reason instanceof Error ? reason.stack : undefined,
209
- type: "unhandledrejection",
210
- handled: false,
211
- cause: reason instanceof Error ? serializeCause(reason.cause) : undefined,
212
- });
181
+ this.record(
182
+ {
183
+ kind: "unhandledrejection",
184
+ message: reason.message || "Unhandled promise rejection",
185
+ stack: reason.stack || undefined,
186
+ handled: false,
187
+ cause: serializeCause(reason.cause),
188
+ },
189
+ reason,
190
+ );
213
191
  };
214
192
 
215
- private queueError(data: ErrorData): void {
193
+ private onVisibilityChange = (): void => {
194
+ if (document.visibilityState === "hidden") this.flush();
195
+ };
196
+
197
+ private record(data: NormalizedError, original?: unknown): void {
198
+ if (original instanceof Error) {
199
+ if (this.handled.has(original)) {
200
+ this.log("Skipping duplicate:", original.message);
201
+ return;
202
+ }
203
+ this.handled.add(original);
204
+ }
205
+
216
206
  if (EXTENSION_URL.test(`${data.filename ?? ""}\n${data.stack ?? ""}`)) {
217
207
  return;
218
208
  }
219
209
 
220
- if (this.debug) {
221
- console.log("[ErrorTracker] Captured error:", data);
222
- }
210
+ const hash = hashError(data);
211
+ const existing = this.queue.get(hash);
223
212
 
224
- const hash = hashErrorData(data);
225
- const bucket = this.errorCounts.get(hash);
226
- if (bucket) {
227
- bucket.count++;
228
- if (this.debug) {
229
- console.log(
230
- `[ErrorTracker] Incremented count for ${hash} to ${bucket.count}`,
231
- );
232
- }
213
+ if (existing) {
214
+ existing.count += 1;
233
215
  } else {
234
- const entry: ErrorEntry = {
216
+ this.queue.set(hash, {
217
+ hash,
218
+ count: 1,
235
219
  error:
236
- data.type === "unhandledrejection" ? "UnhandledRejection" : "Error",
220
+ data.kind === "unhandledrejection" ? "UnhandledRejection" : "Error",
237
221
  message: data.message,
238
222
  stack: parseStack(data.stack),
239
223
  handled: data.handled,
240
224
  ...(data.cause ? { cause: data.cause } : {}),
241
- };
242
- this.errorCounts.set(hash, { entry, count: 1 });
243
- if (this.debug) {
244
- console.log(`[ErrorTracker] Queued new error: ${hash}`);
245
- }
225
+ });
246
226
  }
247
227
 
248
- if (this.errorCounts.size >= this.maxQueueSize) {
249
- this.flush();
250
- }
251
- }
228
+ this.log("Captured error:", data);
252
229
 
253
- captureError(error: Error): void {
254
- if (this.skipDuplicate(error)) {
255
- if (this.debug) {
256
- console.log("[ErrorTracker] Skipping duplicate:", error.message);
257
- }
258
- return;
230
+ if (this.queue.size >= this.maxQueueSize) {
231
+ this.flush();
259
232
  }
260
-
261
- this.queueError({
262
- message: error.message,
263
- stack: error.stack,
264
- type: "error",
265
- handled: true,
266
- cause: serializeCause(error.cause),
267
- });
268
233
  }
269
234
 
270
- private flush(): void {
271
- if (this.errorCounts.size === 0) return;
235
+ private flush = (): void => {
236
+ if (this.queue.size === 0) return;
272
237
 
273
- const errors: ErrorTracking[] = [];
274
- for (const [hash, { entry, count }] of this.errorCounts) {
275
- errors.push({ ...entry, hash, count });
276
- }
277
- this.errorCounts.clear();
278
-
279
- if (this.debug) {
280
- console.log("[ErrorTracker] Flushing errors:", errors);
281
- }
238
+ const errors = [...this.queue.values()];
239
+ this.queue.clear();
282
240
 
283
241
  const identifier = getAnonymousId();
284
242
  const sourcemapsBuild = (
@@ -286,14 +244,15 @@ class ErrorTracker {
286
244
  __SOURCEMAPS_BUILD__?: { buildId?: unknown };
287
245
  }
288
246
  ).__SOURCEMAPS_BUILD__;
247
+
289
248
  const buildId =
290
249
  typeof sourcemapsBuild?.buildId === "string" &&
291
- sourcemapsBuild.buildId.trim().length > 0
250
+ sourcemapsBuild.buildId.trim()
292
251
  ? sourcemapsBuild.buildId
293
252
  : undefined;
294
253
 
295
- const payloadString = JSON.stringify({
296
- token: this.siteKey,
254
+ const payload = JSON.stringify({
255
+ token: this.options.siteKey,
297
256
  ...(identifier ? { userId: identifier } : {}),
298
257
  sessionId: getOrCreateSessionId(),
299
258
  ...(buildId ? { buildId } : {}),
@@ -306,17 +265,14 @@ class ErrorTracker {
306
265
  errors,
307
266
  });
308
267
 
309
- if (this.debug) {
310
- console.log("[ErrorTracker] Payload:", payloadString);
311
- }
268
+ this.log("Flushing errors:", errors);
269
+ this.log("Payload:", payload);
312
270
 
313
271
  sendData({
314
272
  url: this.endpoint,
315
- data: payloadString,
273
+ data: payload,
316
274
  debug: this.debug,
317
275
  debugPrefix: "[ErrorTracker]",
318
276
  });
319
- }
277
+ };
320
278
  }
321
-
322
- export default ErrorTracker;
@@ -0,0 +1,88 @@
1
+ import { featureFlagsCheckUrl } from "./utils/api-urls";
2
+
3
+ export type FeatureFlagEvaluation = {
4
+ enabled: boolean;
5
+ value?: string;
6
+ variantKey?: string;
7
+ variantValue?: string;
8
+ };
9
+
10
+ export type FeatureFlagCheckContext = {
11
+ baseUrl?: string;
12
+ projectToken?: string;
13
+ projectId?: string;
14
+ serverId?: string;
15
+ externalId?: string;
16
+ attributes?: Record<string, unknown>;
17
+ signal?: AbortSignal;
18
+ };
19
+
20
+ function encodeAttributes(attributes: Record<string, unknown>): string {
21
+ const json = JSON.stringify(attributes);
22
+ if (typeof btoa !== "function") {
23
+ return json;
24
+ }
25
+ const bytes = new TextEncoder().encode(json);
26
+ let binary = "";
27
+ for (let i = 0; i < bytes.length; i++) {
28
+ const b = bytes[i];
29
+ if (b !== undefined) binary += String.fromCharCode(b);
30
+ }
31
+ return btoa(binary);
32
+ }
33
+
34
+ export async function fetchFeatureFlagEvaluation(
35
+ flagKey: string,
36
+ context: FeatureFlagCheckContext,
37
+ ): Promise<FeatureFlagEvaluation> {
38
+ const url = featureFlagsCheckUrl(context.baseUrl, flagKey);
39
+ const headers = new Headers();
40
+
41
+ if (context.projectToken && context.projectId) {
42
+ throw new Error("provide either projectToken or projectId, not both");
43
+ }
44
+ if (context.serverId && context.externalId) {
45
+ throw new Error("provide either serverId or externalId, not both");
46
+ }
47
+
48
+ const projectHeader: [string, string] | undefined = context.projectToken
49
+ ? ["X-Project-Token", context.projectToken]
50
+ : context.projectId
51
+ ? ["X-Project-Id", context.projectId]
52
+ : undefined;
53
+ const subjectHeader: [string, string] | undefined = context.serverId
54
+ ? ["X-Server-Id", context.serverId]
55
+ : context.externalId
56
+ ? ["X-External-Id", context.externalId]
57
+ : undefined;
58
+
59
+ if (!projectHeader) {
60
+ throw new Error("feature flag check requires projectToken or projectId");
61
+ }
62
+ if (!subjectHeader) {
63
+ throw new Error("feature flag check requires serverId or externalId");
64
+ }
65
+
66
+ headers.set(projectHeader[0], projectHeader[1]);
67
+ headers.set(subjectHeader[0], subjectHeader[1]);
68
+
69
+ if (context.attributes && Object.keys(context.attributes).length > 0) {
70
+ headers.set("X-Attributes", encodeAttributes(context.attributes));
71
+ }
72
+
73
+ const res = await fetch(url, {
74
+ method: "GET",
75
+ headers,
76
+ credentials: "omit",
77
+ signal: context.signal,
78
+ });
79
+ if (!res.ok) {
80
+ throw new Error(`feature flag check failed: HTTP ${res.status}`);
81
+ }
82
+
83
+ const data = (await res.json()) as { evaluation?: FeatureFlagEvaluation };
84
+ const evaluation = data.evaluation;
85
+ return evaluation?.enabled === true || evaluation?.enabled === false
86
+ ? evaluation
87
+ : { enabled: false };
88
+ }
package/src/module.ts CHANGED
@@ -14,3 +14,12 @@ export {
14
14
  WebAnalytics,
15
15
  type WebAnalyticsOptions,
16
16
  } from "./analytics";
17
+ export {
18
+ type FeatureFlagCheckContext,
19
+ type FeatureFlagEvaluation,
20
+ fetchFeatureFlagEvaluation,
21
+ } from "./feature-flags";
22
+ export {
23
+ featureFlagsCheckUrl,
24
+ normalizeFeatureFlagsBaseUrl,
25
+ } from "./utils/api-urls";