@faststats/web 0.1.3 → 0.1.5

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/analytics.ts CHANGED
@@ -1,4 +1,11 @@
1
+ import type ErrorTracker from "./error";
2
+ import type ReplayTracker from "./replay";
1
3
  import type { ReplayTrackerOptions } from "./replay";
4
+ import {
5
+ identifyEventsUrl,
6
+ normalizeAnalyticsBaseUrl,
7
+ webEventsUrl,
8
+ } from "./utils/api-urls";
2
9
  import {
3
10
  getAnonymousId,
4
11
  getOrCreateSessionId,
@@ -12,9 +19,59 @@ import {
12
19
  normalizeSamplingPercentage,
13
20
  type SendDataOptions,
14
21
  } from "./utils/types";
22
+ import type WebVitalsTracker from "./web-vitals";
15
23
 
16
24
  export type { SendDataOptions };
17
25
 
26
+ type ChildTracker = {
27
+ start(): void;
28
+ stop?(): void;
29
+ };
30
+
31
+ type Dict = Record<string, unknown>;
32
+
33
+ interface SamplingOptions {
34
+ percentage?: number;
35
+ }
36
+
37
+ interface WebVitalsConfig {
38
+ sampling?: SamplingOptions;
39
+ }
40
+
41
+ interface SessionReplayConfig {
42
+ sampling?: SamplingOptions;
43
+ }
44
+
45
+ export type ConsentMode = "pending" | "granted" | "denied";
46
+
47
+ interface ConsentConfig {
48
+ mode?: ConsentMode;
49
+ cookielessWhilePending?: boolean;
50
+ }
51
+
52
+ export interface IdentifyOptions {
53
+ name?: string;
54
+ phone?: string;
55
+ avatarUrl?: string;
56
+ traits?: Record<string, unknown>;
57
+ }
58
+
59
+ export interface WebAnalyticsOptions {
60
+ siteKey: string;
61
+ baseUrl?: string;
62
+ debug?: boolean;
63
+ autoTrack?: boolean;
64
+ trackHash?: boolean;
65
+ trackErrors?: boolean;
66
+ trackWebVitals?: boolean;
67
+ trackReplay?: boolean;
68
+ cookieless?: boolean;
69
+ consent?: ConsentConfig;
70
+ webVitals?: WebVitalsConfig;
71
+ sessionReplays?: SessionReplayConfig;
72
+ replayOptions?: Partial<ReplayTrackerOptions>;
73
+ }
74
+
18
75
  let _instance: WebAnalytics | null = null;
19
76
 
20
77
  export function getInstance(): WebAnalytics | null {
@@ -55,6 +112,11 @@ export function optOut(): void {
55
112
  setConsentMode("denied");
56
113
  }
57
114
 
115
+ export function reportError(error: Error): void {
116
+ if (typeof window === "undefined" || isTrackingDisabled()) return;
117
+ _instance?.reportError(error);
118
+ }
119
+
58
120
  export function isTrackingDisabled(): boolean {
59
121
  if (typeof localStorage === "undefined") return false;
60
122
  const value = localStorage.getItem("disable-faststats");
@@ -76,7 +138,7 @@ export async function sendData(options: SendDataOptions): Promise<boolean> {
76
138
 
77
139
  if (navigator.sendBeacon?.(url, blob)) {
78
140
  if (debug) {
79
- console.log(`${debugPrefix} Sent via beacon`);
141
+ console.log(`${debugPrefix} Sent via beacon`);
80
142
  }
81
143
  return true;
82
144
  }
@@ -94,15 +156,15 @@ export async function sendData(options: SendDataOptions): Promise<boolean> {
94
156
  const success = response.ok;
95
157
  if (debug) {
96
158
  if (success) {
97
- console.log(`${debugPrefix} Sent via fetch`);
159
+ console.log(`${debugPrefix} Sent via fetch`);
98
160
  } else {
99
- console.warn(`${debugPrefix} Failed: ${response.status}`);
161
+ console.warn(`${debugPrefix} Failed: ${response.status}`);
100
162
  }
101
163
  }
102
164
  return success;
103
165
  } catch {
104
166
  if (debug) {
105
- console.warn(`${debugPrefix} Failed to send`);
167
+ console.warn(`${debugPrefix} Failed to send`);
106
168
  }
107
169
  return false;
108
170
  }
@@ -133,59 +195,12 @@ function getUTM(): Record<string, string> {
133
195
  return params;
134
196
  }
135
197
 
136
- type Dict = Record<string, unknown>;
137
-
138
- interface SamplingOptions {
139
- percentage?: number;
140
- }
141
-
142
- interface ErrorTrackingConfig {
143
- enabled?: boolean;
144
- }
145
-
146
- interface WebVitalsConfig {
147
- sampling?: SamplingOptions;
148
- }
149
-
150
- interface SessionReplayConfig {
151
- sampling?: SamplingOptions;
152
- }
153
-
154
- export type ConsentMode = "pending" | "granted" | "denied";
155
-
156
- interface ConsentConfig {
157
- mode?: ConsentMode;
158
- cookielessWhilePending?: boolean;
159
- }
160
-
161
- export interface IdentifyOptions {
162
- name?: string;
163
- phone?: string;
164
- avatarUrl?: string;
165
- traits?: Record<string, unknown>;
166
- }
167
-
168
- export interface WebAnalyticsOptions {
169
- siteKey: string;
170
- endpoint?: string;
171
- debug?: boolean;
172
- autoTrack?: boolean;
173
- trackHash?: boolean;
174
- trackErrors?: boolean;
175
- trackWebVitals?: boolean;
176
- trackReplay?: boolean;
177
- cookieless?: boolean;
178
- consent?: ConsentConfig;
179
- errorTracking?: ErrorTrackingConfig;
180
- webVitals?: WebVitalsConfig;
181
- sessionReplays?: SessionReplayConfig;
182
- replayOptions?: Partial<ReplayTrackerOptions>;
183
- }
184
-
185
198
  export class WebAnalytics {
186
- private readonly endpoint: string;
199
+ private readonly webEndpoint: string;
200
+ private readonly baseUrl: string;
187
201
  private readonly debug: boolean;
188
202
  private started = false;
203
+ private destroyed = false;
189
204
  private pageKey = "";
190
205
  private navTimer: ReturnType<typeof setTimeout> | null = null;
191
206
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
@@ -198,9 +213,30 @@ export class WebAnalytics {
198
213
  private scrollHandler: (() => void) | null = null;
199
214
  private consentMode: ConsentMode;
200
215
  private readonly cookielessWhilePending: boolean;
216
+ private readonly cleanupCallbacks: Array<() => void> = [];
217
+ private readonly childTrackers: ChildTracker[] = [];
218
+ private readonly pendingReportedErrors: Error[] = [];
219
+ private readonly handleVisibilityChange = (): void => {
220
+ if (document.visibilityState === "hidden") {
221
+ this.leavePage();
222
+ this.stopHeartbeat();
223
+ } else {
224
+ this.startHeartbeat();
225
+ }
226
+ };
227
+ private readonly handlePageHide = (): void => {
228
+ this.leavePage();
229
+ };
230
+ private readonly handlePopState = (): void => {
231
+ this.navigate();
232
+ };
233
+ private readonly handleHashChange = (): void => {
234
+ this.navigate();
235
+ };
201
236
 
202
237
  constructor(private readonly options: WebAnalyticsOptions) {
203
- this.endpoint = options.endpoint ?? "https://metrics.faststats.dev/v1/web";
238
+ this.baseUrl = normalizeAnalyticsBaseUrl(options.baseUrl);
239
+ this.webEndpoint = webEventsUrl(this.baseUrl);
204
240
  this.debug = options.debug ?? false;
205
241
  this.consentMode = options.consent?.mode ?? "granted";
206
242
  this.cookielessWhilePending =
@@ -219,11 +255,92 @@ export class WebAnalytics {
219
255
  return;
220
256
  }
221
257
  _instance = this;
258
+ setCookielessMode(this.isCookielessMode());
222
259
  setTimeout(() => void this.start(), 0);
223
260
  }
224
261
 
262
+ private registerCleanup(cleanup: () => void): void {
263
+ this.cleanupCallbacks.push(cleanup);
264
+ }
265
+
266
+ private addWindowListener<K extends keyof WindowEventMap>(
267
+ type: K,
268
+ listener: (event: WindowEventMap[K]) => void,
269
+ ): void {
270
+ window.addEventListener(type, listener);
271
+ this.registerCleanup(() => window.removeEventListener(type, listener));
272
+ }
273
+
274
+ private addDocumentListener<K extends keyof DocumentEventMap>(
275
+ type: K,
276
+ listener: (event: DocumentEventMap[K]) => void,
277
+ ): void {
278
+ document.addEventListener(type, listener);
279
+ this.registerCleanup(() => document.removeEventListener(type, listener));
280
+ }
281
+
282
+ private patchHistory(): void {
283
+ const originalPushState = history.pushState.bind(history);
284
+ const originalReplaceState = history.replaceState.bind(history);
285
+
286
+ history.pushState = (data, unused, url) => {
287
+ originalPushState(data, unused, url);
288
+ this.navigate();
289
+ };
290
+ history.replaceState = (data, unused, url) => {
291
+ originalReplaceState(data, unused, url);
292
+ this.navigate();
293
+ };
294
+
295
+ this.registerCleanup(() => {
296
+ history.pushState = originalPushState;
297
+ history.replaceState = originalReplaceState;
298
+ });
299
+ }
300
+
301
+ private ensureStarted(): boolean {
302
+ if (
303
+ typeof window === "undefined" ||
304
+ this.destroyed ||
305
+ isTrackingDisabled()
306
+ ) {
307
+ return false;
308
+ }
309
+ if (!this.started) {
310
+ void this.start();
311
+ }
312
+ return true;
313
+ }
314
+
315
+ private stopHeartbeat(): void {
316
+ if (this.heartbeatTimer) {
317
+ clearInterval(this.heartbeatTimer);
318
+ this.heartbeatTimer = null;
319
+ }
320
+ }
321
+
322
+ private stopNavigationTimer(): void {
323
+ if (this.navTimer) {
324
+ clearTimeout(this.navTimer);
325
+ this.navTimer = null;
326
+ }
327
+ }
328
+
329
+ private stopChildTrackers(): void {
330
+ for (const tracker of this.childTrackers.splice(0)) {
331
+ tracker.stop?.();
332
+ }
333
+ }
334
+
335
+ private getErrorTracker(): ErrorTracker | null {
336
+ for (const t of this.childTrackers) {
337
+ if ("captureError" in t) return t as ErrorTracker;
338
+ }
339
+ return null;
340
+ }
341
+
225
342
  async start(): Promise<void> {
226
- if (this.started || typeof window === "undefined") return;
343
+ if (this.started || this.destroyed || typeof window === "undefined") return;
227
344
  if (_instance && _instance !== this) {
228
345
  this.log("already started by another instance");
229
346
  return;
@@ -232,42 +349,58 @@ export class WebAnalytics {
232
349
  this.log("disabled");
233
350
  return;
234
351
  }
352
+
235
353
  this.started = true;
236
354
  _instance = this;
237
-
238
355
  setCookielessMode(this.isCookielessMode());
239
- const opts = this.options;
240
356
 
241
- if (opts.errorTracking?.enabled ?? opts.trackErrors) {
242
- const { default: ErrorTracker } = await import("./error");
243
- new ErrorTracker({
244
- siteKey: opts.siteKey,
245
- endpoint: this.endpoint,
246
- debug: this.debug,
247
- }).start();
248
- this.log("error loaded");
249
- }
250
- if (opts.trackWebVitals) {
251
- const { default: WebVitalsTracker } = await import("./web-vitals");
252
- new WebVitalsTracker({
253
- siteKey: opts.siteKey,
254
- endpoint: this.endpoint,
255
- debug: this.debug,
256
- samplingPercentage: normalizeSamplingPercentage(
257
- opts.webVitals?.sampling?.percentage,
258
- ),
259
- }).start();
260
- this.log("web-vitals loaded");
261
- }
262
- if (opts.trackReplay) {
263
- const { default: ReplayTracker } = await import("./replay");
264
- new ReplayTracker({
265
- siteKey: opts.siteKey,
266
- endpoint: this.endpoint,
267
- debug: this.debug,
268
- ...opts.replayOptions,
269
- }).start();
270
- this.log("replay loaded");
357
+ const opts = this.options;
358
+ try {
359
+ if (opts.trackErrors) {
360
+ const { default: ErrorTrackerClass } = await import("./error");
361
+ const errorTracker: ErrorTracker = new ErrorTrackerClass({
362
+ siteKey: opts.siteKey,
363
+ baseUrl: this.baseUrl,
364
+ debug: this.debug,
365
+ });
366
+ errorTracker.start();
367
+ this.childTrackers.push(errorTracker);
368
+ while (this.pendingReportedErrors.length > 0) {
369
+ const pending = this.pendingReportedErrors.shift();
370
+ if (pending) errorTracker.captureError(pending);
371
+ }
372
+ this.log("error loaded");
373
+ }
374
+ if (opts.trackWebVitals) {
375
+ const { default: WebVitalsTrackerClass } = await import("./web-vitals");
376
+ const webVitalsTracker: WebVitalsTracker = new WebVitalsTrackerClass({
377
+ siteKey: opts.siteKey,
378
+ baseUrl: this.baseUrl,
379
+ debug: this.debug,
380
+ samplingPercentage: normalizeSamplingPercentage(
381
+ opts.webVitals?.sampling?.percentage,
382
+ ),
383
+ });
384
+ webVitalsTracker.start();
385
+ this.childTrackers.push(webVitalsTracker);
386
+ this.log("web-vitals loaded");
387
+ }
388
+ if (opts.trackReplay) {
389
+ const { default: ReplayTrackerClass } = await import("./replay");
390
+ const replayTracker: ReplayTracker = new ReplayTrackerClass({
391
+ siteKey: opts.siteKey,
392
+ baseUrl: this.baseUrl,
393
+ debug: this.debug,
394
+ ...opts.replayOptions,
395
+ });
396
+ replayTracker.start();
397
+ this.childTrackers.push(replayTracker);
398
+ this.log("replay loaded");
399
+ }
400
+ } catch (error) {
401
+ this.log(`failed to initialize trackers: ${String(error)}`);
402
+ this.destroy();
403
+ return;
271
404
  }
272
405
 
273
406
  this.enterPage();
@@ -276,18 +409,46 @@ export class WebAnalytics {
276
409
  this.trackScroll();
277
410
  this.startHeartbeat();
278
411
 
279
- document.addEventListener("visibilitychange", () => {
280
- if (document.visibilityState === "hidden") this.leavePage();
281
- else this.startHeartbeat();
282
- });
283
- window.addEventListener("pagehide", () => this.leavePage());
284
- window.addEventListener("popstate", () => this.navigate());
285
- if (opts.trackHash)
286
- window.addEventListener("hashchange", () => this.navigate());
287
- this.patch();
412
+ this.addDocumentListener("visibilitychange", this.handleVisibilityChange);
413
+ this.addWindowListener("pagehide", this.handlePageHide);
414
+ this.addWindowListener("popstate", this.handlePopState);
415
+ if (opts.trackHash) {
416
+ this.addWindowListener("hashchange", this.handleHashChange);
417
+ }
418
+ this.patchHistory();
419
+ }
420
+
421
+ destroy(): void {
422
+ if (this.destroyed) return;
423
+ if (this.started && typeof window !== "undefined") {
424
+ this.leavePage();
425
+ }
426
+
427
+ this.pendingReportedErrors.length = 0;
428
+
429
+ this.stopNavigationTimer();
430
+ this.stopHeartbeat();
431
+
432
+ if (this.scrollHandler && typeof window !== "undefined") {
433
+ window.removeEventListener("scroll", this.scrollHandler);
434
+ this.scrollHandler = null;
435
+ }
436
+
437
+ while (this.cleanupCallbacks.length > 0) {
438
+ const cleanup = this.cleanupCallbacks.pop();
439
+ cleanup?.();
440
+ }
441
+
442
+ this.stopChildTrackers();
443
+ if (_instance === this) {
444
+ _instance = null;
445
+ }
446
+ this.started = false;
447
+ this.destroyed = true;
288
448
  }
289
449
 
290
450
  pageview(extra: Dict = {}): void {
451
+ if (!this.ensureStarted()) return;
291
452
  const key = `${location.pathname}|${(this.options.trackHash ?? false) ? location.hash : ""}`;
292
453
  if (key === this.pageKey) return;
293
454
  this.pageKey = key;
@@ -295,6 +456,7 @@ export class WebAnalytics {
295
456
  }
296
457
 
297
458
  track(name: string, extra: Dict = {}): void {
459
+ if (!this.ensureStarted()) return;
298
460
  this.send(name, extra);
299
461
  }
300
462
 
@@ -303,17 +465,14 @@ export class WebAnalytics {
303
465
  email: string,
304
466
  options: IdentifyOptions = {},
305
467
  ): void {
306
- if (isTrackingDisabled()) return;
468
+ if (!this.ensureStarted()) return;
307
469
  if (this.isCookielessMode()) return;
308
470
 
309
471
  const trimmedExternalId = externalId.trim();
310
472
  const trimmedEmail = email.trim();
311
473
  if (!trimmedExternalId || !trimmedEmail) return;
312
474
 
313
- const identifyEndpoint = this.endpoint.replace(
314
- /\/v1\/web$/,
315
- "/v1/identify",
316
- );
475
+ const identifyEndpoint = identifyEventsUrl(this.baseUrl);
317
476
  const payload = JSON.stringify({
318
477
  token: this.options.siteKey,
319
478
  identifier: getAnonymousId(false),
@@ -325,7 +484,7 @@ export class WebAnalytics {
325
484
  traits: options.traits ?? {},
326
485
  });
327
486
 
328
- sendData({
487
+ void sendData({
329
488
  url: identifyEndpoint,
330
489
  data: payload,
331
490
  contentType: "text/plain",
@@ -335,7 +494,7 @@ export class WebAnalytics {
335
494
  }
336
495
 
337
496
  logout(resetAnonymousIdentity = true): void {
338
- if (isTrackingDisabled()) return;
497
+ if (!this.ensureStarted()) return;
339
498
  if (resetAnonymousIdentity) {
340
499
  resetAnonymousId(this.isCookielessMode());
341
500
  }
@@ -367,6 +526,27 @@ export class WebAnalytics {
367
526
  return getOrCreateSessionId();
368
527
  }
369
528
 
529
+ reportError(error: Error): void {
530
+ if (
531
+ this.destroyed ||
532
+ typeof window === "undefined" ||
533
+ isTrackingDisabled()
534
+ ) {
535
+ return;
536
+ }
537
+ if (!(this.options.trackErrors ?? false)) return;
538
+ if (!this.ensureStarted()) return;
539
+ const tracker = this.getErrorTracker();
540
+ if (tracker) {
541
+ tracker.captureError(error);
542
+ return;
543
+ }
544
+ if (this.pendingReportedErrors.length >= 50) {
545
+ this.pendingReportedErrors.shift();
546
+ }
547
+ this.pendingReportedErrors.push(error);
548
+ }
549
+
370
550
  private isCookielessMode(): boolean {
371
551
  if (this.options.cookieless) return true;
372
552
  if (this.consentMode === "denied") return true;
@@ -375,6 +555,14 @@ export class WebAnalytics {
375
555
  }
376
556
 
377
557
  private send(event: string, extra: Dict = {}): void {
558
+ if (
559
+ typeof window === "undefined" ||
560
+ this.destroyed ||
561
+ isTrackingDisabled()
562
+ ) {
563
+ return;
564
+ }
565
+
378
566
  const identifier = getAnonymousId(this.isCookielessMode());
379
567
  const payload = JSON.stringify({
380
568
  token: this.options.siteKey,
@@ -391,8 +579,8 @@ export class WebAnalytics {
391
579
  },
392
580
  });
393
581
  this.log(event);
394
- sendData({
395
- url: this.endpoint,
582
+ void sendData({
583
+ url: this.webEndpoint,
396
584
  data: payload,
397
585
  contentType: "text/plain",
398
586
  debug: this.debug,
@@ -410,7 +598,7 @@ export class WebAnalytics {
410
598
  }
411
599
 
412
600
  private leavePage(): void {
413
- if (this.hasLeftCurrentPage) return;
601
+ if (this.destroyed || this.hasLeftCurrentPage) return;
414
602
  this.hasLeftCurrentPage = true;
415
603
  const now = Date.now();
416
604
  this.send("page_leave", {
@@ -449,12 +637,11 @@ export class WebAnalytics {
449
637
  }
450
638
 
451
639
  private startHeartbeat(): void {
452
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
640
+ this.stopHeartbeat();
453
641
  this.heartbeatTimer = setInterval(
454
642
  () => {
455
643
  if (document.visibilityState === "hidden") {
456
- clearInterval(this.heartbeatTimer as ReturnType<typeof setInterval>);
457
- this.heartbeatTimer = null;
644
+ this.stopHeartbeat();
458
645
  return;
459
646
  }
460
647
  refreshSessionTimestamp();
@@ -464,7 +651,8 @@ export class WebAnalytics {
464
651
  }
465
652
 
466
653
  private navigate(): void {
467
- if (this.navTimer) clearTimeout(this.navTimer);
654
+ if (!this.started || this.destroyed) return;
655
+ this.stopNavigationTimer();
468
656
  this.navTimer = setTimeout(() => {
469
657
  this.navTimer = null;
470
658
  const pathChanged = location.pathname !== this.pagePath;
@@ -478,28 +666,14 @@ export class WebAnalytics {
478
666
  }, 300);
479
667
  }
480
668
 
481
- private patch(): void {
482
- const fire = () => this.navigate();
483
- for (const method of ["pushState", "replaceState"] as const) {
484
- const orig = history[method];
485
- history[method] = function (
486
- this: History,
487
- ...args: Parameters<typeof orig>
488
- ) {
489
- const result = orig.apply(this, args);
490
- fire();
491
- return result;
492
- };
493
- }
494
- }
495
-
496
669
  private links(): void {
497
670
  const handler = (event: MouseEvent) => {
498
671
  const link = getLinkEl(event.target as Node);
499
- if (link && link.host !== location.host)
672
+ if (link && link.host !== location.host) {
500
673
  this.track("outbound_link", { outbound_link: link.href });
674
+ }
501
675
  };
502
- document.addEventListener("click", handler);
503
- document.addEventListener("auxclick", handler);
676
+ this.addDocumentListener("click", handler);
677
+ this.addDocumentListener("auxclick", handler);
504
678
  }
505
679
  }