@emit-vision/sdk-js 0.1.0 → 0.3.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +51 -40
  2. package/dist/index.js +733 -421
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,462 +1,774 @@
1
- const SDK_NAME = "emit-vision-js";
2
- const SDK_VERSION = "0.1.0";
3
- class EmitVisionClient {
4
- options;
5
- queue = [];
6
- user;
7
- context = {};
8
- deployment;
9
- featureFlags = {};
10
- tags = {};
11
- timer;
12
- flushScheduled = false;
13
- fetchImpl;
14
- flagCache = new Map();
15
- constructor(options) {
16
- this.options = options;
17
- this.fetchImpl = options.fetchImpl;
18
- this.deployment = options.deployment
19
- ? { ...options.deployment }
20
- : undefined;
21
- this.featureFlags = options.featureFlags ? { ...options.featureFlags } : {};
22
- this.timer = setInterval(() => {
23
- if (this.queue.length === 0) {
24
- return;
25
- }
26
- this.flush().catch((err) => this.debug("background flush error", { error: err }));
27
- }, options.flushIntervalMs);
28
- if (options.autoCapture.errors && typeof window !== "undefined") {
29
- window.addEventListener("error", this.handleWindowError);
30
- }
31
- if (options.autoCapture.unhandledRejections &&
32
- typeof window !== "undefined") {
33
- window.addEventListener("unhandledrejection", this.handleUnhandledRejection);
1
+ // src/retry.ts
2
+ var FlushRetryController = class {
3
+ constructor(opts) {
4
+ this.opts = opts;
5
+ }
6
+ opts;
7
+ consecutiveFailures = 0;
8
+ backoffUntil = 0;
9
+ disabled = false;
10
+ isDisabled() {
11
+ return this.disabled;
12
+ }
13
+ isBackingOff(now = Date.now()) {
14
+ return !this.disabled && now < this.backoffUntil;
15
+ }
16
+ msUntilReady(now = Date.now()) {
17
+ if (this.disabled) 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 && this.consecutiveFailures >= this.opts.failureCap) {
27
+ const wasDisabled = this.disabled;
28
+ this.disabled = true;
29
+ this.backoffUntil = 0;
30
+ return {
31
+ backoffMs: 0,
32
+ consecutiveFailures: this.consecutiveFailures,
33
+ justDisabled: !wasDisabled
34
+ };
35
+ }
36
+ if (this.opts.initialMs <= 0) {
37
+ this.backoffUntil = 0;
38
+ return {
39
+ backoffMs: 0,
40
+ consecutiveFailures: this.consecutiveFailures,
41
+ justDisabled: false
42
+ };
43
+ }
44
+ const exp = Math.min(
45
+ this.opts.initialMs * Math.pow(2, this.consecutiveFailures - 1),
46
+ this.opts.maxMs
47
+ );
48
+ const jitter = exp * 0.2 * (Math.random() * 2 - 1);
49
+ const backoffMs = Math.max(0, Math.floor(exp + jitter));
50
+ this.backoffUntil = now + backoffMs;
51
+ return {
52
+ backoffMs,
53
+ consecutiveFailures: this.consecutiveFailures,
54
+ justDisabled: false
55
+ };
56
+ }
57
+ };
58
+
59
+ // src/flag-eval.ts
60
+ var FlagEvaluator = class {
61
+ constructor(cfg, onFlagsLoaded, captureExposure2, debug) {
62
+ this.cfg = cfg;
63
+ this.onFlagsLoaded = onFlagsLoaded;
64
+ this.captureExposure = captureExposure2;
65
+ this.debug = debug;
66
+ }
67
+ cfg;
68
+ onFlagsLoaded;
69
+ captureExposure;
70
+ debug;
71
+ cache = /* @__PURE__ */ new Map();
72
+ async evaluateFlags(opts) {
73
+ const env = opts.environment ?? "";
74
+ const ttl = opts.ttlMs ?? this.cfg.ttlMs;
75
+ const key = `${env}:${opts.evaluationKey}`;
76
+ const cached = this.cache.get(key);
77
+ if (cached && Date.now() < cached.expiresAt) {
78
+ this.debug("flag eval cache hit", { key });
79
+ return cached.flags;
80
+ }
81
+ return this.fetchAndCacheFlags(opts, env, ttl, key);
82
+ }
83
+ async getFlag(flagKey, fallback, opts) {
84
+ try {
85
+ const flags = await this.evaluateFlags(opts);
86
+ const value = flags[flagKey];
87
+ return value !== void 0 ? value : fallback;
88
+ } catch {
89
+ return fallback;
90
+ }
91
+ }
92
+ async refreshFlags(opts) {
93
+ const env = opts.environment ?? "";
94
+ const ttl = opts.ttlMs ?? this.cfg.ttlMs;
95
+ const key = `${env}:${opts.evaluationKey}`;
96
+ this.cache.delete(key);
97
+ return this.fetchAndCacheFlags(opts, env, ttl, key);
98
+ }
99
+ async fetchAndCacheFlags(opts, env, ttl, cacheKey) {
100
+ let flags = {};
101
+ let evaluations = {};
102
+ try {
103
+ const body = {
104
+ environment: env,
105
+ userKey: opts.evaluationKey
106
+ };
107
+ if (opts.flagKeys && opts.flagKeys.length > 0) {
108
+ body.flagKeys = opts.flagKeys;
109
+ }
110
+ const response = await this.cfg.fetchImpl(
111
+ `${this.cfg.endpoint}/v1/flags/evaluate`,
112
+ {
113
+ method: "POST",
114
+ headers: {
115
+ "content-type": "application/json",
116
+ "x-emit-api-key": this.cfg.apiKey
117
+ },
118
+ body: JSON.stringify(body)
34
119
  }
35
- this.debug("initialized", {
36
- endpoint: options.endpoint,
37
- environment: options.environment,
38
- release: options.release,
39
- autoCapture: options.autoCapture,
40
- batchSize: options.batchSize,
41
- flushIntervalMs: options.flushIntervalMs,
42
- flushOnCapture: options.flushOnCapture,
43
- });
120
+ );
121
+ if (response.ok) {
122
+ const data = await response.json();
123
+ evaluations = data.evaluations;
124
+ flags = Object.fromEntries(
125
+ Object.entries(evaluations).map(([k, v]) => [k, v.variantValue])
126
+ );
127
+ this.debug("flag eval complete", { count: Object.keys(flags).length });
128
+ } else {
129
+ this.debug("flag eval response error", { status: response.status });
130
+ }
131
+ } catch (err) {
132
+ this.debug("flag eval network error", { error: err });
44
133
  }
45
- captureEvent(name, properties, options = {}) {
46
- this.debug("queue event", { name, properties, options });
47
- this.enqueue({
48
- type: "event",
49
- name,
50
- properties,
51
- ...this.withDefaults(options),
52
- });
134
+ this.cache.set(cacheKey, { flags, expiresAt: Date.now() + ttl });
135
+ this.onFlagsLoaded(flags);
136
+ if (this.cfg.flagExposures) {
137
+ for (const [flagKey, entry] of Object.entries(evaluations)) {
138
+ this.captureExposure(
139
+ flagKey,
140
+ entry.variantKey,
141
+ entry.variantValue,
142
+ entry.reason,
143
+ env
144
+ );
145
+ }
53
146
  }
54
- captureError(error, options = {}) {
55
- const serialized = serializeError(error);
56
- this.debug("queue error", {
57
- message: serialized.message,
58
- handled: serialized.handled,
59
- options,
60
- });
61
- this.enqueue({
62
- type: "error",
63
- name: "error",
64
- error: serialized,
65
- ...this.withDefaults(options),
66
- });
147
+ return flags;
148
+ }
149
+ };
150
+
151
+ // src/util.ts
152
+ function parseDsn(dsn) {
153
+ const parsed = new URL(dsn);
154
+ const apiKey = decodeURIComponent(parsed.username);
155
+ if (!apiKey) {
156
+ throw new Error("emit-vision dsn must include the API key before the @");
157
+ }
158
+ const pathname = stripTrailingSlash(parsed.pathname) ?? "";
159
+ const endpointPath = pathname.endsWith("/v1") ? pathname.slice(0, -"/v1".length) : pathname;
160
+ return {
161
+ apiKey,
162
+ endpoint: stripTrailingSlash(`${parsed.origin}${endpointPath}`)
163
+ };
164
+ }
165
+ function stripTrailingSlash(value) {
166
+ if (!value) {
167
+ return value;
168
+ }
169
+ return value.endsWith("/") ? value.slice(0, -1) : value;
170
+ }
171
+ function serializeError(error) {
172
+ if (error instanceof Error) {
173
+ return {
174
+ message: error.message,
175
+ name: error.name,
176
+ stack: error.stack,
177
+ handled: true
178
+ };
179
+ }
180
+ return {
181
+ message: typeof error === "string" ? error : JSON.stringify(error),
182
+ handled: true
183
+ };
184
+ }
185
+ function resolveTransport(options) {
186
+ const dsn = options.dsn ? parseDsn(options.dsn) : null;
187
+ const apiKey = options.apiKey ?? dsn?.apiKey;
188
+ if (!apiKey) {
189
+ throw new Error("emit-vision init requires either apiKey or dsn");
190
+ }
191
+ return {
192
+ apiKey,
193
+ endpoint: stripTrailingSlash(options.endpoint ?? dsn?.endpoint)
194
+ };
195
+ }
196
+ var ANON_ID_KEY = "__ev_anon_id";
197
+ function getOrCreateAnonymousId() {
198
+ try {
199
+ const stored = localStorage.getItem(ANON_ID_KEY);
200
+ if (stored) return stored;
201
+ const id = crypto.randomUUID();
202
+ localStorage.setItem(ANON_ID_KEY, id);
203
+ return id;
204
+ } catch {
205
+ return void 0;
206
+ }
207
+ }
208
+ function resolveAutoCapture(options) {
209
+ const legacyDefault = options.autoCaptureErrors === void 0 ? true : options.autoCaptureErrors;
210
+ return {
211
+ errors: options.autoCapture?.errors ?? legacyDefault,
212
+ unhandledRejections: options.autoCapture?.unhandledRejections ?? legacyDefault,
213
+ flagExposures: options.autoCapture?.flagExposures ?? true,
214
+ pageViews: options.autoCapture?.pageViews ?? false
215
+ };
216
+ }
217
+
218
+ // src/client.ts
219
+ var SDK_NAME = "emit-vision-js";
220
+ var SDK_VERSION = "0.3.0";
221
+ var MAX_EVENT_BYTES = 64 * 1024;
222
+ var MAX_BATCH_BYTES = 1 * 1024 * 1024;
223
+ function byteLength(s) {
224
+ return new TextEncoder().encode(s).length;
225
+ }
226
+ function trimEventIfOversized(payload) {
227
+ const json = JSON.stringify(payload);
228
+ const originalBytes = byteLength(json);
229
+ if (originalBytes <= MAX_EVENT_BYTES) {
230
+ return { payload, trimmedFields: null, originalBytes };
231
+ }
232
+ const propBytes = payload.properties ? byteLength(JSON.stringify(payload.properties)) : 0;
233
+ const ctxBytes = payload.context ? byteLength(JSON.stringify(payload.context)) : 0;
234
+ if (typeof console.warn === "function") {
235
+ console.warn(
236
+ `[emit-vision] event truncated \u2014 "${payload.name}" was ${originalBytes} bytes (max ${MAX_EVENT_BYTES}); properties/context trimmed.`
237
+ );
238
+ }
239
+ const trimmedFields = [];
240
+ if (propBytes > 0) trimmedFields.push("properties");
241
+ if (ctxBytes > 0) trimmedFields.push("context");
242
+ if (payload.type === "error" && payload.error.stack) {
243
+ trimmedFields.push("error.stack");
244
+ }
245
+ const base = {
246
+ properties: propBytes > 0 ? { _truncated: true, _originalBytes: propBytes } : void 0,
247
+ context: ctxBytes > 0 ? { _truncated: true, _originalBytes: ctxBytes } : void 0
248
+ };
249
+ if (payload.type === "error") {
250
+ return {
251
+ payload: { ...payload, ...base, error: { ...payload.error, stack: void 0 } },
252
+ trimmedFields,
253
+ originalBytes
254
+ };
255
+ }
256
+ return { payload: { ...payload, ...base }, trimmedFields, originalBytes };
257
+ }
258
+ function buildBatchBody(chunk) {
259
+ return JSON.stringify({
260
+ events: chunk.map((item) => ({
261
+ ...item,
262
+ sdk: { name: SDK_NAME, version: SDK_VERSION }
263
+ }))
264
+ });
265
+ }
266
+ function splitBatch(batch) {
267
+ if (batch.length <= 1) return [batch];
268
+ const body = buildBatchBody(batch);
269
+ if (byteLength(body) <= MAX_BATCH_BYTES) return [batch];
270
+ const mid = Math.floor(batch.length / 2);
271
+ return [...splitBatch(batch.slice(0, mid)), ...splitBatch(batch.slice(mid))];
272
+ }
273
+ var EmitVisionClient = class {
274
+ constructor(options) {
275
+ this.options = options;
276
+ this.fetchImpl = options.fetchImpl;
277
+ this.deployment = options.deployment ? { ...options.deployment } : void 0;
278
+ this.featureFlags = options.featureFlags ? { ...options.featureFlags } : {};
279
+ this.retry = new FlushRetryController({
280
+ initialMs: options.retryBackoffInitialMs,
281
+ maxMs: options.retryBackoffMaxMs,
282
+ failureCap: options.maxConsecutiveFlushFailures
283
+ });
284
+ this.flagEval = new FlagEvaluator(
285
+ {
286
+ endpoint: options.endpoint,
287
+ apiKey: options.apiKey,
288
+ ttlMs: options.flagEvalTtlMs,
289
+ flagExposures: options.autoCapture.flagExposures,
290
+ fetchImpl: options.fetchImpl
291
+ },
292
+ (flags) => {
293
+ this.featureFlags = { ...this.featureFlags, ...flags };
294
+ },
295
+ (flagKey, variantKey, variantValue, reason, env) => this.captureExposure(flagKey, variantKey, variantValue, reason, env),
296
+ (msg, data) => this.debug(msg, data)
297
+ );
298
+ this.timer = setInterval(() => {
299
+ if (this.queue.length === 0 || this.retry.isBackingOff()) {
300
+ return;
301
+ }
302
+ this.flush().catch(
303
+ (err) => this.debug("background flush error", { error: err })
304
+ );
305
+ }, options.flushIntervalMs);
306
+ if (options.autoCapture.errors && typeof window !== "undefined") {
307
+ window.addEventListener("error", this.handleWindowError);
67
308
  }
68
- identify(userIdOrUser, traits = {}) {
69
- this.user =
70
- typeof userIdOrUser === "string"
71
- ? { id: userIdOrUser, ...traits }
72
- : { ...userIdOrUser };
73
- this.debug("identified user", this.user);
309
+ if (options.autoCapture.unhandledRejections && typeof window !== "undefined") {
310
+ window.addEventListener(
311
+ "unhandledrejection",
312
+ this.handleUnhandledRejection
313
+ );
74
314
  }
75
- setContext(context) {
76
- this.context = { ...this.context, ...context };
77
- this.debug("updated context", this.context);
315
+ if (options.autoCapture.pageViews && typeof window !== "undefined") {
316
+ this.capturePageView();
317
+ window.addEventListener("popstate", this.handlePopState);
318
+ this.patchHistoryMethod("pushState");
319
+ this.patchHistoryMethod("replaceState");
78
320
  }
79
- setDeploymentContext(context) {
80
- this.deployment =
81
- Object.keys(context).length > 0 ? { ...context } : undefined;
82
- this.debug("updated deployment context", this.deployment);
321
+ this.debug("initialized", {
322
+ endpoint: options.endpoint,
323
+ environment: options.environment,
324
+ release: options.release,
325
+ autoCapture: options.autoCapture,
326
+ batchSize: options.batchSize,
327
+ flushIntervalMs: options.flushIntervalMs,
328
+ flushOnCapture: options.flushOnCapture
329
+ });
330
+ }
331
+ options;
332
+ queue = [];
333
+ user;
334
+ context = {};
335
+ deployment;
336
+ featureFlags = {};
337
+ tags = {};
338
+ timer;
339
+ flushScheduled = false;
340
+ fetchImpl;
341
+ flagEval;
342
+ retry;
343
+ captureEvent(name, properties, options = {}) {
344
+ this.debug("queue event", { name, properties, options });
345
+ this.enqueue({
346
+ type: "event",
347
+ name,
348
+ properties,
349
+ ...this.withDefaults(options)
350
+ });
351
+ }
352
+ captureError(error, options = {}) {
353
+ const serialized = serializeError(error);
354
+ this.debug("queue error", {
355
+ message: serialized.message,
356
+ handled: serialized.handled,
357
+ options
358
+ });
359
+ this.enqueue({
360
+ type: "error",
361
+ name: "error",
362
+ error: serialized,
363
+ ...this.withDefaults(options)
364
+ });
365
+ }
366
+ identify(userIdOrUser, traits = {}) {
367
+ this.user = typeof userIdOrUser === "string" ? { id: userIdOrUser, ...traits } : { ...userIdOrUser };
368
+ this.debug("identified user", this.user);
369
+ }
370
+ setContext(context) {
371
+ this.context = { ...this.context, ...context };
372
+ this.debug("updated context", this.context);
373
+ }
374
+ setDeploymentContext(context) {
375
+ this.deployment = Object.keys(context).length > 0 ? { ...context } : void 0;
376
+ this.debug("updated deployment context", this.deployment);
377
+ }
378
+ setFeatureFlags(featureFlags) {
379
+ this.featureFlags = { ...featureFlags };
380
+ this.debug("updated feature flags", this.featureFlags);
381
+ }
382
+ evaluateFlags(opts) {
383
+ return this.flagEval.evaluateFlags({
384
+ ...opts,
385
+ environment: opts.environment ?? this.options.environment
386
+ });
387
+ }
388
+ getFlag(flagKey, fallback, opts) {
389
+ return this.flagEval.getFlag(flagKey, fallback, {
390
+ ...opts,
391
+ environment: opts.environment ?? this.options.environment
392
+ });
393
+ }
394
+ refreshFlags(opts) {
395
+ return this.flagEval.refreshFlags({
396
+ ...opts,
397
+ environment: opts.environment ?? this.options.environment
398
+ });
399
+ }
400
+ capturePageView(path) {
401
+ const page = path ?? (typeof window !== "undefined" ? window.location.pathname : void 0);
402
+ if (!page) return;
403
+ this.captureEvent("$page_view", { page });
404
+ }
405
+ captureExposure(flagKey, variantKey, variantValue, reason, environment) {
406
+ this.captureEvent("$flag_exposure", {
407
+ flagKey,
408
+ variantKey,
409
+ ...variantValue !== void 0 ? { variantValue } : {},
410
+ ...reason ? { reason } : {},
411
+ environment: environment ?? this.options.environment
412
+ });
413
+ }
414
+ setTags(tags) {
415
+ this.tags = { ...this.tags, ...tags };
416
+ this.debug("updated tags", this.tags);
417
+ }
418
+ async flush() {
419
+ if (this.retry.isDisabled()) {
420
+ return;
83
421
  }
84
- setFeatureFlags(featureFlags) {
85
- this.featureFlags = { ...featureFlags };
86
- this.debug("updated feature flags", this.featureFlags);
422
+ const batch = this.queue.splice(0, this.options.batchSize);
423
+ if (batch.length === 0) {
424
+ return;
87
425
  }
88
- /**
89
- * Fetch and evaluate all active flags for the given evaluation key.
90
- * Results are cached for `ttlMs` (defaults to `flagEvalTtlMs` from init options).
91
- * On network failure returns an empty object and does not throw.
92
- * Evaluated variants are automatically merged into the SDK's feature-flag context.
93
- */
94
- async evaluateFlags(opts) {
95
- const env = opts.environment ?? this.options.environment ?? "";
96
- const ttl = opts.ttlMs ?? this.options.flagEvalTtlMs;
97
- const key = `${env}:${opts.evaluationKey}`;
98
- const cached = this.flagCache.get(key);
99
- if (cached && Date.now() < cached.expiresAt) {
100
- this.debug("flag eval cache hit", { key });
101
- return cached.flags;
426
+ this.debug("flushing batch", { count: batch.length });
427
+ const chunks = splitBatch(batch);
428
+ for (let i = 0; i < chunks.length; i++) {
429
+ try {
430
+ await this.flushChunk(chunks[i]);
431
+ } catch (err) {
432
+ if (!this.retry.isDisabled()) {
433
+ const tail = chunks.slice(i + 1).flat();
434
+ if (tail.length > 0) {
435
+ this.queue.splice(chunks[i].length, 0, ...tail);
436
+ }
102
437
  }
103
- return this.fetchAndCacheFlags(opts, env, ttl, key);
438
+ throw err;
439
+ }
104
440
  }
105
- /**
106
- * Return a single flag's value, evaluated for the given key.
107
- * Returns `fallback` on network failure or when the flag is not found — never throws.
108
- */
109
- async getFlag(flagKey, fallback, opts) {
110
- try {
111
- const flags = await this.evaluateFlags(opts);
112
- const value = flags[flagKey];
113
- return (value !== undefined ? value : fallback);
114
- }
115
- catch {
116
- return fallback;
117
- }
441
+ if (this.options.flushOnCapture && this.queue.length > 0) {
442
+ this.scheduleFlush();
118
443
  }
119
- /**
120
- * Bypass the cache and re-fetch evaluations for the given key.
121
- * Useful after a flag change in the dashboard that should apply immediately.
122
- */
123
- async refreshFlags(opts) {
124
- const env = opts.environment ?? this.options.environment ?? "";
125
- const ttl = opts.ttlMs ?? this.options.flagEvalTtlMs;
126
- const key = `${env}:${opts.evaluationKey}`;
127
- this.flagCache.delete(key);
128
- return this.fetchAndCacheFlags(opts, env, ttl, key);
444
+ }
445
+ async flushChunk(chunk) {
446
+ let response;
447
+ try {
448
+ response = await this.fetchImpl(`${this.options.endpoint}/v1/batch`, {
449
+ method: "POST",
450
+ headers: {
451
+ "content-type": "application/json",
452
+ "x-emit-api-key": this.options.apiKey
453
+ },
454
+ body: buildBatchBody(chunk),
455
+ keepalive: true
456
+ });
457
+ } catch (err) {
458
+ this.handleFlushFailure(chunk, { reason: "network", error: err });
459
+ throw err;
129
460
  }
130
- captureExposure(flagKey, variantKey, variantValue, reason, environment) {
131
- this.captureEvent("$flag_exposure", {
132
- flagKey,
133
- variantKey,
134
- ...(variantValue !== undefined ? { variantValue } : {}),
135
- ...(reason ? { reason } : {}),
136
- environment: environment ?? this.options.environment,
137
- });
461
+ if (!response.ok) {
462
+ const error = new Error(
463
+ `emit-vision flush failed with ${response.status}`
464
+ );
465
+ this.handleFlushFailure(chunk, {
466
+ reason: "status",
467
+ status: response.status
468
+ });
469
+ throw error;
138
470
  }
139
- async fetchAndCacheFlags(opts, env, ttl, cacheKey) {
140
- let flags = {};
141
- let evaluations = {};
142
- try {
143
- const body = {
144
- environment: env,
145
- userKey: opts.evaluationKey,
146
- };
147
- if (opts.flagKeys && opts.flagKeys.length > 0) {
148
- body.flagKeys = opts.flagKeys;
149
- }
150
- const response = await this.fetchImpl(`${this.options.endpoint}/v1/flags/evaluate`, {
151
- method: "POST",
152
- headers: {
153
- "content-type": "application/json",
154
- "x-emit-api-key": this.options.apiKey,
155
- },
156
- body: JSON.stringify(body),
157
- });
158
- if (response.ok) {
159
- const data = (await response.json());
160
- evaluations = data.evaluations;
161
- flags = Object.fromEntries(Object.entries(evaluations).map(([k, v]) => [k, v.variantValue]));
162
- this.debug("flag eval complete", { count: Object.keys(flags).length });
163
- }
164
- else {
165
- this.debug("flag eval response error", { status: response.status });
166
- }
167
- }
168
- catch (err) {
169
- this.debug("flag eval network error", { error: err });
170
- // Return empty flags on network error — never throw to callers
171
- }
172
- this.flagCache.set(cacheKey, { flags, expiresAt: Date.now() + ttl });
173
- // Merge evaluated variants into the existing feature-flag context so that
174
- // subsequent captureEvent / captureError calls include them automatically.
175
- this.featureFlags = { ...this.featureFlags, ...flags };
176
- if (this.options.autoCapture.flagExposures) {
177
- for (const [flagKey, entry] of Object.entries(evaluations)) {
178
- this.captureExposure(flagKey, entry.variantKey, entry.variantValue, entry.reason, env);
179
- }
180
- }
181
- return flags;
471
+ this.retry.reset();
472
+ this.debug("flush complete", { count: chunk.length });
473
+ }
474
+ handleFlushFailure(batch, detail) {
475
+ const result = this.retry.recordFailure();
476
+ if (result.justDisabled) {
477
+ const droppedQueued = this.queue.length;
478
+ const droppedBatch = batch.length;
479
+ this.queue = [];
480
+ this.stopBackgroundWork();
481
+ const message = `[emit-vision${this.options.label ? `:${this.options.label}` : ""}] disabled after ${result.consecutiveFailures} consecutive flush failures; dropping ${droppedQueued + droppedBatch} queued events. Re-init the SDK to resume.`;
482
+ if (typeof console.warn === "function") {
483
+ console.warn(message);
484
+ }
485
+ this.debug("disabled", {
486
+ consecutiveFailures: result.consecutiveFailures,
487
+ dropped: droppedQueued + droppedBatch
488
+ });
489
+ return;
182
490
  }
183
- setTags(tags) {
184
- this.tags = { ...this.tags, ...tags };
185
- this.debug("updated tags", this.tags);
491
+ this.queue.unshift(...batch);
492
+ if (detail.reason === "network") {
493
+ this.debug("flush network error", {
494
+ error: detail.error,
495
+ restoredCount: batch.length,
496
+ consecutiveFailures: result.consecutiveFailures,
497
+ backoffMs: result.backoffMs
498
+ });
499
+ } else {
500
+ this.debug("flush failed", {
501
+ status: detail.status,
502
+ restoredCount: batch.length,
503
+ consecutiveFailures: result.consecutiveFailures,
504
+ backoffMs: result.backoffMs
505
+ });
186
506
  }
187
- async flush() {
188
- const batch = this.queue.splice(0, this.options.batchSize);
189
- if (batch.length === 0) {
190
- return;
191
- }
192
- this.debug("flushing batch", { count: batch.length });
193
- let response;
194
- try {
195
- response = await this.fetchImpl(`${this.options.endpoint}/v1/batch`, {
196
- method: "POST",
197
- headers: {
198
- "content-type": "application/json",
199
- "x-emit-api-key": this.options.apiKey,
200
- },
201
- body: JSON.stringify({
202
- events: batch.map((item) => ({
203
- ...item,
204
- sdk: { name: SDK_NAME, version: SDK_VERSION },
205
- })),
206
- }),
207
- keepalive: true,
208
- });
209
- }
210
- catch (err) {
211
- this.queue.unshift(...batch);
212
- this.debug("flush network error", {
213
- error: err,
214
- restoredCount: batch.length,
215
- });
216
- throw err;
217
- }
218
- if (!response.ok) {
219
- this.queue.unshift(...batch);
220
- this.debug("flush failed", {
221
- status: response.status,
222
- restoredCount: batch.length,
223
- });
224
- throw new Error(`emit-vision flush failed with ${response.status}`);
225
- }
226
- this.debug("flush complete", { count: batch.length });
227
- if (this.options.flushOnCapture && this.queue.length > 0) {
228
- this.scheduleFlush();
229
- }
507
+ }
508
+ stopBackgroundWork() {
509
+ if (this.timer) {
510
+ clearInterval(this.timer);
511
+ this.timer = void 0;
230
512
  }
231
- close() {
232
- if (this.timer) {
233
- clearInterval(this.timer);
234
- this.timer = undefined;
235
- }
236
- if (typeof window !== "undefined" && this.options.autoCapture.errors) {
237
- window.removeEventListener("error", this.handleWindowError);
238
- }
239
- if (typeof window !== "undefined" &&
240
- this.options.autoCapture.unhandledRejections) {
241
- window.removeEventListener("unhandledrejection", this.handleUnhandledRejection);
242
- }
243
- this.debug("closed client");
513
+ }
514
+ close() {
515
+ this.stopBackgroundWork();
516
+ if (typeof window !== "undefined" && this.options.autoCapture.errors) {
517
+ window.removeEventListener("error", this.handleWindowError);
244
518
  }
245
- enqueue(payload) {
246
- this.queue.push(payload);
247
- this.debug("queued payload", {
248
- type: payload.type,
249
- name: payload.name,
250
- queueSize: this.queue.length,
251
- flushOnCapture: this.options.flushOnCapture,
252
- batchSize: this.options.batchSize,
253
- });
254
- if (this.options.flushOnCapture) {
255
- this.debug("capture flush scheduled", {
256
- queueSize: this.queue.length,
257
- });
258
- this.flush().catch((err) => this.debug("capture flush error", { error: err }));
259
- return;
260
- }
261
- if (this.queue.length >= this.options.batchSize) {
262
- this.flush().catch((err) => this.debug("batch flush error", { error: err }));
263
- }
519
+ if (typeof window !== "undefined" && this.options.autoCapture.unhandledRejections) {
520
+ window.removeEventListener(
521
+ "unhandledrejection",
522
+ this.handleUnhandledRejection
523
+ );
264
524
  }
265
- scheduleFlush() {
266
- if (this.flushScheduled) {
267
- return;
268
- }
269
- this.flushScheduled = true;
270
- queueMicrotask(() => {
271
- this.flushScheduled = false;
272
- if (this.queue.length === 0) {
273
- return;
274
- }
275
- this.flush().catch((err) => this.debug("scheduled flush error", { error: err }));
276
- });
525
+ if (typeof window !== "undefined" && this.options.autoCapture.pageViews) {
526
+ window.removeEventListener("popstate", this.handlePopState);
527
+ this.restoreHistoryMethod("pushState");
528
+ this.restoreHistoryMethod("replaceState");
277
529
  }
278
- withDefaults(options) {
279
- const { context, deployment, featureFlags, tags, user, environment, release, sessionId, ...rest } = options;
280
- return {
281
- ...rest,
282
- environment: environment ?? this.options.environment,
283
- release: release ?? this.options.release,
284
- sessionId: sessionId ?? this.options.sessionId,
285
- user: user ?? this.user,
286
- context: Object.keys(this.context).length
287
- ? { ...this.context, ...context }
288
- : context,
289
- deployment: this.mergeDeployment(deployment),
290
- featureFlags: this.mergeFeatureFlags(featureFlags),
291
- tags: Object.keys(this.tags).length ? { ...this.tags, ...tags } : tags,
292
- };
530
+ this.debug("closed client");
531
+ }
532
+ enqueueMetaEvent(originalName, originalBytes, trimmedFields) {
533
+ this.queue.push({
534
+ type: "event",
535
+ name: "sdk.event_truncated",
536
+ properties: { originalName, originalBytes, trimmedFields, sdkName: SDK_NAME },
537
+ environment: this.options.environment,
538
+ release: this.options.release
539
+ });
540
+ }
541
+ enqueue(payload) {
542
+ if (this.retry.isDisabled()) {
543
+ return;
544
+ }
545
+ const { payload: trimmed, trimmedFields, originalBytes } = trimEventIfOversized(payload);
546
+ this.queue.push(trimmed);
547
+ if (trimmedFields !== null) {
548
+ this.enqueueMetaEvent(payload.name, originalBytes, trimmedFields);
549
+ }
550
+ this.debug("queued payload", {
551
+ type: payload.type,
552
+ name: payload.name,
553
+ queueSize: this.queue.length,
554
+ flushOnCapture: this.options.flushOnCapture,
555
+ batchSize: this.options.batchSize
556
+ });
557
+ if (this.options.flushOnCapture) {
558
+ this.debug("capture flush scheduled", {
559
+ queueSize: this.queue.length
560
+ });
561
+ if (!this.retry.isBackingOff()) {
562
+ this.flush().catch(
563
+ (err) => this.debug("capture flush error", { error: err })
564
+ );
565
+ }
566
+ return;
293
567
  }
294
- mergeDeployment(deployment) {
295
- const merged = {
296
- ...(this.deployment ?? {}),
297
- ...(deployment ?? {}),
298
- };
299
- return Object.keys(merged).length > 0 ? merged : undefined;
568
+ if (this.queue.length >= this.options.batchSize && !this.retry.isBackingOff()) {
569
+ this.flush().catch(
570
+ (err) => this.debug("batch flush error", { error: err })
571
+ );
300
572
  }
301
- mergeFeatureFlags(featureFlags) {
302
- const merged = {
303
- ...this.featureFlags,
304
- ...(featureFlags ?? {}),
305
- };
306
- return Object.keys(merged).length > 0 ? merged : undefined;
573
+ }
574
+ scheduleFlush() {
575
+ if (this.flushScheduled) {
576
+ return;
307
577
  }
308
- handleWindowError = (event) => {
309
- const serialized = serializeError(event.error ?? event.message);
310
- this.debug("auto-captured window error", {
311
- message: serialized.message,
312
- });
313
- this.enqueue({
314
- type: "error",
315
- name: "error",
316
- error: { ...serialized, handled: false },
317
- ...this.withDefaults({ context: { source: "window.error" } }),
318
- });
578
+ this.flushScheduled = true;
579
+ queueMicrotask(() => {
580
+ this.flushScheduled = false;
581
+ if (this.queue.length === 0 || this.retry.isBackingOff()) {
582
+ return;
583
+ }
584
+ this.flush().catch(
585
+ (err) => this.debug("scheduled flush error", { error: err })
586
+ );
587
+ });
588
+ }
589
+ withDefaults(options) {
590
+ const {
591
+ context,
592
+ deployment,
593
+ featureFlags,
594
+ tags,
595
+ user,
596
+ environment,
597
+ release,
598
+ sessionId,
599
+ ...rest
600
+ } = options;
601
+ return {
602
+ ...rest,
603
+ environment: environment ?? this.options.environment,
604
+ release: release ?? this.options.release,
605
+ sessionId: sessionId ?? this.options.sessionId,
606
+ user: user ?? this.user,
607
+ context: Object.keys(this.context).length ? { ...this.context, ...context } : context,
608
+ deployment: this.mergeDeployment(deployment),
609
+ featureFlags: this.mergeFeatureFlags(featureFlags),
610
+ tags: Object.keys(this.tags).length ? { ...this.tags, ...tags } : tags
319
611
  };
320
- handleUnhandledRejection = (event) => {
321
- const serialized = serializeError(event.reason);
322
- this.debug("auto-captured rejection", {
323
- message: serialized.message,
324
- });
325
- this.enqueue({
326
- type: "error",
327
- name: "error",
328
- error: { ...serialized, handled: false },
329
- ...this.withDefaults({
330
- context: { source: "window.unhandledrejection" },
331
- }),
332
- });
612
+ }
613
+ mergeDeployment(deployment) {
614
+ const merged = {
615
+ ...this.deployment ?? {},
616
+ ...deployment ?? {}
333
617
  };
334
- debug(message, payload) {
335
- if (!this.options.debug || typeof console.debug !== "function") {
336
- return;
337
- }
338
- const prefix = this.options.label
339
- ? `[emit-vision:${this.options.label}]`
340
- : "[emit-vision]";
341
- if (payload === undefined) {
342
- console.debug(prefix, message);
343
- return;
344
- }
345
- console.debug(prefix, message, payload);
618
+ return Object.keys(merged).length > 0 ? merged : void 0;
619
+ }
620
+ mergeFeatureFlags(featureFlags) {
621
+ const merged = {
622
+ ...this.featureFlags,
623
+ ...featureFlags ?? {}
624
+ };
625
+ return Object.keys(merged).length > 0 ? merged : void 0;
626
+ }
627
+ handleWindowError = (event) => {
628
+ const serialized = serializeError(event.error ?? event.message);
629
+ this.debug("auto-captured window error", {
630
+ message: serialized.message
631
+ });
632
+ this.enqueue({
633
+ type: "error",
634
+ name: "error",
635
+ error: { ...serialized, handled: false },
636
+ ...this.withDefaults({ context: { source: "window.error" } })
637
+ });
638
+ };
639
+ handlePopState = () => {
640
+ this.capturePageView();
641
+ };
642
+ patchHistoryMethod(method) {
643
+ const original = history[method].bind(history);
644
+ history[`__ev_orig_${method}`] = original;
645
+ history[method] = (...args) => {
646
+ original(...args);
647
+ this.capturePageView();
648
+ };
649
+ }
650
+ restoreHistoryMethod(method) {
651
+ const original = history[`__ev_orig_${method}`];
652
+ if (original) {
653
+ history[method] = original;
346
654
  }
347
- }
348
- let client;
349
- export function init(options) {
350
- const transport = resolveTransport(options);
351
- const autoCapture = resolveAutoCapture(options);
352
- client?.close();
353
- client = new EmitVisionClient({
354
- ...options,
355
- flushIntervalMs: options.flushIntervalMs ?? 5000,
356
- batchSize: options.batchSize ?? 20,
357
- debug: options.debug ?? false,
358
- fetchImpl: options.fetchImpl ?? globalThis.fetch.bind(globalThis),
359
- flushOnCapture: options.flushOnCapture ?? false,
360
- flagEvalTtlMs: options.flagEvalTtlMs ?? 60_000,
361
- apiKey: transport.apiKey,
362
- endpoint: transport.endpoint ?? options.endpoint ?? "http://localhost:4301",
363
- autoCapture,
655
+ }
656
+ handleUnhandledRejection = (event) => {
657
+ const serialized = serializeError(event.reason);
658
+ this.debug("auto-captured rejection", {
659
+ message: serialized.message
364
660
  });
365
- return client;
366
- }
367
- export function captureEvent(name, properties, options) {
368
- requireClient().captureEvent(name, properties, options);
369
- }
370
- export function captureError(error, options) {
371
- requireClient().captureError(error, options);
372
- }
373
- export function identify(userIdOrUser, traits) {
374
- requireClient().identify(userIdOrUser, traits);
375
- }
376
- export function setContext(context) {
377
- requireClient().setContext(context);
661
+ this.enqueue({
662
+ type: "error",
663
+ name: "error",
664
+ error: { ...serialized, handled: false },
665
+ ...this.withDefaults({
666
+ context: { source: "window.unhandledrejection" }
667
+ })
668
+ });
669
+ };
670
+ debug(message, payload) {
671
+ if (!this.options.debug || typeof console.debug !== "function") {
672
+ return;
673
+ }
674
+ const prefix = this.options.label ? `[emit-vision:${this.options.label}]` : "[emit-vision]";
675
+ if (payload === void 0) {
676
+ console.debug(prefix, message);
677
+ return;
678
+ }
679
+ console.debug(prefix, message, payload);
680
+ }
681
+ };
682
+
683
+ // src/index.ts
684
+ var client;
685
+ function init(options) {
686
+ const transport = resolveTransport(options);
687
+ const autoCapture = resolveAutoCapture(options);
688
+ const resolved = {
689
+ ...options,
690
+ flushIntervalMs: options.flushIntervalMs ?? 5e3,
691
+ batchSize: options.batchSize ?? 20,
692
+ debug: options.debug ?? false,
693
+ fetchImpl: options.fetchImpl ?? globalThis.fetch.bind(globalThis),
694
+ flushOnCapture: options.flushOnCapture ?? false,
695
+ flagEvalTtlMs: options.flagEvalTtlMs ?? 6e4,
696
+ retryBackoffInitialMs: options.retryBackoffInitialMs ?? 1e3,
697
+ retryBackoffMaxMs: options.retryBackoffMaxMs ?? 3e4,
698
+ maxConsecutiveFlushFailures: options.maxConsecutiveFlushFailures ?? 20,
699
+ apiKey: transport.apiKey,
700
+ endpoint: transport.endpoint ?? options.endpoint ?? "http://localhost:4301",
701
+ autoCapture,
702
+ sessionId: options.sessionId ?? getOrCreateAnonymousId()
703
+ };
704
+ client?.close();
705
+ client = new EmitVisionClient(resolved);
706
+ return client;
378
707
  }
379
- export function setDeploymentContext(context) {
380
- requireClient().setDeploymentContext(context);
708
+ function captureEvent(name, properties, options) {
709
+ requireClient().captureEvent(name, properties, options);
381
710
  }
382
- export function setFeatureFlags(featureFlags) {
383
- requireClient().setFeatureFlags(featureFlags);
711
+ function captureError(error, options) {
712
+ requireClient().captureError(error, options);
384
713
  }
385
- export function setTags(tags) {
386
- requireClient().setTags(tags);
714
+ function capturePageView(path) {
715
+ requireClient().capturePageView(path);
387
716
  }
388
- export async function flush() {
389
- await requireClient().flush();
717
+ function identify(userIdOrUser, traits) {
718
+ requireClient().identify(userIdOrUser, traits);
390
719
  }
391
- export async function evaluateFlags(options) {
392
- return requireClient().evaluateFlags(options);
720
+ function setContext(context) {
721
+ requireClient().setContext(context);
393
722
  }
394
- export async function getFlag(flagKey, fallback, options) {
395
- return requireClient().getFlag(flagKey, fallback, options);
723
+ function setDeploymentContext(context) {
724
+ requireClient().setDeploymentContext(context);
396
725
  }
397
- export async function refreshFlags(options) {
398
- return requireClient().refreshFlags(options);
726
+ function setFeatureFlags(featureFlags) {
727
+ requireClient().setFeatureFlags(featureFlags);
399
728
  }
400
- export function captureExposure(flagKey, variantKey, variantValue, reason, environment) {
401
- requireClient().captureExposure(flagKey, variantKey, variantValue, reason, environment);
729
+ function setTags(tags) {
730
+ requireClient().setTags(tags);
402
731
  }
403
- function requireClient() {
404
- if (!client) {
405
- throw new Error("emit-vision SDK has not been initialized");
406
- }
407
- return client;
732
+ async function flush() {
733
+ await requireClient().flush();
408
734
  }
409
- function resolveTransport(options) {
410
- const dsn = options.dsn ? parseDsn(options.dsn) : null;
411
- const apiKey = options.apiKey ?? dsn?.apiKey;
412
- if (!apiKey) {
413
- throw new Error("emit-vision init requires either apiKey or dsn");
414
- }
415
- return {
416
- apiKey,
417
- endpoint: stripTrailingSlash(options.endpoint ?? dsn?.endpoint),
418
- };
735
+ async function evaluateFlags(options) {
736
+ return requireClient().evaluateFlags(options);
419
737
  }
420
- function resolveAutoCapture(options) {
421
- const legacyDefault = options.autoCaptureErrors === undefined ? true : options.autoCaptureErrors;
422
- return {
423
- errors: options.autoCapture?.errors ?? legacyDefault,
424
- unhandledRejections: options.autoCapture?.unhandledRejections ?? legacyDefault,
425
- flagExposures: options.autoCapture?.flagExposures ?? true,
426
- };
738
+ async function getFlag(flagKey, fallback, options) {
739
+ return requireClient().getFlag(flagKey, fallback, options);
427
740
  }
428
- function parseDsn(dsn) {
429
- const parsed = new URL(dsn);
430
- const apiKey = decodeURIComponent(parsed.username);
431
- if (!apiKey) {
432
- throw new Error("emit-vision dsn must include the API key before the @");
433
- }
434
- const pathname = stripTrailingSlash(parsed.pathname) ?? "";
435
- const endpointPath = pathname.endsWith("/v1")
436
- ? pathname.slice(0, -"/v1".length)
437
- : pathname;
438
- return {
439
- apiKey,
440
- endpoint: stripTrailingSlash(`${parsed.origin}${endpointPath}`),
441
- };
741
+ async function refreshFlags(options) {
742
+ return requireClient().refreshFlags(options);
442
743
  }
443
- function stripTrailingSlash(value) {
444
- if (!value) {
445
- return value;
446
- }
447
- return value.endsWith("/") ? value.slice(0, -1) : value;
744
+ function captureExposure(flagKey, variantKey, variantValue, reason, environment) {
745
+ requireClient().captureExposure(
746
+ flagKey,
747
+ variantKey,
748
+ variantValue,
749
+ reason,
750
+ environment
751
+ );
448
752
  }
449
- function serializeError(error) {
450
- if (error instanceof Error) {
451
- return {
452
- message: error.message,
453
- name: error.name,
454
- stack: error.stack,
455
- handled: true,
456
- };
457
- }
458
- return {
459
- message: typeof error === "string" ? error : JSON.stringify(error),
460
- handled: true,
461
- };
753
+ function requireClient() {
754
+ if (!client) {
755
+ throw new Error("emit-vision SDK has not been initialized");
756
+ }
757
+ return client;
462
758
  }
759
+ export {
760
+ captureError,
761
+ captureEvent,
762
+ captureExposure,
763
+ capturePageView,
764
+ evaluateFlags,
765
+ flush,
766
+ getFlag,
767
+ identify,
768
+ init,
769
+ refreshFlags,
770
+ setContext,
771
+ setDeploymentContext,
772
+ setFeatureFlags,
773
+ setTags
774
+ };