@scoperat/tracker 0.3.2 → 0.4.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/README.md CHANGED
@@ -32,13 +32,14 @@ scoperat.reset();
32
32
 
33
33
  Initialize the tracker. Must be called before any other method.
34
34
 
35
- | Option | Type | Default | Description |
36
- | --------------- | ------------------------- | ------------------------ | ---------------------------- |
37
- | `endpoint` | `string` | `window.location.origin` | API base URL |
38
- | `flushAt` | `number` | `10` | Flush after this many events |
39
- | `flushInterval` | `number` | `3000` | Auto-flush interval in ms |
40
- | `timeout` | `number` | `10000` | Request timeout in ms |
41
- | `onError` | `(error, events) => void` | — | Error callback |
35
+ | Option | Type | Default | Description |
36
+ | --------------- | ------------------------- | ------------------------ | ------------------------------------ |
37
+ | `endpoint` | `string` | `window.location.origin` | API base URL |
38
+ | `flushAt` | `number` | `10` | Flush after this many events |
39
+ | `flushInterval` | `number` | `3000` | Auto-flush interval in ms |
40
+ | `timeout` | `number` | `10000` | Request timeout in ms |
41
+ | `maxQueueSize` | `number` | `1000` | Max buffered events (oldest dropped) |
42
+ | `onError` | `(error, events) => void` | — | Error callback |
42
43
 
43
44
  ### `scoperat.identify(userId, traits?)`
44
45
 
@@ -65,7 +66,7 @@ Flush remaining events and stop the auto-flush timer. Call before app teardown.
65
66
  - **Auto-batching** — events are buffered and sent in batches for efficiency
66
67
  - **Anonymous IDs** — persistent anonymous ID via `localStorage`, auto-generated on first visit
67
68
  - **sendBeacon fallback** — uses `navigator.sendBeacon` on page hide for reliable delivery
68
- - **Retry on failure** — failed batches are re-queued for the next flush
69
+ - **Retry on failure** — transient failures (network errors, 5xx, 408/429) are re-queued and retried with exponential backoff; non-retryable 4xx batches are dropped
69
70
  - **Zero dependencies** — no runtime dependencies
70
71
  - **Tree-shakeable** — marked as side-effect-free for optimal bundling
71
72
  - **ESM-only** — ships as pure ES modules
