@emit-vision/sdk-js 0.2.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.
package/dist/index.js CHANGED
@@ -1,519 +1,774 @@
1
- import { FlushRetryController } from "./retry.js";
2
- const SDK_NAME = "emit-vision-js";
3
- const SDK_VERSION = "0.1.0";
4
- class EmitVisionClient {
5
- options;
6
- queue = [];
7
- user;
8
- context = {};
9
- deployment;
10
- featureFlags = {};
11
- tags = {};
12
- timer;
13
- flushScheduled = false;
14
- fetchImpl;
15
- flagCache = new Map();
16
- retry;
17
- constructor(options) {
18
- this.options = options;
19
- this.fetchImpl = options.fetchImpl;
20
- this.deployment = options.deployment
21
- ? { ...options.deployment }
22
- : undefined;
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
- });
29
- this.timer = setInterval(() => {
30
- if (this.queue.length === 0 || this.retry.isBackingOff()) {
31
- return;
32
- }
33
- this.flush().catch((err) => this.debug("background flush error", { error: err }));
34
- }, options.flushIntervalMs);
35
- if (options.autoCapture.errors && typeof window !== "undefined") {
36
- window.addEventListener("error", this.handleWindowError);
37
- }
38
- if (options.autoCapture.unhandledRejections &&
39
- typeof window !== "undefined") {
40
- 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)
41
119
  }
42
- this.debug("initialized", {
43
- endpoint: options.endpoint,
44
- environment: options.environment,
45
- release: options.release,
46
- autoCapture: options.autoCapture,
47
- batchSize: options.batchSize,
48
- flushIntervalMs: options.flushIntervalMs,
49
- flushOnCapture: options.flushOnCapture,
50
- });
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 });
51
133
  }
52
- captureEvent(name, properties, options = {}) {
53
- this.debug("queue event", { name, properties, options });
54
- this.enqueue({
55
- type: "event",
56
- name,
57
- properties,
58
- ...this.withDefaults(options),
59
- });
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
+ }
60
146
  }
61
- captureError(error, options = {}) {
62
- const serialized = serializeError(error);
63
- this.debug("queue error", {
64
- message: serialized.message,
65
- handled: serialized.handled,
66
- options,
67
- });
68
- this.enqueue({
69
- type: "error",
70
- name: "error",
71
- error: serialized,
72
- ...this.withDefaults(options),
73
- });
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);
74
308
  }
75
- identify(userIdOrUser, traits = {}) {
76
- this.user =
77
- typeof userIdOrUser === "string"
78
- ? { id: userIdOrUser, ...traits }
79
- : { ...userIdOrUser };
80
- this.debug("identified user", this.user);
309
+ if (options.autoCapture.unhandledRejections && typeof window !== "undefined") {
310
+ window.addEventListener(
311
+ "unhandledrejection",
312
+ this.handleUnhandledRejection
313
+ );
81
314
  }
82
- setContext(context) {
83
- this.context = { ...this.context, ...context };
84
- 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");
85
320
  }
86
- setDeploymentContext(context) {
87
- this.deployment =
88
- Object.keys(context).length > 0 ? { ...context } : undefined;
89
- 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;
90
421
  }
91
- setFeatureFlags(featureFlags) {
92
- this.featureFlags = { ...featureFlags };
93
- this.debug("updated feature flags", this.featureFlags);
422
+ const batch = this.queue.splice(0, this.options.batchSize);
423
+ if (batch.length === 0) {
424
+ return;
94
425
  }
95
- /**
96
- * Fetch and evaluate all active flags for the given evaluation key.
97
- * Results are cached for `ttlMs` (defaults to `flagEvalTtlMs` from init options).
98
- * On network failure returns an empty object and does not throw.
99
- * Evaluated variants are automatically merged into the SDK's feature-flag context.
100
- */
101
- async evaluateFlags(opts) {
102
- const env = opts.environment ?? this.options.environment ?? "";
103
- const ttl = opts.ttlMs ?? this.options.flagEvalTtlMs;
104
- const key = `${env}:${opts.evaluationKey}`;
105
- const cached = this.flagCache.get(key);
106
- if (cached && Date.now() < cached.expiresAt) {
107
- this.debug("flag eval cache hit", { key });
108
- 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
+ }
109
437
  }
110
- return this.fetchAndCacheFlags(opts, env, ttl, key);
438
+ throw err;
439
+ }
111
440
  }
