@faststats/web 0.2.7 → 0.2.9

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 (46) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/chunks/analytics-BlPwufvF.js +1 -0
  3. package/dist/chunks/api-urls-BrkcoElX.js +1 -0
  4. package/dist/{error-asweBGYd.js → chunks/error-C-fHJ_1D.js} +2 -2
  5. package/dist/chunks/feature-flags-6rZlmhfu.d.ts +18 -0
  6. package/dist/chunks/feature-flags-CqVtrpX2.js +1 -0
  7. package/dist/chunks/replay-CKrEvguu.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 +60 -47
  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/feature-flags.ts +2 -4
  33. package/src/replay.ts +104 -59
  34. package/src/sdk.ts +8 -0
  35. package/src/utils/types.ts +1 -1
  36. package/src/web-vitals.ts +32 -14
  37. package/tests/analytics.test.ts +33 -2
  38. package/tests/replay.test.ts +150 -21
  39. package/tsdown.config.ts +16 -1
  40. package/dist/analytics-Ct-lghlf.js +0 -1
  41. package/dist/module.d.ts +0 -531
  42. package/dist/module.js +0 -1
  43. package/dist/replay-DUA5c3Jf.js +0 -1
  44. package/dist/types-Df70G0eM.js +0 -1
  45. package/dist/web-vitals-gcUR-TX_.js +0 -1
  46. 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,29 +131,34 @@ 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(
131
138
  externalId: string,
132
139
  email: string,
133
140
  options?: IdentifyOptions,
134
- ): void {
135
- if (typeof window === "undefined" || isTrackingDisabled()) return;
136
- _instance?.identify(externalId, email, options ?? {});
141
+ ): Promise<boolean> {
142
+ if (typeof window === "undefined" || isTrackingDisabled()) {
143
+ return Promise.resolve(false);
144
+ }
145
+ return (
146
+ moduleState.instance?.identify(externalId, email, options ?? {}) ??
147
+ Promise.resolve(false)
148
+ );
137
149
  }
138
150
 
139
151
  export function logout(resetAnonymousIdentity = true): void {
140
152
  if (typeof window === "undefined" || isTrackingDisabled()) return;
141
- _instance?.logout(resetAnonymousIdentity);
153
+ moduleState.instance?.logout(resetAnonymousIdentity);
142
154
  }
143
155
 
