@faststats/web 0.2.7 → 0.2.8

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 (44) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/chunks/analytics-Cmawx5f9.js +1 -0
  3. package/dist/chunks/api-urls-BrkcoElX.js +1 -0
  4. package/dist/{error-asweBGYd.js → chunks/error-1K0dai1m.js} +2 -2
  5. package/dist/chunks/feature-flags-6rZlmhfu.d.ts +18 -0
  6. package/dist/chunks/feature-flags-BPRTgvIh.js +1 -0
  7. package/dist/chunks/replay-CtMtn0C-.js +1 -0
  8. package/dist/chunks/replay-IhsP2Ab4.d.ts +77 -0
  9. package/dist/chunks/rolldown-runtime-MP-BAFHD.js +1 -0
  10. package/dist/chunks/types-C3vW7XGe.js +1 -0
  11. package/dist/chunks/web-vitals-BooBPWJC.js +1 -0
  12. package/dist/error.d.ts +44 -0
  13. package/dist/error.js +1 -0
  14. package/dist/feature-flags.d.ts +2 -0
  15. package/dist/feature-flags.js +1 -0
  16. package/dist/index.d.ts +140 -0
  17. package/dist/index.js +1 -0
  18. package/dist/replay.d.ts +2 -0
  19. package/dist/replay.js +1 -0
  20. package/dist/web-vitals.d.ts +27 -0
  21. package/dist/web-vitals.js +1 -0
  22. package/package.json +32 -11
  23. package/scripts/check-bundle-size.mjs +90 -9
  24. package/src/analytics.ts +47 -41
  25. package/src/entries/error.ts +6 -0
  26. package/src/entries/feature-flags.ts +5 -0
  27. package/src/entries/main.ts +21 -0
  28. package/src/entries/replay.ts +1 -0
  29. package/src/entries/web-vitals.ts +1 -0
  30. package/src/env.d.ts +2 -0
  31. package/src/error.ts +5 -0
  32. package/src/replay.ts +104 -59
  33. package/src/sdk.ts +8 -0
  34. package/src/utils/types.ts +1 -1
  35. package/src/web-vitals.ts +32 -14
  36. package/tests/replay.test.ts +150 -21
  37. package/tsdown.config.ts +16 -1
  38. package/dist/analytics-Ct-lghlf.js +0 -1
  39. package/dist/module.d.ts +0 -531
  40. package/dist/module.js +0 -1
  41. package/dist/replay-DUA5c3Jf.js +0 -1
  42. package/dist/types-Df70G0eM.js +0 -1
  43. package/dist/web-vitals-gcUR-TX_.js +0 -1
  44. package/src/module.ts +0 -25
package/src/analytics.ts CHANGED
@@ -1,8 +1,5 @@
1
1
  import type ErrorTracker from "./error";
2
- import {
3
- type FeatureFlagEvaluation,
4
- fetchFeatureFlagEvaluation,
5
- } from "./feature-flags";
2
+ import type { FeatureFlagEvaluation } from "./feature-flags";
6
3
  import type ReplayTracker from "./replay";
7
4
  import type { ReplayTrackerOptions } from "./replay";