112
- /**
113
- * Return a single flag's value, evaluated for the given key.
114
- * Returns `fallback` on network failure or when the flag is not found — never throws.
115
- */
116
- async getFlag(flagKey, fallback, opts) {
117
- try {
118
- const flags = await this.evaluateFlags(opts);
119
- const value = flags[flagKey];
120
- return (value !== undefined ? value : fallback);
121
- }
122
- catch {
123
- return fallback;
124
- }
441
+ if (this.options.flushOnCapture && this.queue.length > 0) {
442
+ this.scheduleFlush();
125
443
  }
126
- /**
127
- * Bypass the cache and re-fetch evaluations for the given key.
128
- * Useful after a flag change in the dashboard that should apply immediately.
129
- */
130
- async refreshFlags(opts) {
131
- const env = opts.environment ?? this.options.environment ?? "";
132
- const ttl = opts.ttlMs ?? this.options.flagEvalTtlMs;
133
- const key = `${env}:${opts.evaluationKey}`;
134
- this.flagCache.delete(key);
135
- 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;
136
460
  }
137
- captureExposure(flagKey, variantKey, variantValue, reason, environment) {
138
- this.captureEvent("$flag_exposure", {
139
- flagKey,
140
- variantKey,
141
- ...(variantValue !== undefined ? { variantValue } : {}),
142
- ...(reason ? { reason } : {}),
143
- environment: environment ?? this.options.environment,
144
- });
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;
145
470
  }
146
- async fetchAndCacheFlags(opts, env, ttl, cacheKey) {
147
- let flags = {};
148
- let evaluations = {};
149
- try {
150
- const body = {
151
- environment: env,
152
- userKey: opts.evaluationKey,
153
- };
154
- if (opts.flagKeys && opts.flagKeys.length > 0) {
155
- body.flagKeys = opts.flagKeys;
156
- }
157
- const response = await this.fetchImpl(`${this.options.endpoint}/v1/flags/evaluate`, {
158
- method: "POST",
159
- headers: {
160
- "content-type": "application/json",
161
- "x-emit-api-key": this.options.apiKey,
162
- },
163
- body: JSON.stringify(body),
164
- });
165
- if (response.ok) {
166
- const data = (await response.json());
167
- evaluations = data.evaluations;
168
- flags = Object.fromEntries(Object.entries(evaluations).map(([k, v]) => [k, v.variantValue]));
169
- this.debug("flag eval complete", { count: Object.keys(flags).length });
170
- }
171
- else {
172
- this.debug("flag eval response error", { status: response.status });
173
- }
174
- }
175
- catch (err) {
176
- this.debug("flag eval network error", { error: err });
177
- // Return empty flags on network error — never throw to callers
178
- }
179
- this.flagCache.set(cacheKey, { flags, expiresAt: Date.now() + ttl });
180
- // Merge evaluated variants into the existing feature-flag context so that
181
- // subsequent captureEvent / captureError calls include them automatically.
182
- this.featureFlags = { ...this.featureFlags, ...flags };
183
- if (this.options.autoCapture.flagExposures) {
184
- for (const [flagKey, entry] of Object.entries(evaluations)) {
185
- this.captureExposure(flagKey, entry.variantKey, entry.variantValue, entry.reason, env);
186
- }
187
- }
188
- 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;
189
490
  }
190
- setTags(tags) {
191
- this.tags = { ...this.tags, ...tags };
192
- 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
+ });
193
506
  }
