@emit-vision/sdk-js 0.1.0 → 0.2.0

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/dist/index.d.ts CHANGED
@@ -56,8 +56,14 @@ export type EmitVisionOptions = {
56
56
  fetchImpl?: typeof fetch;
57
57
  /** TTL in milliseconds for in-memory flag evaluation cache. Default: 60 000 (1 minute). */
58
58
  flagEvalTtlMs?: number;
59
+ /** Initial delay (ms) before retrying after a flush failure. Default: 1000. Doubles each consecutive failure up to {@link retryBackoffMaxMs}. Set to 0 to retry immediately on every tick. */
60
+ retryBackoffInitialMs?: number;
61
+ /** Maximum delay (ms) between flush retries. Default: 30000. */
62
+ retryBackoffMaxMs?: number;
63
+ /** Disable the SDK after this many consecutive flush failures: the queue is dropped, future captures become no-ops, and a single warning is logged. Default: 20. Set to 0 to disable the cap and retry forever. */
64
+ maxConsecutiveFlushFailures?: number;
59
65
  };
60
- type RequiredEmitVisionOptions = Required<Pick<EmitVisionOptions, "apiKey" | "endpoint" | "flushIntervalMs" | "batchSize" | "fetchImpl" | "flushOnCapture" | "flagEvalTtlMs">> & Pick<EmitVisionOptions, "environment" | "release" | "sessionId" | "label" | "deployment" | "featureFlags" | "debug"> & {
66
+ type RequiredEmitVisionOptions = Required<Pick<EmitVisionOptions, "apiKey" | "endpoint" | "flushIntervalMs" | "batchSize" | "fetchImpl" | "flushOnCapture" | "flagEvalTtlMs" | "retryBackoffInitialMs" | "retryBackoffMaxMs" | "maxConsecutiveFlushFailures">> & Pick<EmitVisionOptions, "environment" | "release" | "sessionId" | "label" | "deployment" | "featureFlags" | "debug"> & {
61
67
  autoCapture: Required<AutoCaptureOptions> & {
62
68
  flagExposures: boolean;
63
69
  };
@@ -74,6 +80,7 @@ declare class EmitVisionClient {
74
80
  private flushScheduled;
75
81
  private readonly fetchImpl;
76
82
  private readonly flagCache;
83
+ private readonly retry;
77
84
  constructor(options: RequiredEmitVisionOptions);
78
85
  captureEvent(name: string, properties?: Record<string, unknown>, options?: CaptureOptions): void;
79
86
  captureError(error: unknown, options?: CaptureOptions): void;
@@ -102,6 +109,8 @@ declare class EmitVisionClient {
102
109
  private fetchAndCacheFlags;
103
110
  setTags(tags: Record<string, string>): void;
104
111
  flush(): Promise<void>;
112
+ private handleFlushFailure;
113
+ private stopBackgroundWork;
105
114
  close(): void;
106
115
  private enqueue;
107
116
  private scheduleFlush;
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { FlushRetryController } from "./retry.js";
1
2
  const SDK_NAME = "emit-vision-js";
2
3
  const SDK_VERSION = "0.1.0";
3
4
  class EmitVisionClient {
@@ -12,6 +13,7 @@ class EmitVisionClient {
12
13
  flushScheduled = false;
13
14
  fetchImpl;
14
15
  flagCache = new Map();
16
+ retry;
15
17
  constructor(options) {
16
18
  this.options = options;
17
19
  this.fetchImpl = options.fetchImpl;
@@ -19,8 +21,13 @@ class EmitVisionClient {
19
21
  ? { ...options.deployment }
20
22
  : undefined;
21
23
  this.featureFlags = options.featureFlags ? { ...options.featureFlags } : {};
24
+ this.retry = new FlushRetryController({
25
+ initialMs: options.retryBackoffInitialMs,
26
+ maxMs: options.retryBackoffMaxMs,
27
+ failureCap: options.maxConsecutiveFlushFailures,
28
+ });
22
29
  this.timer = setInterval(() => {
23
- if (this.queue.length === 0) {
30
+ if (this.queue.length === 0 || this.retry.isBackingOff()) {
24
31
  return;
25
32
  }
26
33
  this.flush().catch((err) => this.debug("background flush error", { error: err }));
@@ -185,6 +192,9 @@ class EmitVisionClient {
185
192
  this.debug("updated tags", this.tags);
186
193
  }
187
194
  async flush() {
195
+ if (this.retry.isDisabled()) {
196
+ return;
197
+ }
188
198
  const batch = this.queue.splice(0, this.options.batchSize);
189
199
  if (batch.length === 0) {
190
200
  return;
@@ -208,31 +218,69 @@ class EmitVisionClient {
208
218
  });
209
219
  }
210
220
  catch (err) {
211
- this.queue.unshift(...batch);
212
- this.debug("flush network error", {
213
- error: err,
214
- restoredCount: batch.length,
215
- });
221
+ this.handleFlushFailure(batch, { reason: "network", error: err });
216
222
  throw err;
217
223
  }
218
224
  if (!response.ok) {
219
- this.queue.unshift(...batch);
220
- this.debug("flush failed", {
225
+ const error = new Error(`emit-vision flush failed with ${response.status}`);
226
+ this.handleFlushFailure(batch, {
227
+ reason: "status",
221
228
  status: response.status,
222
- restoredCount: batch.length,
223
229
  });
224
- throw new Error(`emit-vision flush failed with ${response.status}`);
230
+ throw error;
225
231
  }
232
+ this.retry.reset();
226
233
  this.debug("flush complete", { count: batch.length });
227
234
  if (this.options.flushOnCapture && this.queue.length > 0) {
228
235
  this.scheduleFlush();
229
236
  }
230
237
  }
231
- close() {
238
+ handleFlushFailure(batch, detail) {
239
+ const result = this.retry.recordFailure();
240
+ if (result.justDisabled) {
241
+ const droppedQueued = this.queue.length;
242
+ const droppedBatch = batch.length;
243
+ this.queue = [];
244
+ this.stopBackgroundWork();
245
+ const message = `[emit-vision${this.options.label ? `:${this.options.label}` : ""}] ` +
246
+ `disabled after ${result.consecutiveFailures} consecutive flush failures; ` +
247
+ `dropping ${droppedQueued + droppedBatch} queued events. ` +
248
+ `Re-init the SDK to resume.`;
249
+ if (typeof console.warn === "function") {
250
+ console.warn(message);
251
+ }
252
+ this.debug("disabled", {
253
+ consecutiveFailures: result.consecutiveFailures,
254
+ dropped: droppedQueued + droppedBatch,
255
+ });
256
+ return;
257
+ }
258
+ this.queue.unshift(...batch);
259
+ if (detail.reason === "network") {
260
+ this.debug("flush network error", {
261
+ error: detail.error,
262
+ restoredCount: batch.length,
263
+ consecutiveFailures: result.consecutiveFailures,
264
+ backoffMs: result.backoffMs,
265
+ });
266
+ }
267
+ else {
268
+ this.debug("flush failed", {
269
+ status: detail.status,
270
+ restoredCount: batch.length,
271
+ consecutiveFailures: result.consecutiveFailures,
272
+ backoffMs: result.backoffMs,
273
+ });
274
+ }
275
+ }
276
+ stopBackgroundWork() {
232
277
  if (this.timer) {
233
278
  clearInterval(this.timer);
234
279
  this.timer = undefined;
235
280
  }
281
+ }
282
+ close() {
283
+ this.stopBackgroundWork();
236
284
  if (typeof window !== "undefined" && this.options.autoCapture.errors) {
237
285
  window.removeEventListener("error", this.handleWindowError);
238
286
  }
@@ -243,6 +291,9 @@ class EmitVisionClient {
243
291
  this.debug("closed client");
244
292
  }
245
293
  enqueue(payload) {
294
+ if (this.retry.isDisabled()) {
295
+ return;
296
+ }
246
297
  this.queue.push(payload);
247
298
  this.debug("queued payload", {
248
299
  type: payload.type,
@@ -255,10 +306,13 @@ class EmitVisionClient {
255
306
  this.debug("capture flush scheduled", {
256
307
  queueSize: this.queue.length,
257
308
  });
258
- this.flush().catch((err) => this.debug("capture flush error", { error: err }));
309
+ if (!this.retry.isBackingOff()) {
310
+ this.flush().catch((err) => this.debug("capture flush error", { error: err }));
311
+ }
259
312
  return;
260
313
  }
261
- if (this.queue.length >= this.options.batchSize) {
314
+ if (this.queue.length >= this.options.batchSize &&
315
+ !this.retry.isBackingOff()) {
262
316
  this.flush().catch((err) => this.debug("batch flush error", { error: err }));
263
317
  }
264
318
  }
@@ -269,7 +323,7 @@ class EmitVisionClient {
269
323
  this.flushScheduled = true;
270
324
  queueMicrotask(() => {
271
325
  this.flushScheduled = false;
272
- if (this.queue.length === 0) {
326
+ if (this.queue.length === 0 || this.retry.isBackingOff()) {
273
327
  return;
274
328
  }
275
329
  this.flush().catch((err) => this.debug("scheduled flush error", { error: err }));
@@ -358,6 +412,9 @@ export function init(options) {
358
412
  fetchImpl: options.fetchImpl ?? globalThis.fetch.bind(globalThis),
359
413
  flushOnCapture: options.flushOnCapture ?? false,
360
414
  flagEvalTtlMs: options.flagEvalTtlMs ?? 60_000,
415
+ retryBackoffInitialMs: options.retryBackoffInitialMs ?? 1_000,
416
+ retryBackoffMaxMs: options.retryBackoffMaxMs ?? 30_000,
417
+ maxConsecutiveFlushFailures: options.maxConsecutiveFlushFailures ?? 20,
361
418
  apiKey: transport.apiKey,
362
419
  endpoint: transport.endpoint ?? options.endpoint ?? "http://localhost:4301",
363
420
  autoCapture,
@@ -0,0 +1,25 @@
1
+ export type FlushRetryOptions = {
2
+ /** Initial backoff in ms after the first failure. Doubles each consecutive failure. Set to 0 to disable backoff. */
3
+ initialMs: number;
4
+ /** Maximum backoff in ms between retries. */
5
+ maxMs: number;
6
+ /** Hard-shutdown threshold: after this many consecutive failures the SDK disables itself permanently. Set to 0 to disable the cap. */
7
+ failureCap: number;
8
+ };
9
+ export type RecordFailureResult = {
10
+ backoffMs: number;
11
+ consecutiveFailures: number;
12
+ justDisabled: boolean;
13
+ };
14
+ export declare class FlushRetryController {
15
+ private readonly opts;
16
+ private consecutiveFailures;
17
+ private backoffUntil;
18
+ private disabled;
19
+ constructor(opts: FlushRetryOptions);
20
+ isDisabled(): boolean;
21
+ isBackingOff(now?: number): boolean;
22
+ msUntilReady(now?: number): number;
23
+ reset(): void;
24
+ recordFailure(now?: number): RecordFailureResult;
25
+ }
package/dist/retry.js ADDED
@@ -0,0 +1,56 @@
1
+ export class FlushRetryController {
2
+ opts;
3
+ consecutiveFailures = 0;
4
+ backoffUntil = 0;
5
+ disabled = false;
6
+ constructor(opts) {
7
+ this.opts = opts;
8
+ }
9
+ isDisabled() {
10
+ return this.disabled;
11
+ }
12
+ isBackingOff(now = Date.now()) {
13
+ return !this.disabled && now < this.backoffUntil;
14
+ }
15
+ msUntilReady(now = Date.now()) {
16
+ if (this.disabled)
17
+ return Infinity;
18
+ return Math.max(0, this.backoffUntil - now);
19
+ }
20
+ reset() {
21
+ this.consecutiveFailures = 0;
22
+ this.backoffUntil = 0;
23
+ }
24
+ recordFailure(now = Date.now()) {
25
+ this.consecutiveFailures += 1;
26
+ if (this.opts.failureCap > 0 &&
27
+ this.consecutiveFailures >= this.opts.failureCap) {
28
+ const wasDisabled = this.disabled;
29
+ this.disabled = true;
30
+ this.backoffUntil = 0;
31
+ return {
32
+ backoffMs: 0,
33
+ consecutiveFailures: this.consecutiveFailures,
34
+ justDisabled: !wasDisabled,
35
+ };
36
+ }
37
+ if (this.opts.initialMs <= 0) {
38
+ this.backoffUntil = 0;
39
+ return {
40
+ backoffMs: 0,
41
+ consecutiveFailures: this.consecutiveFailures,
42
+ justDisabled: false,
43
+ };
44
+ }
45
+ const exp = Math.min(this.opts.initialMs * Math.pow(2, this.consecutiveFailures - 1), this.opts.maxMs);
46
+ // ±20% jitter so multiple clients don't synchronize their retries
47
+ const jitter = exp * 0.2 * (Math.random() * 2 - 1);
48
+ const backoffMs = Math.max(0, Math.floor(exp + jitter));
49
+ this.backoffUntil = now + backoffMs;
50
+ return {
51
+ backoffMs,
52
+ consecutiveFailures: this.consecutiveFailures,
53
+ justDisabled: false,
54
+ };
55
+ }
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emit-vision/sdk-js",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Browser SDK for self-hosted emit-vision analytics and error tracking.",
5
5
  "private": false,
6
6
  "license": "MIT",