8
5
  import {
@@ -41,6 +38,7 @@ interface SamplingOptions {
41
38
 
42
39
  interface WebVitalsConfig {
43
40
  sampling?: SamplingOptions;
41
+ attribution?: boolean;
44
42
  }
45
43
 
46
44
  interface SessionReplayConfig {
@@ -77,6 +75,10 @@ export interface WebAnalyticsOptions {
77
75
  webVitals?: WebVitalsConfig;
78
76
  sessionReplays?: SessionReplayConfig;
79
77
  replayOptions?: Partial<ReplayTrackerOptions>;
78
+ /** @internal */
79
+ sdkName?: string;
80
+ /** @internal */
81
+ sdkVersion?: string;
80
82
  }
81
83
 
82
84
  function hasReplaySamplingConfig(options: WebAnalyticsOptions): boolean {
@@ -112,11 +114,16 @@ export function resolveReplayTrackerOptions(
112
114
  };
113
115
  }
114
116
 
115
- let _instance: WebAnalytics | null = null;
116
- let pendingConsentMode: ConsentMode | undefined;
117
+ const moduleState: {
118
+ instance: WebAnalytics | null;
119
+ pendingConsentMode: ConsentMode | undefined;
120
+ } = {
121
+ instance: null,
122
+ pendingConsentMode: undefined,
123
+ };
117
124
 
118
125
  export function getInstance(): WebAnalytics | null {
119
- return _instance;
126
+ return moduleState.instance;
120
127
  }
121
128
 
122
129
  export function trackEvent(
@@ -124,7 +131,7 @@ export function trackEvent(
124
131
  properties?: Record<string, unknown>,
125
132
  ): void {
126
133
  if (typeof window === "undefined" || isTrackingDisabled()) return;
127
- _instance?.track(eventName, properties ?? {});
134
+ moduleState.instance?.track(eventName, properties ?? {});
128
135
  }
129
136
 
130
137
  export function identify(
@@ -133,20 +140,20 @@ export function identify(
133
140
  options?: IdentifyOptions,
134
141
  ): void {
135
142
  if (typeof window === "undefined" || isTrackingDisabled()) return;
136
- _instance?.identify(externalId, email, options ?? {});
143
+ moduleState.instance?.identify(externalId, email, options ?? {});
137
144
  }
138
145
 
139
146
  export function logout(resetAnonymousIdentity = true): void {
140
147
  if (typeof window === "undefined" || isTrackingDisabled()) return;
141
- _instance?.logout(resetAnonymousIdentity);
148
+ moduleState.instance?.logout(resetAnonymousIdentity);
142
149
  }
143
150
 
144
151
  export function setConsentMode(mode: ConsentMode): void {
145
- if (_instance) {
146
- _instance.setConsentMode(mode);
152
+ if (moduleState.instance) {
153
+ moduleState.instance.setConsentMode(mode);
147
154
  return;
148
155
  }
149
- pendingConsentMode = mode;
156
+ moduleState.pendingConsentMode = mode;
150
157
  }
151
158
 
152
159
  export function optIn(): void {
@@ -159,7 +166,7 @@ export function optOut(): void {
159
166
 
160
167
  export function reportError(error: Error): void {
161
168
  if (typeof window === "undefined" || isTrackingDisabled()) return;
162
- _instance?.reportError(error);
169
+ moduleState.instance?.reportError(error);
163
170
  }
164
171
 
165
172
  export function isTrackingDisabled(): boolean {
@@ -190,9 +197,9 @@ export async function sendData(options: SendDataOptions): Promise<boolean> {
190
197
  data instanceof Blob
191
198
  ? data
192
199
  : typeof Blob !== "undefined"
193
- ? new Blob([data], { type: contentType })
200
+ ? new Blob([data as BlobPart], { type: contentType })
194
201
  : data;
195
- if (navigator.sendBeacon(url, beaconBody)) {
202
+ if (navigator.sendBeacon(url, beaconBody as BodyInit)) {
196
203
  if (debug) {
197
204
  console.log(`${debugPrefix} Sent via beacon`);
198
205
  }
@@ -211,7 +218,7 @@ export async function sendData(options: SendDataOptions): Promise<boolean> {
211
218
  try {
212
219
  const response = await fetch(url, {
213
220
  method: "POST",
214
- body: data,
221
+ body: data as BodyInit,
215
222
  headers: {
216
223
  "Content-Type": contentType,
217
224
  ...headers,
@@ -311,9 +318,9 @@ export class WebAnalytics {
311
318
  this.consentMode = options.consent?.mode ?? "granted";
312
319
  this.cookielessWhilePending =
313
320
  options.consent?.cookielessWhilePending ?? true;
314
- if (pendingConsentMode !== undefined) {
315
- this.consentMode = pendingConsentMode;
316
- pendingConsentMode = undefined;
321
+ if (moduleState.pendingConsentMode !== undefined) {
322
+ this.consentMode = moduleState.pendingConsentMode;
323
+ moduleState.pendingConsentMode = undefined;
317
324
  }
318
325
  if (options.autoTrack ?? true) this.init();
319
326
  }
@@ -328,7 +335,7 @@ export class WebAnalytics {
328
335
  this.log("disabled");
329
336
  return;
330
337
  }
331
- _instance = this;
338
+ moduleState.instance = this;
332
339
  setCookielessMode(this.isCookielessMode());
333
340
  setTimeout(() => void this.start(), 0);
334
341
  }
@@ -408,7 +415,7 @@ export class WebAnalytics {
408
415
  }
409
416
 
410
417
  private registerChildTracker(tracker: ChildTracker): boolean {
411
- if (!this.started || this.destroyed || _instance !== this) {
418
+ if (!this.started || this.destroyed || moduleState.instance !== this) {
412
419
  tracker.stop?.();
413
420
  return false;
414
421
  }
@@ -420,12 +427,15 @@ export class WebAnalytics {
420
427
  private async startErrorTracker(): Promise<void> {
421
428
  try {
422
429
  const { default: ErrorTrackerClass } = await import("./error");
423
- if (!this.started || this.destroyed || _instance !== this) return;
430
+ if (!this.started || this.destroyed || moduleState.instance !== this)
431
+ return;
424
432
 
425
433
  const errorTracker: ErrorTracker = new ErrorTrackerClass({
426
434
  siteKey: this.options.siteKey,
427
435
  baseUrl: this.baseUrl,
428
436
  debug: this.debug,
437
+ sdkName: this.options.sdkName,
438
+ sdkVersion: this.options.sdkVersion,
429
439
  });
430
440
  errorTracker.start();
431
441
  if (!this.registerChildTracker(errorTracker)) return;
@@ -444,7 +454,8 @@ export class WebAnalytics {
444
454
  private async startWebVitalsTracker(): Promise<void> {
445
455
  try {
446
456
  const { default: WebVitalsTrackerClass } = await import("./web-vitals");
447
- if (!this.started || this.destroyed || _instance !== this) return;
457
+ if (!this.started || this.destroyed || moduleState.instance !== this)
458
+ return;
448
459
 
449
460
  const webVitalsTracker: WebVitalsTracker = new WebVitalsTrackerClass({
450
461
  siteKey: this.options.siteKey,
@@ -453,6 +464,7 @@ export class WebAnalytics {
453
464
  samplingPercentage: normalizeSamplingPercentage(
454
465
  this.options.webVitals?.sampling?.percentage,
455
466
  ),
467
+ attribution: this.options.webVitals?.attribution ?? false,
456
468
  });
457
469
  webVitalsTracker.start();
458
470
  if (!this.registerChildTracker(webVitalsTracker)) return;
@@ -468,7 +480,8 @@ export class WebAnalytics {
468
480
  ): Promise<void> {
469
481
  try {
470
482
  const { default: ReplayTrackerClass } = await import("./replay");
471
- if (!this.started || this.destroyed || _instance !== this) return;
483
+ if (!this.started || this.destroyed || moduleState.instance !== this)
484
+ return;
472
485
 
473
486
  const replayTracker: ReplayTracker = new ReplayTrackerClass(
474
487
  replayOptions,
@@ -484,7 +497,7 @@ export class WebAnalytics {
484
497
 
485
498
  async start(): Promise<void> {
486
499
  if (this.started || this.destroyed || typeof window === "undefined") return;
487
- if (_instance && _instance !== this) {
500
+ if (moduleState.instance && moduleState.instance !== this) {
488
501
  this.log("already started by another instance");
489
502
  return;
490
503
  }
@@ -494,7 +507,7 @@ export class WebAnalytics {
494
507
  }
495
508
 
496
509
  this.started = true;
497
- _instance = this;
510
+ moduleState.instance = this;
498
511
  setCookielessMode(this.isCookielessMode());
499
512
  getOrCreateSessionId();
500
513
 
@@ -551,8 +564,8 @@ export class WebAnalytics {
551
564
  }
552
565
 
553
566
  this.stopChildTrackers();
554
- if (_instance === this) {
555
- _instance = null;
567
+ if (moduleState.instance === this) {
568
+ moduleState.instance = null;
556
569
  }
557
570
  this.started = false;
558
571
  this.destroyed = true;
@@ -646,23 +659,16 @@ export class WebAnalytics {
646
659
  return { value: "false" };
647
660
  }
648
661
  const externalId = opts?.externalId?.trim();
649
- if (externalId) {
650
- return fetchFeatureFlagEvaluation(key, {
651
- baseUrl: this.featureFlagsBaseUrl,
652
- projectToken: this.options.siteKey,
653
- externalId,
654
- attributes,
655
- signal: opts?.signal,
656
- });
657
- }
658
- const identifier = this.getAnonymousId();
659
- if (!identifier) {
662
+ const identifier = externalId ? undefined : this.getAnonymousId();
663
+ if (!externalId && !identifier) {
660
664
  return { value: "false" };
661
665
  }
666
+
667
+ const { fetchFeatureFlagEvaluation } = await import("./feature-flags");
662
668
  return fetchFeatureFlagEvaluation(key, {
663
669
  baseUrl: this.featureFlagsBaseUrl,
664
670
  projectToken: this.options.siteKey,
665
- identifier,
671
+ ...(externalId ? { externalId } : { identifier }),
666
672
  attributes,
667
673
  signal: opts?.signal,
668
674
  });
@@ -0,0 +1,6 @@
1
+ export {
2
+ default,
3
+ type ErrorEntry,
4
+ type ErrorTracking,
5
+ type ErrorTrackingOptions,
6
+ } from "../error";
@@ -0,0 +1,5 @@
1
+ export type {
2
+ FeatureFlagCheckContext,
3
+ FeatureFlagEvaluation,
4
+ } from "../feature-flags";
5
+ export { fetchFeatureFlagEvaluation } from "../feature-flags";
@@ -0,0 +1,21 @@
1
+ export {
2
+ type ConsentMode,
3
+ getInstance,
4
+ type IdentifyOptions,
5
+ identify,
6
+ isTrackingDisabled,
7
+ logout,
8
+ optIn,
9
+ optOut,
10
+ reportError,
11
+ sendData,
12
+ setConsentMode,
13
+ trackEvent,
14
+ WebAnalytics,
15
+ type WebAnalyticsOptions,
16
+ } from "../analytics";
17
+ export type {
18
+ FeatureFlagCheckContext,
19
+ FeatureFlagEvaluation,
20
+ } from "../feature-flags";
21
+ export { fetchFeatureFlagEvaluation } from "../feature-flags";
@@ -0,0 +1 @@
1
+ export { default, type ReplayTrackerOptions } from "../replay";
@@ -0,0 +1 @@
1
+ export { default, type WebVitalsOptions } from "../web-vitals";
package/src/env.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ declare const __FASTSTATS_SDK_NAME__: string;
2
+ declare const __FASTSTATS_SDK_VERSION__: string;
package/src/error.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { sendData } from "./analytics";
2
+ import { SDK_NAME, SDK_VERSION } from "./sdk";
2
3
  import { webEventsUrl } from "./utils/api-urls";
3
4
  import { getAnonymousId, getOrCreateSessionId } from "./utils/identifiers";
4
5
 
@@ -33,6 +34,8 @@ export interface ErrorTrackingOptions {
33
34
  debug?: boolean;
34
35
  flushInterval?: number;
35
36
  maxQueueSize?: number;
37
+ sdkName?: string;
38
+ sdkVersion?: string;
36
39
  }
37
40
 
38
41
  const EXTENSION_URL =
@@ -256,6 +259,8 @@ export default class ErrorTracker {
256
259
  ...(identifier ? { userId: identifier } : {}),
257
260
  sessionId: getOrCreateSessionId(),
258
261
  ...(buildId ? { buildId } : {}),
262
+ sdkName: this.options.sdkName ?? SDK_NAME,
263
+ sdkVersion: this.options.sdkVersion ?? SDK_VERSION,
259
264
  data: {
260
265
  url: location.href,
261
266
  page: location.pathname,
package/src/replay.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { getRecordConsolePlugin } from "@rrweb/rrweb-plugin-console-record";
2
- import { getRecordSequentialIdPlugin } from "@rrweb/rrweb-plugin-sequential-id-record";
3
- import type { eventWithTime, listenerHandler } from "@rrweb/types";
4
- import { EventType, record } from "rrweb";
1
+ import {
2
+ EventType,
3
+ type eventWithTime,
4
+ type listenerHandler,
5
+ } from "@rrweb/types";
6
+ import { record } from "rrweb";
5
7
  import type { recordOptions } from "rrweb/typings/types";
6
8
  import type { SlimDOMOptions } from "rrweb-snapshot";
7
9
  import { sendData } from "./analytics";
@@ -15,6 +17,7 @@ import { normalizeSamplingPercentage } from "./utils/types";
15
17
 
16
18
  const RRWEB_SEQUENTIAL_ID_KEY = "_faststatsSeqId";
17
19
  const MAX_TIMEOUT_MS = 2_147_483_647;
20
+ const DEFAULT_MAX_QUEUE_SIZE_BYTES = 2 * 1024 * 1024;
18
21
 
19
22
  export interface ReplayTrackerOptions {
20
23
  siteKey: string;
@@ -25,6 +28,7 @@ export interface ReplayTrackerOptions {
25
28
  flushInterval?: number;
26
29
  maxEvents?: number;
27
30
  maxPendingBatches?: number;
31
+ maxQueueSizeBytes?: number;
28
32
  minReplayLengthMs?: number;
29
33
  sampling?: recordOptions<eventWithTime>["sampling"];
30
34
  slimDOMOptions?: SlimDOMOptions;
@@ -77,6 +81,7 @@ export default class ReplayTracker {
77
81
  private readonly sampled: boolean;
78
82
  private readonly events: eventWithTime[] = [];
79
83
  private readonly pending: ReplayBatch[] = [];
84
+ private pendingSizeBytes = 0;
80
85
 
81
86
  private sessionId: string | undefined;
82
87
  private started = false;
@@ -112,12 +117,16 @@ export default class ReplayTracker {
112
117
  return this.options.maxPendingBatches ?? 30;
113
118
  }
114
119
 
120
+ private get maxQueueSizeBytes(): number {
121
+ return this.options.maxQueueSizeBytes ?? DEFAULT_MAX_QUEUE_SIZE_BYTES;
122
+ }
123
+
115
124
  private get minReplayLengthMs(): number {
116
125
  return this.options.minReplayLengthMs ?? 3000;
117
126
  }
118
127
 
119
128
  private get shouldCompress(): boolean {
120
- return this.options.compress ?? false;
129
+ return this.options.compress ?? true;
121
130
  }
122
131
 
123
132
  private log(...args: unknown[]): void {
@@ -131,6 +140,37 @@ export default class ReplayTracker {
131
140
  this.sessionId = getOrCreateSessionId();
132
141
  this.startTime = getSessionStart();
133
142
 
143
+ void this.beginRecording();
144
+
145
+ this.intervalId = setInterval(this.requestFlush, this.flushInterval);
146
+
147
+ window.addEventListener("beforeunload", this.onUnload);
148
+ window.addEventListener("pagehide", this.onUnload);
149
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
150
+
151
+ this.log("Recording started");
152
+ }
153
+
154
+ private async beginRecording(): Promise<void> {
155
+ const wantsConsole = this.options.recordConsole ?? true;
156
+
157
+ const [{ getRecordSequentialIdPlugin }, consolePluginModule] =
158
+ await Promise.all([
159
+ import("@rrweb/rrweb-plugin-sequential-id-record"),
160
+ wantsConsole
161
+ ? import("@rrweb/rrweb-plugin-console-record")
162
+ : Promise.resolve(null),
163
+ ]);
164
+
165
+ if (!this.started) return;
166
+
167
+ const plugins = [
168
+ getRecordSequentialIdPlugin({ key: RRWEB_SEQUENTIAL_ID_KEY }),
169
+ ];
170
+ if (consolePluginModule) {
171
+ plugins.push(consolePluginModule.getRecordConsolePlugin());
172
+ }
173
+
134
174
  this.stopRecording = record({
135
175
  emit: this.onEvent,
136
176
  sampling: this.options.sampling ?? defaultSampling,
@@ -147,21 +187,8 @@ export default class ReplayTracker {
147
187
  maskTextSelector: this.options.maskTextSelector,
148
188
  checkoutEveryNms: this.options.checkoutEveryNms ?? 60_000,
149
189
  checkoutEveryNth: this.options.checkoutEveryNth,
150
- plugins: [
151
- getRecordSequentialIdPlugin({ key: RRWEB_SEQUENTIAL_ID_KEY }),
152
- ...((this.options.recordConsole ?? true)
153
- ? [getRecordConsolePlugin()]
154
- : []),
155
- ],
190
+ plugins,
156
191
  });
157
-
158
- this.intervalId = setInterval(this.requestFlush, this.flushInterval);
159
-
160
- window.addEventListener("beforeunload", this.onUnload);
161
- window.addEventListener("pagehide", this.onUnload);
162
- document.addEventListener("visibilitychange", this.onVisibilityChange);
163
-
164
- this.log("Recording started");
165
192
  }
166
193
 
167
194
  stop(): void {
@@ -284,13 +311,52 @@ export default class ReplayTracker {
284
311
  };
285
312
  }
286
313
 
314
+ private getBatchSizeBytes(batch: ReplayBatch): number {
315
+ return new TextEncoder().encode(JSON.stringify(batch)).byteLength;
316
+ }
317
+
318
+ private dropOldestPendingBatch(reason: string): void {
319
+ const dropped = this.pending.shift();
320
+ if (!dropped) return;
321
+
322
+ this.pendingSizeBytes = Math.max(
323
+ 0,
324
+ this.pendingSizeBytes - this.getBatchSizeBytes(dropped),
325
+ );
326
+ this.log(`${reason}, dropping batch ${dropped.sequence}`);
327
+ }
328
+
329
+ private enqueueBatch(batch: ReplayBatch): void {
330
+ const batchSizeBytes = this.getBatchSizeBytes(batch);
331
+
332
+ if (batchSizeBytes > this.maxQueueSizeBytes) {
333
+ this.log(
334
+ `Replay batch ${batch.sequence} is ${batchSizeBytes}B, exceeding ${this.maxQueueSizeBytes}B queue limit; dropping`,
335
+ );
336
+ return;
337
+ }
338
+
339
+ while (
340
+ this.pending.length > 0 &&
341
+ this.pendingSizeBytes + batchSizeBytes > this.maxQueueSizeBytes
342
+ ) {
343
+ this.dropOldestPendingBatch("Pending queue size limit reached");
344
+ }
345
+
346
+ while (this.pending.length >= this.maxPendingBatches) {
347
+ this.dropOldestPendingBatch("Pending batch limit reached");
348
+ }
349
+
350
+ this.pending.push(batch);
351
+ this.pendingSizeBytes += batchSizeBytes;
352
+ }
353
+
287
354
  private async encodeBatch(
288
355
  batch: ReplayBatch,
289
- lowLatency: boolean,
290
- ): Promise<{ data: string | Blob; isCompressed: boolean }> {
356
+ ): Promise<{ data: string | Uint8Array; isCompressed: boolean }> {
291
357
  const json = JSON.stringify(batch);
292
358
 
293
- if (!this.shouldCompress || !this.compressionSupported || lowLatency) {
359
+ if (!this.shouldCompress || !this.compressionSupported) {
294
360
  return {
295
361
  data: json,
296
362
  isCompressed: false,
@@ -298,14 +364,18 @@ export default class ReplayTracker {
298
364
  }
299
365
 
300
366
  try {
367
+ const compressed = await this.compress(json);
368
+ this.log(
369
+ `Compressed ${json.length}B -> ${compressed.byteLength}B (${Math.round((compressed.byteLength / json.length) * 100)}%)`,
370
+ );
301
371
  return {
302
- data: await this.compress(json),
372
+ data: compressed,
303
373
  isCompressed: true,
304
374
  };
305
375
  } catch {
306
376
  this.log("Compression failed, using uncompressed");
307
377
  return {
308
- data: new Blob([json], { type: "application/json" }),
378
+ data: json,
309
379
  isCompressed: false,
310
380
  };
311
381
  }
@@ -319,7 +389,7 @@ export default class ReplayTracker {
319
389
  this.scheduleMinLengthFlush();
320
390
  } else {
321
391
  const batch = this.createBatch(this.events.splice(0));
322
- this.pending.push(batch);
392
+ this.enqueueBatch(batch);
323
393
  }
324
394
  }
325
395
 
@@ -331,7 +401,7 @@ export default class ReplayTracker {
331
401
  const batch = this.pending[0];
332
402
  if (!batch) break;
333
403
 
334
- const encoded = await this.encodeBatch(batch, lowLatency);
404
+ const encoded = await this.encodeBatch(batch);
335
405
  const ok = await this.send(
336
406
  encoded.data,
337
407
  encoded.isCompressed,
@@ -339,15 +409,11 @@ export default class ReplayTracker {
339
409
  );
340
410
 
341
411
  if (!ok) {
342
- if (this.pending.length >= this.maxPendingBatches) {
343
- this.log(`Pending buffer full, dropping batch ${batch.sequence}`);
344
- this.pending.shift();
345
- }
346
412
  this.log(`Failed to send replay batch ${batch.sequence}, retrying`);
347
413
  this.scheduleRetry();
348
414
  break;
349
415
  }
350
- this.pending.shift();
416
+ this.dropOldestPendingBatch("Sent replay batch");
351
417
  lowLatency = false;
352
418
  }
353
419
  } finally {
@@ -363,37 +429,16 @@ export default class ReplayTracker {
363
429
  }, 1000);
364
430
  }
365
431
 
366
- private async compress(data: string): Promise<Blob> {
367
- const input = new TextEncoder().encode(data);
368
- const cs = new CompressionStream("gzip");
369
- const writer = cs.writable.getWriter();
370
-
371
- await writer.write(input);
372
- await writer.close();
373
-
374
- const chunks: Uint8Array[] = [];
375
- const reader = cs.readable.getReader();
376
-
377
- while (true) {
378
- const { done, value } = await reader.read();
379
- if (done) break;
380
- if (value) chunks.push(value);
381
- }
382
-
383
- const size = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
384
- const output = new Uint8Array(size);
385
-
386
- let offset = 0;
387
- for (const chunk of chunks) {
388
- output.set(chunk, offset);
389
- offset += chunk.length;
390
- }
391
-
392
- return new Blob([output], { type: "application/octet-stream" });
432
+ private async compress(data: string): Promise<Uint8Array> {
433
+ const stream = new Blob([data])
434
+ .stream()
435
+ .pipeThrough(new CompressionStream("gzip"));
436
+ const buffer = await new Response(stream).arrayBuffer();
437
+ return new Uint8Array(buffer);
393
438
  }
394
439
 
395
440
  private send(
396
- data: string | Blob,
441
+ data: string | Uint8Array,
397
442
  isCompressed: boolean,
398
443
  lowLatency: boolean,
399
444
  ): Promise<boolean> {
package/src/sdk.ts ADDED
@@ -0,0 +1,8 @@
1
+ export const SDK_NAME =
2
+ typeof __FASTSTATS_SDK_NAME__ === "string"
3
+ ? __FASTSTATS_SDK_NAME__
4
+ : "@faststats/web";
5
+ export const SDK_VERSION =
6
+ typeof __FASTSTATS_SDK_VERSION__ === "string"
7
+ ? __FASTSTATS_SDK_VERSION__
8
+ : "0.2.7";
@@ -1,6 +1,6 @@
1
1
  export interface SendDataOptions {
2
2
  url: string;
3
- data: string | Blob;
3
+ data: string | Blob | Uint8Array;
4
4
  contentType?: string;
5
5
  headers?: Record<string, string>;
6
6
  debug?: boolean;
package/src/web-vitals.ts CHANGED
@@ -1,11 +1,4 @@
1
- import {
2
- type MetricWithAttribution,
3
- onCLS,
4
- onFCP,
5
- onINP,
6
- onLCP,
7
- onTTFB,
8
- } from "web-vitals/attribution";
1
+ import type { Metric, MetricWithAttribution } from "web-vitals";
9
2
  import { webVitalsEventsUrl } from "./utils/api-urls";
10
3
  import { getOrCreateSessionId } from "./utils/identifiers";
11
4
  import { normalizeSamplingPercentage } from "./utils/types";
@@ -15,6 +8,7 @@ export interface WebVitalsOptions {
15
8
  baseUrl?: string;
16
9
  debug?: boolean;
17
10
  samplingPercentage?: number;
11
+ attribution?: boolean;
18
12
  }
19
13
 
20
14
  type MetricName = "CLS" | "INP" | "LCP" | "FCP" | "TTFB";
@@ -24,7 +18,19 @@ type MetricState = {
24
18
  attributes: Record<string, unknown>;
25
19
  };
26
20
 
27
- const observeVitals = [onCLS, onINP, onLCP, onFCP, onTTFB];
21
+ type AnyMetric = Metric | MetricWithAttribution;
22
+ type Observer = (cb: (metric: AnyMetric) => void) => void;
23
+
24
+ async function loadObservers(attribution: boolean): Promise<Observer[]> {
25
+ if (attribution) {
26
+ const { onCLS, onFCP, onINP, onLCP, onTTFB } = await import(
27
+ "web-vitals/attribution"
28
+ );
29
+ return [onCLS, onFCP, onINP, onLCP, onTTFB] as Observer[];
30
+ }
31
+ const { onCLS, onFCP, onINP, onLCP, onTTFB } = await import("web-vitals");
32
+ return [onCLS, onFCP, onINP, onLCP, onTTFB] as Observer[];
33
+ }
28
34
 
29
35
  export default class WebVitalsTracker {
30
36
  private readonly endpoint: string;
@@ -58,9 +64,7 @@ export default class WebVitalsTracker {
58
64
 
59
65
  this.log("Tracking started");
60
66
 
61
- for (const observe of observeVitals) {
62
- observe(this.captureMetric);
63
- }
67
+ void this.observe();
64
68
  }
65
69
 
66
70
  stop(): void {
@@ -73,14 +77,28 @@ export default class WebVitalsTracker {
73
77
  this.flush();
74
78
  }
75
79
 
80
+ private async observe(): Promise<void> {
81
+ try {
82
+ const observers = await loadObservers(this.options.attribution ?? false);
83
+ if (!this.started) return;
84
+ for (const observe of observers) {
85
+ observe(this.captureMetric);
86
+ }
87
+ } catch (error) {
88
+ this.log("Failed to load web-vitals", error);
89
+ }
90
+ }
91
+
76
92
  private onVisibilityChange = (): void => {
77
93
  if (document.visibilityState === "hidden") this.flush();
78
94
  };
79
95
 
80
- private captureMetric = (metric: MetricWithAttribution): void => {
96
+ private captureMetric = (metric: AnyMetric): void => {
81
97
  if (this.flushed || !this.sampled) return;
82
98
 
83
99
  const name = metric.name as MetricName;
100
+ const attribution =
101
+ (metric as MetricWithAttribution).attribution ?? undefined;
84
102
 
85
103
  this.metrics.set(name, {
86
104
  value: metric.value,
@@ -89,7 +107,7 @@ export default class WebVitalsTracker {
89
107
  rating: metric.rating,
90
108
  delta: metric.delta,
91
109
  navigationType: metric.navigationType,
92
- ...(metric.attribution ?? {}),
110
+ ...(attribution ?? {}),
93
111
  },
94
112
  });
95
113