@faststats/web 0.0.1

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 ADDED
@@ -0,0 +1,324 @@
1
+ import type { SendDataOptions } from "./utils/types";
2
+
3
+ export type ErrorEntry = {
4
+ error: string;
5
+ message?: string;
6
+ stack?: string[];
7
+ cause?: ErrorEntry;
8
+ };
9
+
10
+ export type ErrorTracking = {
11
+ hash: string;
12
+ count: number;
13
+ } & ErrorEntry;
14
+
15
+ export type ErrorData = {
16
+ message: string;
17
+ filename?: string;
18
+ lineno?: number;
19
+ colno?: number;
20
+ stack?: string;
21
+ type: "error" | "unhandledrejection";
22
+ };
23
+
24
+ export interface ErrorTrackingOptions {
25
+ siteKey: string;
26
+ endpoint?: string;
27
+ debug?: boolean;
28
+ flushInterval?: number;
29
+ maxQueueSize?: number;
30
+ getCommonData?: () => Record<string, unknown>;
31
+ }
32
+
33
+ class ErrorTracker {
34
+ private readonly endpoint: string;
35
+ private readonly siteKey: string;
36
+ private readonly debug: boolean;
37
+ private readonly flushInterval: number;
38
+ private readonly maxQueueSize: number;
39
+ private readonly getCommonData: () => Record<string, unknown>;
40
+
41
+ private readonly handledErrors = new WeakSet<Error>();
42
+ private readonly errorCounts = new Map<
43
+ string,
44
+ { entry: ErrorEntry; hash: string; count: number }
45
+ >();
46
+ private flushTimer: ReturnType<typeof setInterval> | null = null;
47
+ private started = false;
48
+
49
+ constructor(options: ErrorTrackingOptions) {
50
+ this.siteKey = options.siteKey;
51
+ this.endpoint = options.endpoint ?? "https://metrics.faststats.dev/v1/web";
52
+ this.debug = options.debug ?? false;
53
+ this.flushInterval = options.flushInterval ?? 5000;
54
+ this.maxQueueSize = options.maxQueueSize ?? 50;
55
+ this.getCommonData = options.getCommonData ?? (() => ({}));
56
+ }
57
+
58
+ start(): void {
59
+ if (this.started || typeof window === "undefined") return;
60
+ if (window.__FA_isTrackingDisabled?.()) {
61
+ if (this.debug) {
62
+ console.log("[ErrorTracker] Tracking disabled via localStorage");
63
+ }
64
+ return;
65
+ }
66
+ this.started = true;
67
+
68
+ window.addEventListener("error", this.handleErrorEvent);
69
+ window.addEventListener("unhandledrejection", this.handleRejection);
70
+
71
+ this.flushTimer = setInterval(() => this.flush(), this.flushInterval);
72
+ document.addEventListener("visibilitychange", () => {
73
+ if (document.visibilityState === "hidden") this.flush();
74
+ });
75
+ window.addEventListener("pagehide", () => this.flush());
76
+
77
+ if (this.debug) {
78
+ console.log("[ErrorTracker] Started listening for errors");
79
+ }
80
+ }
81
+
82
+ stop(): void {
83
+ if (!this.started || typeof window === "undefined") return;
84
+ this.started = false;
85
+
86
+ window.removeEventListener("error", this.handleErrorEvent);
87
+ window.removeEventListener("unhandledrejection", this.handleRejection);
88
+
89
+ if (this.flushTimer) {
90
+ clearInterval(this.flushTimer);
91
+ this.flushTimer = null;
92
+ }
93
+
94
+ this.flush();
95
+
96
+ if (this.debug) {
97
+ console.log("[ErrorTracker] Stopped listening for errors");
98
+ }
99
+ }
100
+
101
+ private handleErrorEvent = (event: ErrorEvent): void => {
102
+ const error = event.error;
103
+
104
+ if (error instanceof Error) {
105
+ if (this.handledErrors.has(error)) {
106
+ if (this.debug) {
107
+ console.log(
108
+ "[ErrorTracker] Skipping duplicate error:",
109
+ error.message,
110
+ );
111
+ }
112
+ return;
113
+ }
114
+ this.handledErrors.add(error);
115
+ }
116
+
117
+ this.queueError({
118
+ message: event.message || (error?.message ?? "Unknown error"),
119
+ filename: event.filename || undefined,
120
+ lineno: event.lineno || undefined,
121
+ colno: event.colno || undefined,
122
+ stack: error?.stack || undefined,
123
+ type: "error",
124
+ });
125
+ };
126
+
127
+ private handleRejection = (event: PromiseRejectionEvent): void => {
128
+ const reason = event.reason;
129
+
130
+ if (reason instanceof Error) {
131
+ if (this.handledErrors.has(reason)) {
132
+ if (this.debug) {
133
+ console.log(
134
+ "[ErrorTracker] Skipping duplicate rejection:",
135
+ reason.message,
136
+ );
137
+ }
138
+ return;
139
+ }
140
+ this.handledErrors.add(reason);
141
+ }
142
+
143
+ this.queueError({
144
+ message:
145
+ reason instanceof Error
146
+ ? reason.message
147
+ : typeof reason === "string"
148
+ ? reason
149
+ : "Unhandled promise rejection",
150
+ stack: reason instanceof Error ? reason.stack : undefined,
151
+ type: "unhandledrejection",
152
+ });
153
+ };
154
+
155
+ private isExtensionError(errorData: ErrorData): boolean {
156
+ if (errorData.filename?.startsWith("chrome-extension://")) {
157
+ return true;
158
+ }
159
+ if (errorData.stack) {
160
+ const firstFrame = errorData.stack
161
+ .split("\n")
162
+ .find((line) => line.trim().startsWith("at "));
163
+ if (firstFrame?.includes("chrome-extension://")) {
164
+ return true;
165
+ }
166
+ }
167
+ return false;
168
+ }
169
+
170
+ private parseStack(stack?: string): string[] | undefined {
171
+ if (!stack) return undefined;
172
+ return stack
173
+ .split("\n")
174
+ .map((line) => line.trim())
175
+ .filter((line) => line.length > 0);
176
+ }
177
+
178
+ private async generateErrorHash(errorData: ErrorData): Promise<string> {
179
+ const key = [
180
+ errorData.type,
181
+ errorData.message,
182
+ errorData.filename ?? "",
183
+ errorData.lineno ?? "",
184
+ ].join(":");
185
+
186
+ const encoder = new TextEncoder();
187
+ const data = encoder.encode(key);
188
+
189
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
190
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
191
+ const hashHex = hashArray
192
+ .map((b) => b.toString(16).padStart(2, "0"))
193
+ .join("");
194
+
195
+ return `err_${hashHex}`;
196
+ }
197
+
198
+ private async queueError(errorData: ErrorData): Promise<void> {
199
+ if (this.isExtensionError(errorData)) return;
200
+
201
+ if (this.debug) {
202
+ console.log("[ErrorTracker] Captured error:", errorData);
203
+ }
204
+
205
+ const hash = await this.generateErrorHash(errorData);
206
+ const existing = this.errorCounts.get(hash);
207
+
208
+ if (existing) {
209
+ existing.count++;
210
+ if (this.debug) {
211
+ console.log(
212
+ `[ErrorTracker] Incremented count for ${hash} to ${existing.count}`,
213
+ );
214
+ }
215
+ } else {
216
+ const entry: ErrorEntry = {
217
+ error:
218
+ errorData.type === "unhandledrejection"
219
+ ? "UnhandledRejection"
220
+ : "Error",
221
+ message: errorData.message,
222
+ stack: this.parseStack(errorData.stack),
223
+ };
224
+ this.errorCounts.set(hash, { entry, hash, count: 1 });
225
+
226
+ if (this.debug) {
227
+ console.log(`[ErrorTracker] Queued new error: ${hash}`);
228
+ }
229
+ }
230
+
231
+ if (this.errorCounts.size >= this.maxQueueSize) {
232
+ this.flush();
233
+ }
234
+ }
235
+
236
+ captureError(error: Error): void {
237
+ if (this.handledErrors.has(error)) {
238
+ if (this.debug) {
239
+ console.log(
240
+ "[ErrorTracker] Skipping duplicate manual capture:",
241
+ error.message,
242
+ );
243
+ }
244
+ return;
245
+ }
246
+ this.handledErrors.add(error);
247
+
248
+ this.queueError({
249
+ message: error.message,
250
+ stack: error.stack,
251
+ type: "error",
252
+ });
253
+ }
254
+
255
+ private flush(): void {
256
+ if (this.errorCounts.size === 0) return;
257
+
258
+ const errors: ErrorTracking[] = [];
259
+ for (const { entry, hash, count } of this.errorCounts.values()) {
260
+ errors.push({ ...entry, hash, count });
261
+ }
262
+ this.errorCounts.clear();
263
+
264
+ if (this.debug) {
265
+ console.log("[ErrorTracker] Flushing errors:", errors);
266
+ }
267
+
268
+ const commonData = this.getCommonData();
269
+ const identifier = window.__FA_getAnonymousId?.();
270
+ const sourcemapsBuild = (
271
+ globalThis as {
272
+ __SOURCEMAPS_BUILD__?: { buildId?: unknown };
273
+ }
274
+ ).__SOURCEMAPS_BUILD__;
275
+ const buildId =
276
+ typeof sourcemapsBuild?.buildId === "string" &&
277
+ sourcemapsBuild.buildId.trim().length > 0
278
+ ? sourcemapsBuild.buildId
279
+ : undefined;
280
+
281
+ const payload: Record<string, unknown> = {
282
+ token: this.siteKey,
283
+ ...(identifier ? { userId: identifier } : {}),
284
+ sessionId: window.__FA_getSessionId?.(),
285
+ ...(buildId ? { buildId } : {}),
286
+ data: {
287
+ url: commonData.url as string,
288
+ page: commonData.page as string,
289
+ referrer: commonData.referrer as string,
290
+ title: typeof document !== "undefined" ? document.title : "",
291
+ },
292
+ errors,
293
+ };
294
+
295
+ const payloadString = JSON.stringify(payload);
296
+
297
+ if (this.debug) {
298
+ console.log("[ErrorTracker] Payload:", payloadString);
299
+ }
300
+
301
+ window.__FA_sendData?.({
302
+ url: this.endpoint,
303
+ data: payloadString,
304
+ debug: this.debug,
305
+ debugPrefix: "[ErrorTracker]",
306
+ });
307
+ }
308
+ }
309
+
310
+ export default ErrorTracker;
311
+
312
+ declare global {
313
+ interface Window {
314
+ __FA_ErrorTracker?: typeof ErrorTracker;
315
+ __FA_getAnonymousId?: () => string;
316
+ __FA_getSessionId?: () => string;
317
+ __FA_sendData?: (options: SendDataOptions) => Promise<boolean>;
318
+ __FA_isTrackingDisabled?: () => boolean;
319
+ }
320
+ }
321
+
322
+ if (typeof window !== "undefined") {
323
+ window.__FA_ErrorTracker = ErrorTracker;
324
+ }
package/src/index.ts ADDED
@@ -0,0 +1,95 @@
1
+ import {
2
+ type ConsentMode,
3
+ isTrackingDisabled,
4
+ sendData,
5
+ WebAnalytics,
6
+ type WebAnalyticsOptions,
7
+ } from "./analytics";
8
+
9
+ export { isTrackingDisabled, sendData, WebAnalytics };
10
+ export type { ConsentMode, WebAnalyticsOptions };
11
+
12
+ function parseBooleanAttribute(
13
+ value: string | null | undefined,
14
+ ): boolean | undefined {
15
+ if (value == null) return undefined;
16
+ const normalized = value.trim().toLowerCase();
17
+ if (normalized === "true" || normalized === "1") return true;
18
+ if (normalized === "false" || normalized === "0") return false;
19
+ return undefined;
20
+ }
21
+
22
+ function parseSamplingAttribute(
23
+ value: string | null | undefined,
24
+ ): number | undefined {
25
+ if (value == null || value.trim() === "") return undefined;
26
+ const parsed = Number(value);
27
+ if (!Number.isFinite(parsed)) return undefined;
28
+ return Math.max(0, Math.min(100, parsed));
29
+ }
30
+
31
+ function parseConsentModeAttribute(
32
+ value: string | null | undefined,
33
+ ): ConsentMode | undefined {
34
+ if (value == null) return undefined;
35
+ const normalized = value.trim().toLowerCase();
36
+ if (normalized === "pending") return "pending";
37
+ if (normalized === "granted") return "granted";
38
+ if (normalized === "denied") return "denied";
39
+ return undefined;
40
+ }
41
+
42
+ if (typeof window !== "undefined") {
43
+ window.WebAnalytics = WebAnalytics;
44
+ window.__FA_isTrackingDisabled = isTrackingDisabled;
45
+
46
+ (() => {
47
+ const s = document.currentScript as HTMLScriptElement | null;
48
+ if (!s) return;
49
+ const el = s;
50
+ const d = (k: string) => el.getAttribute(`data-${k}`);
51
+ const siteKey = d("sitekey");
52
+ if (!siteKey) return;
53
+ const opts: WebAnalyticsOptions = { siteKey };
54
+ const endpoint = d("endpoint");
55
+ if (endpoint) opts.endpoint = endpoint;
56
+ const dbg = d("debug");
57
+ if (dbg) opts.debug = dbg === "true";
58
+ const err = parseBooleanAttribute(d("trackErrors"));
59
+ if (err !== undefined) {
60
+ opts.trackErrors = err;
61
+ opts.errorTracking = { enabled: err };
62
+ }
63
+ const wv = parseBooleanAttribute(d("webVitals"));
64
+ const wvs = parseSamplingAttribute(d("web-vitals-sampling"));
65
+ if (wv !== undefined || wvs !== undefined) {
66
+ if (wv !== undefined) opts.trackWebVitals = wv;
67
+ opts.webVitals = {
68
+ enabled: wv,
69
+ sampling: wvs !== undefined ? { percentage: wvs } : undefined,
70
+ };
71
+ }
72
+ const rp = parseBooleanAttribute(d("replay"));
73
+ const rps = parseSamplingAttribute(d("replay-sampling"));
74
+ if (rp !== undefined || rps !== undefined) {
75
+ if (rp !== undefined) opts.trackReplay = rp;
76
+ opts.sessionReplays = {
77
+ enabled: rp,
78
+ sampling: rps !== undefined ? { percentage: rps } : undefined,
79
+ };
80
+ }
81
+ const cookieless = parseBooleanAttribute(d("cookieless"));
82
+ if (cookieless !== undefined) opts.cookieless = cookieless;
83
+ const consentMode = parseConsentModeAttribute(d("consent-mode"));
84
+ const cookielessWhilePending = parseBooleanAttribute(
85
+ d("cookieless-while-pending"),
86
+ );
87
+ if (consentMode !== undefined || cookielessWhilePending !== undefined) {
88
+ opts.consent = {
89
+ mode: consentMode,
90
+ cookielessWhilePending,
91
+ };
92
+ }
93
+ new WebAnalytics(opts);
94
+ })();
95
+ }
package/src/module.ts ADDED
@@ -0,0 +1,6 @@
1
+ export {
2
+ type ConsentMode,
3
+ type IdentifyOptions,
4
+ WebAnalytics,
5
+ type WebAnalyticsOptions,
6
+ } from "./analytics";