@emit-vision/sdk-js 0.4.1 → 0.4.2

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
@@ -38,6 +38,7 @@ type CaptureOptions = {
38
38
  release?: string;
39
39
  sessionId?: string;
40
40
  user?: EmitVisionUser;
41
+ groupId?: string;
41
42
  context?: Record<string, unknown>;
42
43
  deployment?: DeploymentContext;
43
44
  featureFlags?: FeatureFlagsContext;
@@ -60,6 +61,10 @@ type EmitVisionOptions = {
60
61
  flushIntervalMs?: number;
61
62
  batchSize?: number;
62
63
  fetchImpl?: typeof fetch;
64
+ /** Project ID — passed through to the DevTools debug channel so the extension can build dashboard deep links. Not required for event capture. */
65
+ projectId?: string;
66
+ /** Initial group ID to associate with all events. Can be overridden per-event or updated via `group()`. */
67
+ groupId?: string;
63
68
  /** TTL in milliseconds for in-memory flag evaluation cache. Default: 60 000 (1 minute). */
64
69
  flagEvalTtlMs?: number;
65
70
  /** 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. */
@@ -68,17 +73,72 @@ type EmitVisionOptions = {
68
73
  retryBackoffMaxMs?: number;
69
74
  /** 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. */
70
75
  maxConsecutiveFlushFailures?: number;
76
+ /** When true, events are queued in memory but not sent until `consent()` is called. Default: false. */
77
+ consentRequired?: boolean;
78
+ /** Set to true when `consentRequired` is true but consent is already known at init time (e.g. read from a cookie server-side). Skips the consent gate. Default: false. */
79
+ consentGiven?: boolean;
71
80
  };
72
81
  type RequiredEmitVisionOptions = Required<Pick<EmitVisionOptions, "apiKey" | "endpoint" | "flushIntervalMs" | "batchSize" | "fetchImpl" | "flushOnCapture" | "flagEvalTtlMs" | "retryBackoffInitialMs" | "retryBackoffMaxMs" | "maxConsecutiveFlushFailures">> & Pick<EmitVisionOptions, "environment" | "release" | "sessionId" | "label" | "deployment" | "featureFlags" | "debug"> & {
73
82
  autoCapture: Required<AutoCaptureOptions> & {
74
83
  flagExposures: boolean;
75
84
  pageViews: boolean;
76
85
  };
86
+ projectId?: string;
87
+ groupId?: string;
88
+ consentRequired?: boolean;
89
+ consentGiven?: boolean;
90
+ };
91
+
92
+ type DevToolsMessage = {
93
+ type: "init";
94
+ payload: {
95
+ sdkVersion: string;
96
+ endpoint: string;
97
+ environment?: string;
98
+ release?: string;
99
+ sessionId: string;
100
+ autoCapture: AutoCaptureOptions;
101
+ projectId?: string;
102
+ consentRequired?: boolean;
103
+ };
104
+ } | {
105
+ type: "event";
106
+ payload: {
107
+ name: string;
108
+ properties?: Record<string, unknown>;
109
+ eventId?: string;
110
+ timestamp?: string;
111
+ environment?: string;
112
+ sessionId?: string;
113
+ };
114
+ } | {
115
+ type: "identify";
116
+ payload: {
117
+ userId?: string;
118
+ traits?: Record<string, unknown>;
119
+ };
120
+ } | {
121
+ type: "flush";
122
+ payload: {
123
+ count: number;
124
+ };
125
+ } | {
126
+ type: "error";
127
+ payload: {
128
+ message: string;
129
+ };
130
+ } | {
131
+ type: "group";
132
+ payload: {
133
+ groupId: string;
134
+ traits?: Record<string, unknown>;
135
+ };
77
136
  };
78
137
 
79
138
  declare class EmitVisionClient {
80
139
  private readonly options;
81
140
  private user;
141
+ private groupId;
82
142
  private context;
83
143
  private deployment;
84
144
  private featureFlags;
@@ -86,10 +146,13 @@ declare class EmitVisionClient {
86
146
  private readonly flagEval;
87
147
  private readonly autoCapture;
88
148
  private readonly flusher;
149
+ private readonly persistOnHide;
150
+ private readonly persistOnHidden;
89
151
  constructor(options: RequiredEmitVisionOptions);
90
152
  captureEvent(name: string, properties?: Record<string, unknown>, options?: CaptureOptions): void;
91
153
  captureError(error: unknown, options?: CaptureOptions): void;
92
154
  identify(userIdOrUser: string | EmitVisionUser, traits?: EmitVisionTraits): void;
155
+ group(groupId: string, traits?: Record<string, unknown>): void;
93
156
  setContext(context: Record<string, unknown>): void;
94
157
  setDeploymentContext(context: DeploymentContext): void;
95
158
  setFeatureFlags(featureFlags: FeatureFlagsContext): void;
@@ -104,6 +167,7 @@ declare class EmitVisionClient {
104
167
  }, options?: CaptureOptions): void;
105
168
  captureExposure(flagKey: string, variantKey: string, variantValue?: string | number | boolean | null, reason?: string, environment?: string): void;
106
169
  setTags(tags: Record<string, string>): void;
170
+ consent(): Promise<void>;
107
171
  flush(): Promise<void>;
108
172
  close(): void;
109
173
  private withDefaults;
@@ -125,9 +189,11 @@ declare function setDeploymentContext(context: DeploymentContext): void;
125
189
  declare function setFeatureFlags(featureFlags: FeatureFlagsContext): void;
126
190
  declare function setTags(tags: Record<string, string>): void;
127
191
  declare function flush(): Promise<void>;
192
+ declare function consent(): Promise<void>;
193
+ declare function group(groupId: string, traits?: Record<string, unknown>): void;
128
194
  declare function evaluateFlags(options: FlagEvaluationOptions): Promise<FeatureFlagsContext>;
129
195
  declare function getFlag<T extends string | number | boolean | null>(flagKey: string, fallback: T, options: FlagEvaluationOptions): Promise<T>;
130
196
  declare function refreshFlags(options: FlagEvaluationOptions): Promise<FeatureFlagsContext>;
131
197
  declare function captureExposure(flagKey: string, variantKey: string, variantValue?: string | number | boolean | null, reason?: string, environment?: string): void;
132
198
 
133
- export { type AutoCaptureOptions, type CaptureOptions, type DeploymentContext, type EmitVisionOptions, type EmitVisionTraits, type EmitVisionUser, type FeatureFlagsContext, type FlagEvaluationOptions, captureError, captureEvent, captureExposure, captureFeedback, capturePageView, evaluateFlags, flush, getFlag, identify, init, refreshFlags, setContext, setDeploymentContext, setFeatureFlags, setTags };
199
+ export { type AutoCaptureOptions, type CaptureOptions, type DeploymentContext, type DevToolsMessage, type EmitVisionOptions, type EmitVisionTraits, type EmitVisionUser, type FeatureFlagsContext, type FlagEvaluationOptions, captureError, captureEvent, captureExposure, captureFeedback, capturePageView, consent, evaluateFlags, flush, getFlag, group, identify, init, refreshFlags, setContext, setDeploymentContext, setFeatureFlags, setTags };
package/dist/index.js CHANGED
@@ -276,6 +276,21 @@ function mergeFeatureFlagsFn(clientFlags, overrideFlags) {
276
276
  function buildFlushDisabledMessage(label, consecutiveFailures, dropped) {
277
277
  return `[emit-vision${label ? `:${label}` : ""}] disabled after ${consecutiveFailures} consecutive flush failures; dropping ${dropped} queued events. Re-init the SDK to resume.`;
278
278
  }
279
+ function buildAuthDisabledMessage(label, status, dropped) {
280
+ return `[emit-vision${label ? `:${label}` : ""}] SDK disabled: received ${AUTH_FAILURE_CAP} consecutive ${status} responses from the ingest API. This usually means your DSN or API key is invalid \u2014 check the dsn or apiKey option passed to init(). Dropping ${dropped} queued events.`;
281
+ }
282
+ var AUTH_FAILURE_CAP = 3;
283
+ var FlushHttpError = class extends Error {
284
+ constructor(status, retryAfterSeconds, serverMessage) {
285
+ super(`emit-vision flush failed with ${status}`);
286
+ this.status = status;
287
+ this.retryAfterSeconds = retryAfterSeconds;
288
+ this.serverMessage = serverMessage;
289
+ }
290
+ status;
291
+ retryAfterSeconds;
292
+ serverMessage;
293
+ };
279
294
  async function executeHttpSend(chunk, endpoint, apiKey, fetchImpl) {
280
295
  const response = await fetchImpl(`${endpoint}/v1/batch`, {
281
296
  method: "POST",
@@ -284,7 +299,18 @@ async function executeHttpSend(chunk, endpoint, apiKey, fetchImpl) {
284
299
  keepalive: true
285
300
  });
286
301
  if (!response.ok) {
287
- throw new Error(`emit-vision flush failed with ${response.status}`);
302
+ const retryAfter = Number(response.headers?.get?.("retry-after"));
303
+ let serverMessage;
304
+ try {
305
+ const body = await response.json();
306
+ if (body && typeof body.message === "string") serverMessage = body.message;
307
+ } catch {
308
+ }
309
+ throw new FlushHttpError(
310
+ response.status,
311
+ Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : void 0,
312
+ serverMessage
313
+ );
288
314
  }
289
315
  }
290
316
 
@@ -307,10 +333,20 @@ var FlushRetryController = class {
307
333
  if (this.disabled) return Infinity;
308
334
  return Math.max(0, this.backoffUntil - now);
309
335
  }
336
+ forceDisable() {
337
+ this.disabled = true;
338
+ this.backoffUntil = 0;
339
+ }
310
340
  reset() {
311
341
  this.consecutiveFailures = 0;
312
342
  this.backoffUntil = 0;
313
343
  }
344
+ /** Push the next retry out to honor a server-sent Retry-After hint. Capped at 5 minutes; never shortens an existing backoff. */
345
+ applyRetryAfter(seconds, now = Date.now()) {
346
+ if (this.disabled || !Number.isFinite(seconds) || seconds <= 0) return;
347
+ const cappedMs = Math.min(seconds, 300) * 1e3;
348
+ this.backoffUntil = Math.max(this.backoffUntil, now + cappedMs);
349
+ }
314
350
  recordFailure(now = Date.now()) {
315
351
  this.consecutiveFailures += 1;
316
352
  if (this.opts.failureCap > 0 && this.consecutiveFailures >= this.opts.failureCap) {
@@ -346,11 +382,77 @@ var FlushRetryController = class {
346
382
  }
347
383
  };
348
384
 
385
+ // src/persistence.ts
386
+ var MAX_PERSISTED_EVENTS = 200;
387
+ var MAX_PERSISTED_AGE_MS = 24 * 60 * 60 * 1e3;
388
+ function persistenceKey(apiKey, projectId) {
389
+ return `emit-vision:queue:${projectId ?? "-"}:${apiKey.slice(0, 16)}`;
390
+ }
391
+ function getStorage() {
392
+ try {
393
+ if (typeof localStorage === "undefined") return null;
394
+ return localStorage;
395
+ } catch {
396
+ return null;
397
+ }
398
+ }
399
+ function persistQueue(key, queue) {
400
+ const storage = getStorage();
401
+ if (!storage || queue.length === 0) return;
402
+ const record = {
403
+ savedAt: Date.now(),
404
+ events: queue.slice(0, MAX_PERSISTED_EVENTS)
405
+ };
406
+ try {
407
+ storage.setItem(key, JSON.stringify(record));
408
+ } catch {
409
+ }
410
+ }
411
+ function restoreQueue(key, now = Date.now()) {
412
+ const storage = getStorage();
413
+ if (!storage) return [];
414
+ try {
415
+ const raw = storage.getItem(key);
416
+ storage.removeItem(key);
417
+ if (!raw) return [];
418
+ const parsed = JSON.parse(raw);
419
+ if (typeof parsed.savedAt !== "number" || !Array.isArray(parsed.events)) {
420
+ return [];
421
+ }
422
+ if (now - parsed.savedAt > MAX_PERSISTED_AGE_MS) return [];
423
+ return parsed.events.slice(0, MAX_PERSISTED_EVENTS);
424
+ } catch {
425
+ return [];
426
+ }
427
+ }
428
+ function clearPersistedQueue(key) {
429
+ const storage = getStorage();
430
+ if (!storage) return;
431
+ try {
432
+ storage.removeItem(key);
433
+ } catch {
434
+ }
435
+ }
436
+
437
+ // src/devtools-channel.ts
438
+ function dispatchDevToolsEvent(type, payload) {
439
+ if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") {
440
+ return;
441
+ }
442
+ window.dispatchEvent(
443
+ new CustomEvent("__emitVisionDevTools", {
444
+ detail: { type, payload, timestamp: Date.now() }
445
+ })
446
+ );
447
+ }
448
+
349
449
  // src/client-flush.ts
350
450
  var FlushManager = class {
351
451
  constructor(options, fetchImpl) {
352
452
  this.options = options;
353
453
  this.fetchImpl = fetchImpl;
454
+ this.consentGiven = !options.consentRequired || (options.consentGiven ?? false);
455
+ this.storageKey = persistenceKey(options.apiKey, options.projectId);
354
456
  this.retry = new FlushRetryController({
355
457
  initialMs: options.retryBackoffInitialMs,
356
458
  maxMs: options.retryBackoffMaxMs,
@@ -369,6 +471,9 @@ var FlushManager = class {
369
471
  timer;
370
472
  flushScheduled = false;
371
473
  retry;
474
+ storageKey;
475
+ consentGiven;
476
+ authFailureCount = 0;
372
477
  enqueue(payload) {
373
478
  if (this.retry.isDisabled()) return;
374
479
  const {
@@ -407,8 +512,27 @@ var FlushManager = class {
407
512
  );
408
513
  }
409
514
  }
515
+ giveConsent() {
516
+ this.consentGiven = true;
517
+ return this.flush();
518
+ }
519
+ /** Restore any queue persisted by a previous page load, prepending it so older events flush first. */
520
+ restorePersisted() {
521
+ const restored = restoreQueue(this.storageKey);
522
+ if (restored.length === 0) return;
523
+ this.queue.unshift(...restored);
524
+ debugLog("restored persisted queue", this.options, {
525
+ count: restored.length
526
+ });
527
+ }
528
+ /** Spill the current queue to localStorage (pagehide / tab hidden). No-op without consent. */
529
+ persistNow() {
530
+ if (!this.consentGiven || this.retry.isDisabled()) return;
531
+ persistQueue(this.storageKey, this.queue);
532
+ }
410
533
  async flush() {
411
534
  if (this.retry.isDisabled()) return;
535
+ if (!this.consentGiven) return;
412
536
  const batch = this.queue.splice(0, this.options.batchSize);
413
537
  if (batch.length === 0) return;
414
538
  debugLog("flushing batch", this.options, { count: batch.length });
@@ -446,19 +570,50 @@ var FlushManager = class {
446
570
  this.fetchImpl
447
571
  );
448
572
  } catch (err) {
449
- const m = err instanceof Error ? /flush failed with (\d+)/.exec(err.message) : null;
450
573
  this.handleFlushFailure(
451
574
  chunk,
452
575
  tailCount,
453
- m ? { reason: "status", status: Number(m[1]) } : { reason: "network", error: err }
576
+ err instanceof FlushHttpError ? {
577
+ reason: "status",
578
+ status: err.status,
579
+ retryAfterSeconds: err.retryAfterSeconds,
580
+ serverMessage: err.serverMessage
581
+ } : { reason: "network", error: err }
454
582
  );
455
583
  throw err;
456
584
  }
457
585
  this.retry.reset();
586
+ this.authFailureCount = 0;
587
+ clearPersistedQueue(this.storageKey);
458
588
  debugLog("flush complete", this.options, { count: chunk.length });
589
+ dispatchDevToolsEvent("flush", { count: chunk.length });
459
590
  }
460
591
  handleFlushFailure(batch, tailCount, detail) {
592
+ if (detail.reason === "status" && (detail.status === 401 || detail.status === 403)) {
593
+ this.authFailureCount += 1;
594
+ if (this.authFailureCount >= AUTH_FAILURE_CAP) {
595
+ const dropped = this.queue.length + batch.length + tailCount;
596
+ this.queue = [];
597
+ this.close();
598
+ this.retry.forceDisable();
599
+ const msg = buildAuthDisabledMessage(
600
+ this.options.label,
601
+ detail.status,
602
+ dropped
603
+ );
604
+ if (typeof console.warn === "function") console.warn(msg);
605
+ debugLog("disabled (auth)", this.options, {
606
+ status: detail.status,
607
+ authFailures: this.authFailureCount,
608
+ dropped
609
+ });
610
+ return;
611
+ }
612
+ }
461
613
  const result = this.retry.recordFailure();
614
+ if (detail.reason === "status" && (detail.status === 429 || detail.status === 503) && detail.retryAfterSeconds !== void 0) {
615
+ this.retry.applyRetryAfter(detail.retryAfterSeconds);
616
+ }
462
617
  if (result.justDisabled) {
463
618
  const dropped = this.queue.length + batch.length + tailCount;
464
619
  this.queue = [];
@@ -476,6 +631,7 @@ var FlushManager = class {
476
631
  return;
477
632
  }
478
633
  this.queue.unshift(...batch);
634
+ this.persistNow();
479
635
  const base = {
480
636
  restoredCount: batch.length,
481
637
  consecutiveFailures: result.consecutiveFailures,
@@ -489,6 +645,7 @@ var FlushManager = class {
489
645
  } else {
490
646
  debugLog("flush failed", this.options, {
491
647
  status: detail.status,
648
+ serverMessage: detail.serverMessage,
492
649
  ...base
493
650
  });
494
651
  }
@@ -608,9 +765,17 @@ var BrowserAutoCapture = class {
608
765
  var EmitVisionClient = class {
609
766
  constructor(options) {
610
767
  this.options = options;
768
+ this.groupId = options.groupId;
611
769
  this.deployment = options.deployment ? { ...options.deployment } : void 0;
612
770
  this.featureFlags = options.featureFlags ? { ...options.featureFlags } : {};
613
771
  this.flusher = new FlushManager(options, options.fetchImpl);
772
+ this.flusher.restorePersisted();
773
+ if (typeof window !== "undefined") {
774
+ window.addEventListener("pagehide", this.persistOnHide);
775
+ }
776
+ if (typeof document !== "undefined") {
777
+ document.addEventListener("visibilitychange", this.persistOnHidden);
778
+ }
614
779
  this.flagEval = new FlagEvaluator(
615
780
  {
616
781
  endpoint: options.endpoint,
@@ -641,9 +806,20 @@ var EmitVisionClient = class {
641
806
  flushIntervalMs: options.flushIntervalMs,
642
807
  flushOnCapture: options.flushOnCapture
643
808
  });
809
+ dispatchDevToolsEvent("init", {
810
+ sdkVersion: SDK_VERSION,
811
+ endpoint: options.endpoint,
812
+ environment: options.environment,
813
+ release: options.release,
814
+ sessionId: options.sessionId ?? "",
815
+ autoCapture: options.autoCapture,
816
+ projectId: options.projectId,
817
+ consentRequired: options.consentRequired
818
+ });
644
819
  }
645
820
  options;
646
821
  user;
822
+ groupId;
647
823
  context = {};
648
824
  deployment;
649
825
  featureFlags = {};
@@ -651,13 +827,28 @@ var EmitVisionClient = class {
651
827
  flagEval;
652
828
  autoCapture;
653
829
  flusher;
830
+ persistOnHide = () => this.flusher.persistNow();
831
+ persistOnHidden = () => {
832
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
833
+ this.flusher.persistNow();
834
+ }
835
+ };
654
836
  captureEvent(name, properties, options = {}) {
837
+ const eventId = options.eventId ?? (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : void 0);
655
838
  debugLog("queue event", this.options, { name, properties, options });
656
839
  this.flusher.enqueue({
657
840
  type: "event",
658
841
  name,
659
842
  properties,
660
- ...this.withDefaults(options)
843
+ ...this.withDefaults({ ...options, eventId })
844
+ });
845
+ dispatchDevToolsEvent("event", {
846
+ name,
847
+ properties,
848
+ eventId,
849
+ timestamp: options.timestamp,
850
+ environment: options.environment,
851
+ sessionId: options.sessionId
661
852
  });
662
853
  }
663
854
  captureError(error, options = {}) {
@@ -676,6 +867,13 @@ var EmitVisionClient = class {
676
867
  identify(userIdOrUser, traits = {}) {
677
868
  this.user = typeof userIdOrUser === "string" ? { id: userIdOrUser, ...traits } : { ...userIdOrUser };
678
869
  debugLog("identified user", this.options, this.user);
870
+ const { id, ...rest } = this.user;
871
+ dispatchDevToolsEvent("identify", { userId: id, traits: rest });
872
+ }
873
+ group(groupId, traits) {
874
+ this.groupId = groupId;
875
+ debugLog("set group", this.options, { groupId, traits });
876
+ dispatchDevToolsEvent("group", { groupId, traits });
679
877
  }
680
878
  setContext(context) {
681
879
  this.context = { ...this.context, ...context };
@@ -733,10 +931,19 @@ var EmitVisionClient = class {
733
931
  this.tags = { ...this.tags, ...tags };
734
932
  debugLog("updated tags", this.options, this.tags);
735
933
  }
934
+ consent() {
935
+ return this.flusher.giveConsent();
936
+ }
736
937
  flush() {
737
938
  return this.flusher.flush();
738
939
  }
739
940
  close() {
941
+ if (typeof window !== "undefined") {
942
+ window.removeEventListener("pagehide", this.persistOnHide);
943
+ }
944
+ if (typeof document !== "undefined") {
945
+ document.removeEventListener("visibilitychange", this.persistOnHidden);
946
+ }
740
947
  this.flusher.close();
741
948
  this.autoCapture.detach();
742
949
  debugLog("closed client", this.options);
@@ -749,7 +956,7 @@ var EmitVisionClient = class {
749
956
  this.featureFlags,
750
957
  this.tags,
751
958
  this.options,
752
- opts
959
+ { groupId: this.groupId, ...opts }
753
960
  );
754
961
  }
755
962
  };
@@ -809,6 +1016,12 @@ function setTags(tags) {
809
1016
  async function flush() {
810
1017
  await requireClient().flush();
811
1018
  }
1019
+ async function consent() {
1020
+ await requireClient().consent();
1021
+ }
1022
+ function group(groupId, traits) {
1023
+ requireClient().group(groupId, traits);
1024
+ }
812
1025
  async function evaluateFlags(options) {
813
1026
  return requireClient().evaluateFlags(options);
814
1027
  }
@@ -839,9 +1052,11 @@ export {
839
1052
  captureExposure,
840
1053
  captureFeedback,
841
1054
  capturePageView,
1055
+ consent,
842
1056
  evaluateFlags,
843
1057
  flush,
844
1058
  getFlag,
1059
+ group,
845
1060
  identify,
846
1061
  init,
847
1062
  refreshFlags,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emit-vision/sdk-js",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Browser SDK for self-hosted emit-vision analytics and error tracking.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -33,7 +33,19 @@
33
33
  "default": "./dist/index.js"
34
34
  }
35
35
  },
36
+ "size-limit": [
37
+ {
38
+ "name": "@emit-vision/sdk-js (gzip)",
39
+ "path": "dist/index.js",
40
+ "limit": "9 kB"
41
+ }
42
+ ],
43
+ "devDependencies": {
44
+ "@size-limit/file": "^11.1.6",
45
+ "size-limit": "^11.1.6"
46
+ },
36
47
  "scripts": {
37
- "build": "tsup --config tsup.config.ts"
48
+ "build": "tsup --config tsup.config.ts",
49
+ "size": "size-limit"
38
50
  }
39
51
  }