144
156
  export function setConsentMode(mode: ConsentMode): void {
145
- if (_instance) {
146
- _instance.setConsentMode(mode);
157
+ if (moduleState.instance) {
158
+ moduleState.instance.setConsentMode(mode);
147
159
  return;
148
160
  }
149
- pendingConsentMode = mode;
161
+ moduleState.pendingConsentMode = mode;
150
162
  }
151
163
 
152
164
  export function optIn(): void {
@@ -159,7 +171,7 @@ export function optOut(): void {
159
171
 
160
172
  export function reportError(error: Error): void {
161
173
  if (typeof window === "undefined" || isTrackingDisabled()) return;
162
- _instance?.reportError(error);
174
+ moduleState.instance?.reportError(error);
163
175
  }
164
176
 
165
177
  export function isTrackingDisabled(): boolean {
@@ -190,9 +202,9 @@ export async function sendData(options: SendDataOptions): Promise<boolean> {
190
202
  data instanceof Blob
191
203
  ? data
192
204
  : typeof Blob !== "undefined"
193
- ? new Blob([data], { type: contentType })
205
+ ? new Blob([data as BlobPart], { type: contentType })
194
206
  : data;
195
- if (navigator.sendBeacon(url, beaconBody)) {
207
+ if (navigator.sendBeacon(url, beaconBody as BodyInit)) {
196
208
  if (debug) {
197
209
  console.log(`${debugPrefix} Sent via beacon`);
198
210
  }
@@ -211,7 +223,7 @@ export async function sendData(options: SendDataOptions): Promise<boolean> {
211
223
  try {
212
224
  const response = await fetch(url, {
213
225
  method: "POST",
214
- body: data,
226
+ body: data as BodyInit,
215
227
  headers: {
216
228
  "Content-Type": contentType,
217
229
  ...headers,
@@ -311,9 +323,9 @@ export class WebAnalytics {
311
323
  this.consentMode = options.consent?.mode ?? "granted";
312
324
  this.cookielessWhilePending =
313
325
  options.consent?.cookielessWhilePending ?? true;
314
- if (pendingConsentMode !== undefined) {
315
- this.consentMode = pendingConsentMode;
316
- pendingConsentMode = undefined;
326
+ if (moduleState.pendingConsentMode !== undefined) {
327
+ this.consentMode = moduleState.pendingConsentMode;
328
+ moduleState.pendingConsentMode = undefined;
317
329
  }
318
330
  if (options.autoTrack ?? true) this.init();
319
331
  }
@@ -328,7 +340,7 @@ export class WebAnalytics {
328
340
  this.log("disabled");
329
341
  return;
330
342
  }
331
- _instance = this;
343
+ moduleState.instance = this;
332
344
  setCookielessMode(this.isCookielessMode());
333
345
  setTimeout(() => void this.start(), 0);
334
346
  }
@@ -408,7 +420,7 @@ export class WebAnalytics {
408
420
  }
409
421
 
410
422
  private registerChildTracker(tracker: ChildTracker): boolean {
411
- if (!this.started || this.destroyed || _instance !== this) {
423
+ if (!this.started || this.destroyed || moduleState.instance !== this) {
412
424
  tracker.stop?.();
413
425
  return false;
414
426
  }
@@ -420,12 +432,15 @@ export class WebAnalytics {
420
432
  private async startErrorTracker(): Promise<void> {
421
433
  try {
422
434
  const { default: ErrorTrackerClass } = await import("./error");
423
- if (!this.started || this.destroyed || _instance !== this) return;
435
+ if (!this.started || this.destroyed || moduleState.instance !== this)
436
+ return;
424
437
 
425
438
  const errorTracker: ErrorTracker = new ErrorTrackerClass({
426
439
  siteKey: this.options.siteKey,
427
440
  baseUrl: this.baseUrl,
428
441
  debug: this.debug,
442
+ sdkName: this.options.sdkName,
443
+ sdkVersion: this.options.sdkVersion,
429
444
  });
430
445
  errorTracker.start();
431
446
  if (!this.registerChildTracker(errorTracker)) return;
@@ -444,7 +459,8 @@ export class WebAnalytics {
444
459
  private async startWebVitalsTracker(): Promise<void> {
445
460
  try {
446
461
  const { default: WebVitalsTrackerClass } = await import("./web-vitals");
447
- if (!this.started || this.destroyed || _instance !== this) return;
462
+ if (!this.started || this.destroyed || moduleState.instance !== this)
463
+ return;
448
464
 
449
465
  const webVitalsTracker: WebVitalsTracker = new WebVitalsTrackerClass({
450
466
  siteKey: this.options.siteKey,
@@ -453,6 +469,7 @@ export class WebAnalytics {
453
469
  samplingPercentage: normalizeSamplingPercentage(
454
470
  this.options.webVitals?.sampling?.percentage,
455
471
  ),
472
+ attribution: this.options.webVitals?.attribution ?? false,
456
473
  });
457
474
  webVitalsTracker.start();
458
475
  if (!this.registerChildTracker(webVitalsTracker)) return;
@@ -468,7 +485,8 @@ export class WebAnalytics {
468
485
  ): Promise<void> {
469
486
  try {
470
487
  const { default: ReplayTrackerClass } = await import("./replay");
471
- if (!this.started || this.destroyed || _instance !== this) return;
488
+ if (!this.started || this.destroyed || moduleState.instance !== this)
489
+ return;
472
490
 
473
491
  const replayTracker: ReplayTracker = new ReplayTrackerClass(
474
492
  replayOptions,
@@ -484,7 +502,7 @@ export class WebAnalytics {
484
502
 
485
503
  async start(): Promise<void> {
486
504
  if (this.started || this.destroyed || typeof window === "undefined") return;
487
- if (_instance && _instance !== this) {
505
+ if (moduleState.instance && moduleState.instance !== this) {
488
506
  this.log("already started by another instance");
489
507
  return;
490
508
  }
@@ -494,7 +512,7 @@ export class WebAnalytics {
494
512
  }
495
513
 
496
514
  this.started = true;
497
- _instance = this;
515
+ moduleState.instance = this;
498
516
  setCookielessMode(this.isCookielessMode());
499
517
  getOrCreateSessionId();
500
518
 
@@ -551,8 +569,8 @@ export class WebAnalytics {
551
569
  }
552
570
 
553
571
  this.stopChildTrackers();
554
- if (_instance === this) {
555
- _instance = null;
572
+ if (moduleState.instance === this) {
573
+ moduleState.instance = null;
556
574
  }
557
575
  this.started = false;
558
576
  this.destroyed = true;
@@ -575,13 +593,13 @@ export class WebAnalytics {
575
593
  externalId: string,
576
594
  email: string,
577
595
  options: IdentifyOptions = {},
578
- ): void {
579
- if (!this.ensureStarted()) return;
580
- if (this.isCookielessMode()) return;
596
+ ): Promise<boolean> {
597
+ if (!this.ensureStarted()) return Promise.resolve(false);
598
+ if (this.isCookielessMode()) return Promise.resolve(false);
581
599
 
582
600
  const trimmedExternalId = externalId.trim();
583
601
  const trimmedEmail = email.trim();
584
- if (!trimmedExternalId || !trimmedEmail) return;
602
+ if (!trimmedExternalId || !trimmedEmail) return Promise.resolve(false);
585
603
 
586
604
  const identifyEndpoint = identifyEventsUrl(this.baseUrl);
587
605
  const payload = JSON.stringify({
@@ -595,12 +613,13 @@ export class WebAnalytics {
595
613
  traits: options.traits ?? {},
596
614
  });
597
615
 
598
- void sendData({
616
+ return sendData({
599
617
  url: identifyEndpoint,
600
618
  data: payload,
601
619
  contentType: "text/plain",
602
620
  debug: this.debug,
603
621
  debugPrefix: "[Analytics] identify",
622
+ useBeacon: false,
604
623
  });
605
624
  }
606
625
 
@@ -646,23 +665,17 @@ export class WebAnalytics {
646
665
  return { value: "false" };
647
666
  }
648
667
  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
668
  const identifier = this.getAnonymousId();
659
- if (!identifier) {
669
+ if (!externalId && !identifier) {
660
670
  return { value: "false" };
661
671
  }
672
+
673
+ const { fetchFeatureFlagEvaluation } = await import("./feature-flags");
662
674
  return fetchFeatureFlagEvaluation(key, {
663
675
  baseUrl: this.featureFlagsBaseUrl,
664
676
  projectToken: this.options.siteKey,
665
- identifier,
677
+ ...(identifier ? { identifier } : {}),
678
+ ...(externalId ? { externalId } : {}),
666
679
  attributes,
667
680
  signal: opts?.signal,
668
681
  });
@@ -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,
@@ -23,9 +23,6 @@ export async function fetchFeatureFlagEvaluation(
23
23
  if (context.projectToken && context.projectId) {
24
24
  throw new Error("provide either projectToken or projectId, not both");
25
25
  }
26
- if (context.identifier && context.externalId) {
27
- throw new Error("provide either serverId or externalId, not both");
28
- }
29
26
 
30
27
  const projectToken = context.projectToken;
31
28
  const projectId = context.projectId;
@@ -42,7 +39,8 @@ export async function fetchFeatureFlagEvaluation(
42
39
  const body: Record<string, unknown> = {
43
40
  key: flagKey,
44
41
  ...(projectId ? { projectId } : {}),
45
- ...(identifier ? { identifier } : { externalId }),
42
+ ...(identifier ? { identifier } : {}),
43
+ ...(externalId ? { externalId } : {}),
46
44
  };
47
45
 
48
46
  if (context.attributes && Object.keys(context.attributes).length > 0) {
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;