package/dist/index.d.ts CHANGED
@@ -37,6 +37,12 @@ interface InitOptions {
37
37
  flushInterval?: number;
38
38
  timeout?: number;
39
39
  onError?: (error: Error, events: TrackEvent[]) => void;
40
+ /**
41
+ * Maximum number of events buffered while the ingest API is
42
+ * unreachable. When full, the oldest non-error event is dropped
43
+ * (surfaced through `onError`). Defaults to 1000.
44
+ */
45
+ maxQueueSize?: number;
40
46
  /** Session inactivity timeout in ms. Defaults to 30 minutes. */
41
47
  sessionTimeout?: number;
42
48
  /** Automatically capture uncaught errors and unhandled rejections. Defaults to false. */
@@ -108,8 +114,16 @@ declare class TrackerImpl {
108
114
  private timeout;
109
115
  private onError?;
110
116
  private buffer;
117
+ private maxQueueSize;
111
118
  private timer;
112
119
  private visibilityHandler;
120
+ private pageHideHandler;
121
+ private uninstallErrorHandlers;
122
+ /** Consecutive flush failures, drives exponential retry backoff. */
123
+ private flushFailures;
124
+ /** Timestamp before which automatic flushes are suppressed (backoff). */
125
+ private nextFlushAllowedAt;
126
+ private exceptionFlushScheduled;
113
127
  private userId;
114
128
  private orgId;
115
129
  private traits;
@@ -188,7 +202,22 @@ declare class TrackerImpl {
188
202
  addBreadcrumb(category: string, message: string, data?: Record<string, unknown>): void;
189
203
  track(event: string, properties?: Record<string, unknown>): void;
190
204
  track(event: TrackEvent): void;
205
+ /**
206
+ * Push an event onto the buffer, evicting the oldest event when the
207
+ * queue is full. `$exception` events are preferentially retained.
208
+ */
209
+ private enqueue;
210
+ /** Flush unless we're inside a retry-backoff window. */
211
+ private maybeFlush;
212
+ private scheduleExceptionFlush;
191
213
  flush(): Promise<FlushResult>;
214
+ /**
215
+ * Record a failed flush and push out the next automatic flush with
216
+ * exponential backoff (capped) so a down ingest endpoint isn't
217
+ * hammered on every `flushInterval` tick. Explicit `flush()` calls
218
+ * are unaffected.
219
+ */
220
+ private recordFlushFailure;
192
221
  /**
193
222
  * Fire-and-forget flush that survives page unload.
194
223
  *
@@ -208,6 +237,7 @@ declare class TrackerImpl {
208
237
  refreshFlags(): Promise<void>;
209
238
  shutdown(): Promise<void>;
210
239
  }
240
+
211
241
  declare const scoperat: TrackerImpl;
212
242
 
213
243
  export { type EvaluatedFlag, type EventSource, FlagClient, type FlushResult, type IdentifyOptions, type IdentifyTraits, type InitOptions, type TrackEvent, scoperat as default };
package/dist/index.js CHANGED
@@ -103,54 +103,107 @@ function getBrowserContext() {
103
103
 
104
104
  // src/errors.ts
105
105
  var DEDUP_COOLDOWN_MS = 6e4;
106
+ var MAX_DEDUP_ENTRIES = 200;
107
+ var MAX_MESSAGE_LENGTH = 2048;
108
+ var MAX_STACK_LENGTH = 16384;
109
+ var MAX_REASON_JSON_LENGTH = 1024;
106
110
  var dedupMap = /* @__PURE__ */ new Map();
107
- function simpleFingerprint(type, stack) {
111
+ function truncate2(str, max) {
112
+ return str.length > max ? str.slice(0, max) + "\u2026" : str;
113
+ }
114
+ function fingerprint(type, message, stack) {
108
115
  const firstFrame = stack?.split("\n")[1]?.trim() ?? "";
109
- return `${type}:${firstFrame}`;
116
+ return `${type}:${truncate2(message, 100)}:${firstFrame}`;
110
117
  }
111
- function isDuplicate(fingerprint) {
112
- const lastSent = dedupMap.get(fingerprint);
113
- if (lastSent && Date.now() - lastSent < DEDUP_COOLDOWN_MS) return true;
114
- dedupMap.set(fingerprint, Date.now());
118
+ function isDuplicate(fp) {
119
+ const now = Date.now();
120
+ const lastSent = dedupMap.get(fp);
121
+ if (lastSent && now - lastSent < DEDUP_COOLDOWN_MS) return true;
122
+ dedupMap.set(fp, now);
123
+ pruneDedupMap(now);
115
124
  return false;
116
125
  }
126
+ function pruneDedupMap(now) {
127
+ if (dedupMap.size <= MAX_DEDUP_ENTRIES) return;
128
+ for (const [key, ts] of dedupMap) {
129
+ if (now - ts >= DEDUP_COOLDOWN_MS) dedupMap.delete(key);
130
+ }
131
+ for (const key of dedupMap.keys()) {
132
+ if (dedupMap.size <= MAX_DEDUP_ENTRIES) break;
133
+ dedupMap.delete(key);
134
+ }
135
+ }
117
136
  function sendException(tracker, props, getBreadcrumbs2) {
118
- const fp = simpleFingerprint(props.type, props.stack);
137
+ const fp = fingerprint(props.type, props.message, props.stack);
119
138
  if (isDuplicate(fp)) return;
120
139
  tracker.track("$exception", {
121
140
  ...props,
141
+ message: truncate2(props.message, MAX_MESSAGE_LENGTH),
142
+ stack: props.stack ? truncate2(props.stack, MAX_STACK_LENGTH) : void 0,
122
143
  context: { breadcrumbs: getBreadcrumbs2() }
123
144
  });
124
145
  }
146
+ function describeRejectionReason(reason) {
147
+ if (reason instanceof Error) {
148
+ return {
149
+ type: reason.name || "UnhandledRejection",
150
+ message: reason.message,
151
+ stack: reason.stack
152
+ };
153
+ }
154
+ if (typeof reason === "object" && reason !== null) {
155
+ let json;
156
+ try {
157
+ json = JSON.stringify(reason) ?? Object.prototype.toString.call(reason);
158
+ } catch {
159
+ json = Object.prototype.toString.call(reason);
160
+ }
161
+ return {
162
+ type: "UnhandledRejection",
163
+ message: `Non-Error promise rejection: ${truncate2(json, MAX_REASON_JSON_LENGTH)}`,
164
+ stack: void 0
165
+ };
166
+ }
167
+ return {
168
+ type: "UnhandledRejection",
169
+ message: String(reason),
170
+ stack: void 0
171
+ };
172
+ }
173
+ var uninstallHandlers = null;
125
174
  function installGlobalHandlers(tracker, getBreadcrumbs2) {
126
- if (typeof window === "undefined") return;
127
- window.onerror = (_message, filename, lineno, colno, error) => {
128
- const err = error ?? new Error(String(_message));
175
+ if (typeof window === "undefined") return () => {
176
+ };
177
+ if (uninstallHandlers) return uninstallHandlers;
178
+ const onError = (event) => {
179
+ const error = event.error;
180
+ const message = error?.message || (typeof event.message === "string" && event.message ? event.message : "Unknown error");
181
+ const opaque = !error && /^Script error\.?$/.test(message);
129
182
  sendException(
130
183
  tracker,
131
184
  {
132
- type: err.name || "Error",
133
- message: err.message || String(_message),
134
- stack: err.stack,
185
+ type: error?.name || "Error",
186
+ message,
187
+ stack: error?.stack,
135
188
  handled: false,
136
189
  mechanism: "onerror",
137
190
  source: "browser",
138
- filename,
139
- lineno,
140
- colno
191
+ filename: event.filename || void 0,
192
+ lineno: event.lineno || void 0,
193
+ colno: event.colno || void 0,
194
+ ...opaque ? { opaque: true } : {}
141
195
  },
142
196
  getBreadcrumbs2
143
197
  );
144
198
  };
145
- window.onunhandledrejection = (event) => {
146
- const reason = event.reason;
147
- const err = reason instanceof Error ? reason : new Error(String(reason));
199
+ const onRejection = (event) => {
200
+ const { type, message, stack } = describeRejectionReason(event.reason);
148
201
  sendException(
149
202
  tracker,
150
203
  {
151
- type: err.name || "UnhandledRejection",
152
- message: err.message,
153
- stack: err.stack,
204
+ type,
205
+ message,
206
+ stack,
154
207
  handled: false,
155
208
  mechanism: "onunhandledrejection",
156
209
  source: "browser"
@@ -158,6 +211,14 @@ function installGlobalHandlers(tracker, getBreadcrumbs2) {
158
211
  getBreadcrumbs2
159
212
  );
160
213
  };
214
+ window.addEventListener("error", onError);
215
+ window.addEventListener("unhandledrejection", onRejection);
216
+ uninstallHandlers = () => {
217
+ window.removeEventListener("error", onError);
218
+ window.removeEventListener("unhandledrejection", onRejection);
219
+ uninstallHandlers = null;
220
+ };
221
+ return uninstallHandlers;
161
222
  }
162
223
 
163
224
  // src/flag-client.ts
@@ -262,7 +323,9 @@ var DEFAULT_FLUSH_AT = 10;
262
323
  var DEFAULT_FLUSH_INTERVAL = 3e3;
263
324
  var DEFAULT_TIMEOUT = 1e4;
264
325
  var DEFAULT_SESSION_TIMEOUT = 30 * 60 * 1e3;
326
+ var DEFAULT_MAX_QUEUE_SIZE = 1e3;
265
327
  var MAX_BATCH_SIZE = 100;
328
+ var MAX_RETRY_BACKOFF = 6e4;
266
329
  var ANON_ID_KEY = "__scoperat_anon_id";
267
330
  var SESSION_ID_KEY = "__scoperat_session_id";
268
331
  var LAST_ACTIVITY_KEY = "__scoperat_last_activity";
@@ -303,8 +366,16 @@ var TrackerImpl = class {
303
366
  timeout = DEFAULT_TIMEOUT;
304
367
  onError;
305
368
  buffer = [];
369
+ maxQueueSize = DEFAULT_MAX_QUEUE_SIZE;
306
370
  timer = null;
307
371
  visibilityHandler = null;
372
+ pageHideHandler = null;
373
+ uninstallErrorHandlers = null;
374
+ /** Consecutive flush failures, drives exponential retry backoff. */
375
+ flushFailures = 0;
376
+ /** Timestamp before which automatic flushes are suppressed (backoff). */
377
+ nextFlushAllowedAt = 0;
378
+ exceptionFlushScheduled = false;
308
379
  userId;
309
380
  orgId;
310
381
  traits = {};
@@ -322,12 +393,12 @@ var TrackerImpl = class {
322
393
  this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
323
394
  this.onError = options.onError;
324
395
  this.sessionTimeout = options.sessionTimeout ?? DEFAULT_SESSION_TIMEOUT;
396
+ this.maxQueueSize = options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE;
325
397
  this.anonymousId = getOrCreateAnonId();
326
398
  if (this.timer) clearInterval(this.timer);
327
399
  this.timer = setInterval(() => {
328
400
  if (this.buffer.length > 0) {
329
- this.flush().catch(() => {
330
- });
401
+ this.maybeFlush();
331
402
  }
332
403
  }, this.flushInterval);
333
404
  if (typeof window !== "undefined") {
@@ -340,9 +411,18 @@ var TrackerImpl = class {
340
411
  }
341
412
  };
342
413
  window.addEventListener("visibilitychange", this.visibilityHandler);
414
+ if (this.pageHideHandler) {
415
+ window.removeEventListener("pagehide", this.pageHideHandler);
416
+ }
417
+ this.pageHideHandler = () => {
418
+ if (this.buffer.length > 0) {
419
+ this.flushSync();
420
+ }
421
+ };
422
+ window.addEventListener("pagehide", this.pageHideHandler);
343
423
  }
344
424
  if (options.captureErrors) {
345
- installGlobalHandlers(this, getBreadcrumbs);
425
+ this.uninstallErrorHandlers = installGlobalHandlers(this, getBreadcrumbs);
346
426
  startBreadcrumbCollection();
347
427
  }
348
428
  this._flagClient?.destroy();
@@ -597,7 +677,7 @@ var TrackerImpl = class {
597
677
  emitSessionEvent(event, sessionId, reason, occurredAt) {
598
678
  if (!this.writeKey) return;
599
679
  const traitsContext = Object.keys(this.traits).length > 0 ? { traits: this.traits } : {};
600
- this.buffer.push({
680
+ this.enqueue({
601
681
  event,
602
682
  properties: { reason },
603
683
  occurred_at: occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
@@ -643,17 +723,20 @@ var TrackerImpl = class {
643
723
  }
644
724
  captureException(error, context) {
645
725
  if (!this.writeKey) return;
646
- const breadcrumbs = getBreadcrumbs();
647
- this.track("$exception", {
648
- type: error.name || "Error",
649
- message: error.message,
650
- stack: error.stack,
651
- handled: true,
652
- mechanism: context?.mechanism ?? "manual",
653
- source: "browser",
654
- ...context,
655
- context: { breadcrumbs }
656
- });
726
+ const { mechanism, ...extra } = context ?? {};
727
+ sendException(
728
+ this,
729
+ {
730
+ type: error.name || "Error",
731
+ message: error.message,
732
+ stack: error.stack,
733
+ handled: true,
734
+ mechanism: typeof mechanism === "string" ? mechanism : "manual",
735
+ source: "browser",
736
+ extra: Object.keys(extra).length > 0 ? extra : void 0
737
+ },
738
+ getBreadcrumbs
739
+ );
657
740
  }
658
741
  addBreadcrumb(category, message, data) {
659
742
  addBreadcrumb({
@@ -682,12 +765,44 @@ var TrackerImpl = class {
682
765
  ...evt.context
683
766
  }
684
767
  };
685
- this.buffer.push(enriched);
686
- if (this.buffer.length >= this.flushAt) {
687
- this.flush().catch(() => {
688
- });
768
+ this.enqueue(enriched);
769
+ if (enriched.event === "$exception") {
770
+ this.scheduleExceptionFlush();
771
+ } else if (this.buffer.length >= this.flushAt) {
772
+ this.maybeFlush();
689
773
  }
690
774
  }
775
+ /**
776
+ * Push an event onto the buffer, evicting the oldest event when the
777
+ * queue is full. `$exception` events are preferentially retained.
778
+ */
779
+ enqueue(evt) {
780
+ if (this.buffer.length >= this.maxQueueSize) {
781
+ const idx = this.buffer.findIndex((e) => e.event !== "$exception");
782
+ const dropped = this.buffer.splice(idx === -1 ? 0 : idx, 1);
783
+ if (dropped.length > 0) {
784
+ this.onError?.(
785
+ new Error("Event buffer full \u2014 dropped oldest event"),
786
+ dropped
787
+ );
788
+ }
789
+ }
790
+ this.buffer.push(evt);
791
+ }
792
+ /** Flush unless we're inside a retry-backoff window. */
793
+ maybeFlush() {
794
+ if (Date.now() < this.nextFlushAllowedAt) return;
795
+ this.flush().catch(() => {
796
+ });
797
+ }
798
+ scheduleExceptionFlush() {
799
+ if (this.exceptionFlushScheduled) return;
800
+ this.exceptionFlushScheduled = true;
801
+ setTimeout(() => {
802
+ this.exceptionFlushScheduled = false;
803
+ this.maybeFlush();
804
+ }, 0);
805
+ }
691
806
  async flush() {
692
807
  if (!this.writeKey || this.buffer.length === 0) {
693
808
  return { success: true, ingested: 0 };
@@ -705,17 +820,41 @@ var TrackerImpl = class {
705
820
  });
706
821
  if (!res.ok) {
707
822
  const body = await res.text().catch(() => "");
708
- throw new Error(`API returned ${res.status}: ${body}`);
823
+ const err = new Error(`API returned ${res.status}: ${body}`);
824
+ const retryable = res.status === 408 || res.status === 429 || res.status >= 500;
825
+ if (retryable) {
826
+ this.buffer.unshift(...batch);
827
+ }
828
+ this.recordFlushFailure();
829
+ this.onError?.(err, batch);
830
+ return { success: false, ingested: 0 };
709
831
  }
832
+ this.flushFailures = 0;
833
+ this.nextFlushAllowedAt = 0;
710
834
  const json = await res.json();
711
835
  return { success: true, ingested: json.ingested };
712
836
  } catch (error) {
713
837
  this.buffer.unshift(...batch);
838
+ this.recordFlushFailure();
714
839
  const err = error instanceof Error ? error : new Error(String(error));
715
840
  this.onError?.(err, batch);
716
841
  return { success: false, ingested: 0 };
717
842
  }
718
843
  }
844
+ /**
845
+ * Record a failed flush and push out the next automatic flush with
846
+ * exponential backoff (capped) so a down ingest endpoint isn't
847
+ * hammered on every `flushInterval` tick. Explicit `flush()` calls
848
+ * are unaffected.
849
+ */
850
+ recordFlushFailure() {
851
+ this.flushFailures += 1;
852
+ const delay = Math.min(
853
+ this.flushInterval * 2 ** this.flushFailures,
854
+ MAX_RETRY_BACKOFF
855
+ );
856
+ this.nextFlushAllowedAt = Date.now() + delay;
857
+ }
719
858
  /**
720
859
  * Fire-and-forget flush that survives page unload.
721
860
  *
@@ -767,12 +906,21 @@ var TrackerImpl = class {
767
906
  }
768
907
  this._flagClient?.destroy();
769
908
  this._flagClient = null;
770
- if (typeof window !== "undefined" && this.visibilityHandler) {
771
- window.removeEventListener("visibilitychange", this.visibilityHandler);
772
- this.visibilityHandler = null;
909
+ if (typeof window !== "undefined") {
910
+ if (this.visibilityHandler) {
911
+ window.removeEventListener("visibilitychange", this.visibilityHandler);
912
+ this.visibilityHandler = null;
913
+ }
914
+ if (this.pageHideHandler) {
915
+ window.removeEventListener("pagehide", this.pageHideHandler);
916
+ this.pageHideHandler = null;
917
+ }
773
918
  }
919
+ this.uninstallErrorHandlers?.();
920
+ this.uninstallErrorHandlers = null;
774
921
  while (this.buffer.length > 0) {
775
- await this.flush();
922
+ const result = await this.flush();
923
+ if (!result.success) break;
776
924
  }
777
925
  }
778
926
  };