194
- async flush() {
195
- if (this.retry.isDisabled()) {
196
- return;
197
- }
198
- const batch = this.queue.splice(0, this.options.batchSize);
199
- if (batch.length === 0) {
200
- return;
201
- }
202
- this.debug("flushing batch", { count: batch.length });
203
- let response;
204
- try {
205
- response = await this.fetchImpl(`${this.options.endpoint}/v1/batch`, {
206
- method: "POST",
207
- headers: {
208
- "content-type": "application/json",
209
- "x-emit-api-key": this.options.apiKey,
210
- },
211
- body: JSON.stringify({
212
- events: batch.map((item) => ({
213
- ...item,
214
- sdk: { name: SDK_NAME, version: SDK_VERSION },
215
- })),
216
- }),
217
- keepalive: true,
218
- });
219
- }
220
- catch (err) {
221
- this.handleFlushFailure(batch, { reason: "network", error: err });
222
- throw err;
223
- }
224
- if (!response.ok) {
225
- const error = new Error(`emit-vision flush failed with ${response.status}`);
226
- this.handleFlushFailure(batch, {
227
- reason: "status",
228
- status: response.status,
229
- });
230
- throw error;
231
- }
232
- this.retry.reset();
233
- this.debug("flush complete", { count: batch.length });
234
- if (this.options.flushOnCapture && this.queue.length > 0) {
235
- this.scheduleFlush();
236
- }
507
+ }
508
+ stopBackgroundWork() {
509
+ if (this.timer) {
510
+ clearInterval(this.timer);
511
+ this.timer = void 0;
237
512
  }
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
- }
513
+ }
514
+ close() {
515
+ this.stopBackgroundWork();
516
+ if (typeof window !== "undefined" && this.options.autoCapture.errors) {
517
+ window.removeEventListener("error", this.handleWindowError);
275
518
  }
276
- stopBackgroundWork() {
277
- if (this.timer) {
278
- clearInterval(this.timer);
279
- this.timer = undefined;
280
- }
519
+ if (typeof window !== "undefined" && this.options.autoCapture.unhandledRejections) {
520
+ window.removeEventListener(
521
+ "unhandledrejection",
522
+ this.handleUnhandledRejection
523
+ );
281
524
  }
282
- close() {
283
- this.stopBackgroundWork();
284
- if (typeof window !== "undefined" && this.options.autoCapture.errors) {
285
- window.removeEventListener("error", this.handleWindowError);
286
- }
287
- if (typeof window !== "undefined" &&
288
- this.options.autoCapture.unhandledRejections) {
289
- window.removeEventListener("unhandledrejection", this.handleUnhandledRejection);
290
- }
291
- this.debug("closed client");
525
+ if (typeof window !== "undefined" && this.options.autoCapture.pageViews) {
526
+ window.removeEventListener("popstate", this.handlePopState);
527
+ this.restoreHistoryMethod("pushState");
528
+ this.restoreHistoryMethod("replaceState");
292
529
  }
293
- enqueue(payload) {
294
- if (this.retry.isDisabled()) {
295
- return;
296
- }
297
- this.queue.push(payload);
298
- this.debug("queued payload", {
299
- type: payload.type,
300
- name: payload.name,
301
- queueSize: this.queue.length,
302
- flushOnCapture: this.options.flushOnCapture,
303
- batchSize: this.options.batchSize,
304
- });
305
- if (this.options.flushOnCapture) {
306
- this.debug("capture flush scheduled", {
307
- queueSize: this.queue.length,
308
- });
309
- if (!this.retry.isBackingOff()) {
310
- this.flush().catch((err) => this.debug("capture flush error", { error: err }));
311
- }
312
- return;
313
- }
314
- if (this.queue.length >= this.options.batchSize &&
315
- !this.retry.isBackingOff()) {
316
- this.flush().catch((err) => this.debug("batch flush error", { error: err }));
317
- }
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;
318
544
  }
319
- scheduleFlush() {
320
- if (this.flushScheduled) {
321
- return;
322
- }
323
- this.flushScheduled = true;
324
- queueMicrotask(() => {
325
- this.flushScheduled = false;
326
- if (this.queue.length === 0 || this.retry.isBackingOff()) {
327
- return;
328
- }
329
- this.flush().catch((err) => this.debug("scheduled flush error", { error: err }));
330
- });
545
+ const { payload: trimmed, trimmedFields, originalBytes } = trimEventIfOversized(payload);
546
+ this.queue.push(trimmed);
547
+ if (trimmedFields !== null) {
548
+ this.enqueueMetaEvent(payload.name, originalBytes, trimmedFields);
331
549
  }
332
- withDefaults(options) {
333
- const { context, deployment, featureFlags, tags, user, environment, release, sessionId, ...rest } = options;
334
- return {
335
- ...rest,
336
- environment: environment ?? this.options.environment,
337
- release: release ?? this.options.release,
338
- sessionId: sessionId ?? this.options.sessionId,
339
- user: user ?? this.user,
340
- context: Object.keys(this.context).length
341
- ? { ...this.context, ...context }
342
- : context,
343
- deployment: this.mergeDeployment(deployment),
344
- featureFlags: this.mergeFeatureFlags(featureFlags),
345
- tags: Object.keys(this.tags).length ? { ...this.tags, ...tags } : tags,
346
- };
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;
347
567
  }
348
- mergeDeployment(deployment) {
349
- const merged = {
350
- ...(this.deployment ?? {}),
351
- ...(deployment ?? {}),
352
- };
353
- 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
+ );
354
572
  }
355
- mergeFeatureFlags(featureFlags) {
356
- const merged = {
357
- ...this.featureFlags,
358
- ...(featureFlags ?? {}),
359
- };
360
- return Object.keys(merged).length > 0 ? merged : undefined;
573
+ }
574
+ scheduleFlush() {
575
+ if (this.flushScheduled) {
576
+ return;
361
577
  }
362
- handleWindowError = (event) => {
363
- const serialized = serializeError(event.error ?? event.message);
364
- this.debug("auto-captured window error", {
365
- message: serialized.message,
366
- });
367
- this.enqueue({
368
- type: "error",
369
- name: "error",
370
- error: { ...serialized, handled: false },
371
- ...this.withDefaults({ context: { source: "window.error" } }),
372
- });
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
373
611
  };
374
- handleUnhandledRejection = (event) => {
375
- const serialized = serializeError(event.reason);
376
- this.debug("auto-captured rejection", {
377
- message: serialized.message,
378
- });
379
- this.enqueue({
380
- type: "error",
381
- name: "error",
382
- error: { ...serialized, handled: false },
383
- ...this.withDefaults({
384
- context: { source: "window.unhandledrejection" },
385
- }),
386
- });
612
+ }
613
+ mergeDeployment(deployment) {
614
+ const merged = {
615
+ ...this.deployment ?? {},
616
+ ...deployment ?? {}
387
617
  };
388
- debug(message, payload) {
389
- if (!this.options.debug || typeof console.debug !== "function") {
390
- return;
391
- }
392
- const prefix = this.options.label
393
- ? `[emit-vision:${this.options.label}]`
394
- : "[emit-vision]";
395
- if (payload === undefined) {
396
- console.debug(prefix, message);
397
- return;
398
- }
399
- 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;
400
654
  }
401
- }
402
- let client;
403
- export function init(options) {
404
- const transport = resolveTransport(options);
405
- const autoCapture = resolveAutoCapture(options);
406
- client?.close();
407
- client = new EmitVisionClient({
408
- ...options,
409
- flushIntervalMs: options.flushIntervalMs ?? 5000,
410
- batchSize: options.batchSize ?? 20,
411
- debug: options.debug ?? false,
412
- fetchImpl: options.fetchImpl ?? globalThis.fetch.bind(globalThis),
413
- flushOnCapture: options.flushOnCapture ?? false,
414
- flagEvalTtlMs: options.flagEvalTtlMs ?? 60_000,
415
- retryBackoffInitialMs: options.retryBackoffInitialMs ?? 1_000,
416
- retryBackoffMaxMs: options.retryBackoffMaxMs ?? 30_000,
417
- maxConsecutiveFlushFailures: options.maxConsecutiveFlushFailures ?? 20,
418
- apiKey: transport.apiKey,
419
- endpoint: transport.endpoint ?? options.endpoint ?? "http://localhost:4301",
420
- autoCapture,
655
+ }
656
+ handleUnhandledRejection = (event) => {
657
+ const serialized = serializeError(event.reason);
658
+ this.debug("auto-captured rejection", {
659
+ message: serialized.message
421
660
  });
422
- return client;
423
- }
424
- export function captureEvent(name, properties, options) {
425
- requireClient().captureEvent(name, properties, options);
426
- }
427
- export function captureError(error, options) {
428
- requireClient().captureError(error, options);
429
- }
430
- export function identify(userIdOrUser, traits) {
431
- requireClient().identify(userIdOrUser, traits);
432
- }
433
- export function setContext(context) {
434
- 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;
435
707
  }
436
- export function setDeploymentContext(context) {
437
- requireClient().setDeploymentContext(context);
708
+ function captureEvent(name, properties, options) {
709
+ requireClient().captureEvent(name, properties, options);
438
710
  }
439
- export function setFeatureFlags(featureFlags) {
440
- requireClient().setFeatureFlags(featureFlags);
711
+ function captureError(error, options) {
712
+ requireClient().captureError(error, options);
441
713
  }
442
- export function setTags(tags) {
443
- requireClient().setTags(tags);
714
+ function capturePageView(path) {
715
+ requireClient().capturePageView(path);
444
716
  }
445
- export async function flush() {
446
- await requireClient().flush();
717
+ function identify(userIdOrUser, traits) {
718
+ requireClient().identify(userIdOrUser, traits);
447
719
  }
448
- export async function evaluateFlags(options) {
449
- return requireClient().evaluateFlags(options);
720
+ function setContext(context) {
721
+ requireClient().setContext(context);
450
722
  }
451
- export async function getFlag(flagKey, fallback, options) {
452
- return requireClient().getFlag(flagKey, fallback, options);
723
+ function setDeploymentContext(context) {
724
+ requireClient().setDeploymentContext(context);
453
725
  }
454
- export async function refreshFlags(options) {
455
- return requireClient().refreshFlags(options);
726
+ function setFeatureFlags(featureFlags) {
727
+ requireClient().setFeatureFlags(featureFlags);
456
728
  }
457
- export function captureExposure(flagKey, variantKey, variantValue, reason, environment) {
458
- requireClient().captureExposure(flagKey, variantKey, variantValue, reason, environment);
729
+ function setTags(tags) {
730
+ requireClient().setTags(tags);
459
731
  }
460
- function requireClient() {
461
- if (!client) {
462
- throw new Error("emit-vision SDK has not been initialized");
463
- }
464
- return client;
732
+ async function flush() {
733
+ await requireClient().flush();
465
734
  }
466
- function resolveTransport(options) {
467
- const dsn = options.dsn ? parseDsn(options.dsn) : null;
468
- const apiKey = options.apiKey ?? dsn?.apiKey;
469
- if (!apiKey) {
470
- throw new Error("emit-vision init requires either apiKey or dsn");
471
- }
472
- return {
473
- apiKey,
474
- endpoint: stripTrailingSlash(options.endpoint ?? dsn?.endpoint),
475
- };
735
+ async function evaluateFlags(options) {
736
+ return requireClient().evaluateFlags(options);
476
737
  }
477
- function resolveAutoCapture(options) {
478
- const legacyDefault = options.autoCaptureErrors === undefined ? true : options.autoCaptureErrors;
479
- return {
480
- errors: options.autoCapture?.errors ?? legacyDefault,
481
- unhandledRejections: options.autoCapture?.unhandledRejections ?? legacyDefault,
482
- flagExposures: options.autoCapture?.flagExposures ?? true,
483
- };
738
+ async function getFlag(flagKey, fallback, options) {
739
+ return requireClient().getFlag(flagKey, fallback, options);
484
740
  }
485
- function parseDsn(dsn) {
486
- const parsed = new URL(dsn);
487
- const apiKey = decodeURIComponent(parsed.username);
488
- if (!apiKey) {
489
- throw new Error("emit-vision dsn must include the API key before the @");
490
- }
491
- const pathname = stripTrailingSlash(parsed.pathname) ?? "";
492
- const endpointPath = pathname.endsWith("/v1")
493
- ? pathname.slice(0, -"/v1".length)
494
- : pathname;
495
- return {
496
- apiKey,
497
- endpoint: stripTrailingSlash(`${parsed.origin}${endpointPath}`),
498
- };
741
+ async function refreshFlags(options) {
742
+ return requireClient().refreshFlags(options);
499
743
  }
500
- function stripTrailingSlash(value) {
501
- if (!value) {
502
- return value;
503
- }
504
- 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
+ );
505
752
  }
506
- function serializeError(error) {
507
- if (error instanceof Error) {
508
- return {
509
- message: error.message,
510
- name: error.name,
511
- stack: error.stack,
512
- handled: true,
513
- };
514
- }
515
- return {
516
- message: typeof error === "string" ? error : JSON.stringify(error),
517
- handled: true,
518
- };
753
+ function requireClient() {
754
+ if (!client) {
755
+ throw new Error("emit-vision SDK has not been initialized");
756
+ }
757
+ return client;
519
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
+ };