@shipeasy/sdk 6.3.1 → 7.1.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.
@@ -122,50 +122,21 @@ function send(url) {
122
122
  }
123
123
  }
124
124
 
125
- // src/logger.ts
126
- var LOG_LEVELS = ["silent", "error", "warn", "info", "debug"];
127
- var RANK = {
128
- silent: 0,
129
- error: 1,
130
- warn: 2,
131
- info: 3,
132
- debug: 4
133
- };
134
- var currentLevel = "warn";
135
- function setLogLevel(level) {
136
- if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
137
- currentLevel = level;
138
- }
125
+ // src/env.ts
126
+ function isProductionEnv(configuredEnv) {
127
+ const native = readNativeEnv();
128
+ if (native !== null) return native === "production" || native === "prod";
129
+ return (configuredEnv ?? "prod").toLowerCase() === "prod";
139
130
  }
140
- function write(method, args) {
131
+ function readNativeEnv() {
141
132
  try {
142
- const c = globalThis.console;
143
- if (!c) return;
144
- const fn = c[method] ?? c.log;
145
- if (typeof fn === "function") fn.call(c, ...args);
133
+ const proc = globalThis.process;
134
+ const raw = proc?.env?.SHIPEASY_ENV ?? proc?.env?.NODE_ENV;
135
+ if (typeof raw !== "string") return null;
136
+ const v = raw.trim().toLowerCase();
137
+ return v.length ? v : null;
146
138
  } catch {
147
- }
148
- }
149
- var logger = {
150
- error(...args) {
151
- if (RANK[currentLevel] >= RANK.error) write("error", args);
152
- },
153
- warn(...args) {
154
- if (RANK[currentLevel] >= RANK.warn) write("warn", args);
155
- },
156
- info(...args) {
157
- if (RANK[currentLevel] >= RANK.info) write("info", args);
158
- },
159
- debug(...args) {
160
- if (RANK[currentLevel] >= RANK.debug) write("debug", args);
161
- }
162
- };
163
- function safeRun(label, fallback, fn) {
164
- try {
165
- return fn();
166
- } catch (err) {
167
- logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
168
- return fallback;
139
+ return null;
169
140
  }
170
141
  }
171
142
 
@@ -286,7 +257,7 @@ function captureCallsiteStack() {
286
257
  const kept = lines.slice(1).filter((l) => !/@shipeasy[\\/]sdk|see[\\/]core|captureCallsiteStack|\bsee\b\s*\(/.test(l));
287
258
  return kept.length ? kept.join("\n") : void 0;
288
259
  }
289
- function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlationId) {
260
+ function buildSeeEvent(problem, consequence, extras, ctx2, kindOverride, correlationId) {
290
261
  let errorType;
291
262
  let message;
292
263
  let stack;
@@ -314,8 +285,8 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
314
285
  message: truncate(message, SEE_MAX_MESSAGE),
315
286
  subject: consequence.subject,
316
287
  outcome: consequence.outcome,
317
- side: ctx.side,
318
- sdk_version: ctx.sdkVersion,
288
+ side: ctx2.side,
289
+ sdk_version: ctx2.sdkVersion,
319
290
  ts: Date.now()
320
291
  };
321
292
  if (stack) ev.stack = truncate(stack, SEE_MAX_STACK);
@@ -324,10 +295,10 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
324
295
  if (causedBy) ev.caused_by = causedBy;
325
296
  const cleanExtras = sanitizeExtras(extras);
326
297
  if (cleanExtras) ev.extras = cleanExtras;
327
- if (ctx.url) ev.url = truncate(ctx.url, SEE_MAX_SUBJECT);
328
- if (ctx.userId) ev.user_id = ctx.userId;
329
- if (ctx.anonId) ev.anonymous_id = ctx.anonId;
330
- if (ctx.env) ev.env = ctx.env;
298
+ if (ctx2.url) ev.url = truncate(ctx2.url, SEE_MAX_SUBJECT);
299
+ if (ctx2.userId) ev.user_id = ctx2.userId;
300
+ if (ctx2.anonId) ev.anonymous_id = ctx2.anonId;
301
+ if (ctx2.env) ev.env = ctx2.env;
331
302
  markReported(problem, ev);
332
303
  return ev;
333
304
  }
@@ -420,6 +391,98 @@ var SeeLimiter = class {
420
391
  }
421
392
  };
422
393
 
394
+ // src/internal-report.ts
395
+ var INGEST_URL = "https://api.shipeasy.ai/collect";
396
+ var PLACEHOLDER_KEY = "sdk_client_REPLACE_WITH_SHIPEASY_INTERNAL_ERROR_KEY";
397
+ var INGEST_KEY = "sdk_client_00bd4608a03e4084922978f9522614d5";
398
+ function keyConfigured() {
399
+ return !!INGEST_KEY && INGEST_KEY !== PLACEHOLDER_KEY;
400
+ }
401
+ var OUTCOME = "returned a safe default";
402
+ var SDK_ID = "ts";
403
+ var ctx = null;
404
+ var limiter = new SeeLimiter();
405
+ function setInternalReportContext(c) {
406
+ ctx = { side: c.side, sdkVersion: c.sdkVersion, enabled: c.enabled !== false };
407
+ }
408
+ function reportInternalError(label, err) {
409
+ try {
410
+ if (!ctx || !ctx.enabled || !keyConfigured()) return;
411
+ const ev = buildSeeEvent(
412
+ err,
413
+ causesThe(label).to(OUTCOME),
414
+ { sdk: SDK_ID },
415
+ { side: ctx.side, sdkVersion: ctx.sdkVersion },
416
+ "caught"
417
+ );
418
+ if (!limiter.shouldSend(ev)) return;
419
+ send2(INGEST_URL, INGEST_KEY, JSON.stringify({ events: [ev] }));
420
+ } catch {
421
+ }
422
+ }
423
+ function send2(url, key, body) {
424
+ try {
425
+ const f = globalThis.fetch;
426
+ if (typeof f !== "function") return;
427
+ void f(url, {
428
+ method: "POST",
429
+ headers: { "X-SDK-Key": key, "Content-Type": "text/plain" },
430
+ body,
431
+ keepalive: true
432
+ }).catch(() => {
433
+ });
434
+ } catch {
435
+ }
436
+ }
437
+
438
+ // src/logger.ts
439
+ var LOG_LEVELS = ["silent", "error", "warn", "info", "debug"];
440
+ var RANK = {
441
+ silent: 0,
442
+ error: 1,
443
+ warn: 2,
444
+ info: 3,
445
+ debug: 4
446
+ };
447
+ var currentLevel = "warn";
448
+ function setLogLevel(level) {
449
+ if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
450
+ currentLevel = level;
451
+ }
452
+ }
453
+ function write(method, args) {
454
+ try {
455
+ const c = globalThis.console;
456
+ if (!c) return;
457
+ const fn = c[method] ?? c.log;
458
+ if (typeof fn === "function") fn.call(c, ...args);
459
+ } catch {
460
+ }
461
+ }
462
+ var logger = {
463
+ error(...args) {
464
+ if (RANK[currentLevel] >= RANK.error) write("error", args);
465
+ },
466
+ warn(...args) {
467
+ if (RANK[currentLevel] >= RANK.warn) write("warn", args);
468
+ },
469
+ info(...args) {
470
+ if (RANK[currentLevel] >= RANK.info) write("info", args);
471
+ },
472
+ debug(...args) {
473
+ if (RANK[currentLevel] >= RANK.debug) write("debug", args);
474
+ }
475
+ };
476
+ function safeRun(label, fallback, fn) {
477
+ try {
478
+ return fn();
479
+ } catch (err) {
480
+ logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
481
+ reportInternalError(label, err);
482
+ return fallback;
483
+ }
484
+ }
485
+
423
486
  // src/server/index.ts
424
487
  var version = "4.0.0";
425
488
  var C1 = 3432918353;
@@ -556,6 +619,78 @@ function evalGateInternal(gate, user) {
556
619
  if (!uid) return gate.rolloutPct >= 1e4;
557
620
  return murmur3(`${gate.salt}:${uid}`) % 1e4 < gate.rolloutPct;
558
621
  }
622
+ function paramDefaultsFromSchema(schema) {
623
+ if (!schema || schema.length === 0) return null;
624
+ const out = {};
625
+ for (const p of schema) out[p.name] = p.default;
626
+ return out;
627
+ }
628
+ function mergeParams(paramDefaults, groupParams) {
629
+ return paramDefaults ? { ...paramDefaults, ...groupParams } : { ...groupParams };
630
+ }
631
+ function classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky) {
632
+ const asGroup = (g) => ({
633
+ state: "group",
634
+ group: g.name,
635
+ params: mergeParams(paramDefaults, g.params)
636
+ });
637
+ if (exp.targetingGate && !evalGate(exp.targetingGate)) return { state: "out" };
638
+ const uid = pickIdentifier(user, exp.bucketBy);
639
+ if (!uid) return { state: "out" };
640
+ const universeSeg = murmur3(`${exp.universe}:${uid}`) % 1e4;
641
+ if (holdoutRange) {
642
+ const [lo, hi] = holdoutRange;
643
+ if (universeSeg >= lo && universeSeg <= hi) return { state: "holdout" };
644
+ }
645
+ if (exp.holdoutGate && evalGate(exp.holdoutGate)) return { state: "holdout" };
646
+ const salt8 = exp.salt.slice(0, 8);
647
+ if (sticky) {
648
+ const entry = sticky.get();
649
+ if (entry && entry.s === salt8) {
650
+ const g = exp.groups.find((x) => x.name === entry.g);
651
+ if (g) return asGroup(g);
652
+ }
653
+ }
654
+ const pooled = (exp.hashVersion ?? 1) >= 2 && exp.poolOffsetBp != null && exp.poolSizeBp != null && exp.poolSizeBp > 0;
655
+ if (pooled) {
656
+ const lo = exp.poolOffsetBp;
657
+ const hi = lo + exp.poolSizeBp;
658
+ if (universeSeg < lo || universeSeg >= hi) return { state: "out" };
659
+ } else {
660
+ if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return { state: "out" };
661
+ }
662
+ const reserved = Math.max(0, Math.min(1e4, exp.reservedHeadroomBp ?? 0));
663
+ const usable = 1e4 - reserved;
664
+ const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
665
+ if (groupHash >= usable) return { state: "out" };
666
+ let cumulative = 0;
667
+ for (let i = 0; i < exp.groups.length; i++) {
668
+ const g = exp.groups[i];
669
+ cumulative += g.weight;
670
+ if (groupHash < cumulative || i === exp.groups.length - 1) {
671
+ sticky?.set({ g: g.name, s: salt8 });
672
+ return asGroup(g);
673
+ }
674
+ }
675
+ return { state: "out" };
676
+ }
677
+ var AssignmentImpl = class {
678
+ constructor(name, group, params) {
679
+ this.name = name;
680
+ this.group = group;
681
+ this.params = params;
682
+ }
683
+ name;
684
+ group;
685
+ params;
686
+ get enrolled() {
687
+ return this.group !== null;
688
+ }
689
+ get(field, fallback) {
690
+ const v = this.params[field];
691
+ return v === void 0 ? fallback : v;
692
+ }
693
+ };
559
694
  var TRUE_RX = /^(true|on|1|yes)$/i;
560
695
  var FALSE_RX = /^(false|off|0|no)$/i;
561
696
  function parseOverrideBool(raw) {
@@ -607,6 +742,13 @@ var Engine = class _Engine {
607
742
  env;
608
743
  privateAttributes;
609
744
  stickyStore;
745
+ // Master network gate — false ⇒ the SDK never touches the network (no fetch,
746
+ // no track, no telemetry, no internal error reports). Defaults to
747
+ // environment-derived (prod-on), forced false by testMode.
748
+ networkEnabled;
749
+ // Convenience inverse of networkEnabled — the "no network at all" state that
750
+ // every fetch/track/init gate keys on. `testMode` implies offline.
751
+ offline;
610
752
  telemetry;
611
753
  seeLimiter = new SeeLimiter();
612
754
  flagsBlob = null;
@@ -626,6 +768,9 @@ var Engine = class _Engine {
626
768
  flagOverrides = /* @__PURE__ */ new Map();
627
769
  configOverrides = /* @__PURE__ */ new Map();
628
770
  experimentOverrides = /* @__PURE__ */ new Map();
771
+ // Bounded per-process exposure dedup (`uid:exp:group`) so auto-exposure from
772
+ // repeated assign() calls doesn't spam /collect. Cleared past a soft cap.
773
+ exposureSeen = /* @__PURE__ */ new Set();
629
774
  // Change listeners fired after a background poll returns NEW data (200, not
630
775
  // 304). Never fired in testMode/offline (no polling happens there).
631
776
  changeListeners = /* @__PURE__ */ new Set();
@@ -637,15 +782,24 @@ var Engine = class _Engine {
637
782
  this.privateAttributes = opts.privateAttributes ?? [];
638
783
  this.stickyStore = opts.stickyStore;
639
784
  this.testMode = opts.testMode === true;
785
+ const prod = isProductionEnv(this.env);
786
+ this.networkEnabled = this.testMode ? false : opts.isNetworkEnabled ?? prod;
787
+ this.offline = !this.networkEnabled;
788
+ setInternalReportContext({
789
+ side: "server",
790
+ sdkVersion: version,
791
+ enabled: this.networkEnabled && opts.disableInternalErrorReporting !== true
792
+ });
640
793
  this.telemetry = new Telemetry({
641
794
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
642
795
  sdkKey: this.apiKey,
643
796
  side: "server",
644
797
  env: this.env,
645
- // Test mode never talks to the network telemetry off regardless of opt.
646
- disabled: this.testMode || opts.disableTelemetry
798
+ // Off when the network is disabled; otherwise honour an explicit
799
+ // disableTelemetry, else default to prod-on (off outside production).
800
+ disabled: this.offline || (opts.disableTelemetry ?? !prod)
647
801
  });
648
- if (opts.initialBlob || this.testMode) {
802
+ if (opts.initialBlob || this.offline) {
649
803
  this.flagsBlob = opts.initialBlob ?? { version: "test", plan: "free", gates: {}, configs: {}, killswitches: {} };
650
804
  this.expsBlob = this.expsBlob ?? { version: "test", universes: {}, experiments: {} };
651
805
  this.initialized = true;
@@ -696,7 +850,7 @@ var Engine = class _Engine {
696
850
  return _Engine.fromSnapshot(snapshot);
697
851
  }
698
852
  async init() {
699
- if (this.testMode) {
853
+ if (this.offline) {
700
854
  this.initialized = true;
701
855
  return;
702
856
  }
@@ -705,7 +859,7 @@ var Engine = class _Engine {
705
859
  this.startPoll();
706
860
  }
707
861
  async initOnce() {
708
- if (this.testMode || this.initialized) return;
862
+ if (this.offline || this.initialized) return;
709
863
  await this.fetchAll();
710
864
  this.initialized = true;
711
865
  }
@@ -847,68 +1001,77 @@ var Engine = class _Engine {
847
1001
  return "defaultValue" in opts ? opts.defaultValue : void 0;
848
1002
  }
849
1003
  }
850
- getExperiment(name, user, defaultParams, decode) {
851
- this.telemetry.emit("experiment", name);
852
- const notIn = {
853
- inExperiment: false,
854
- group: "control",
855
- params: defaultParams
1004
+ /**
1005
+ * Bind the per-experiment sticky seam over the configured {@link StickyBucketStore}
1006
+ * for one (unit, experiment). Absent store ⇒ deterministic (no sticky).
1007
+ */
1008
+ bindSticky(name, uid) {
1009
+ const store = this.stickyStore;
1010
+ if (!store) return void 0;
1011
+ return {
1012
+ get: () => store.get(uid)?.[name],
1013
+ set: (entry) => store.set(uid, name, entry)
856
1014
  };
1015
+ }
1016
+ /**
1017
+ * Evaluate one experiment by name for `user` — override → full classify
1018
+ * pipeline (targeting → universe holdout → holdout gate → sticky → allocation
1019
+ * → group), merging the universe defaults under the assigned variant (§B2).
1020
+ * Internal: the public surface is `universe(name).assign(user)`. Reused by the
1021
+ * SSR `evaluate()` bootstrap (keyed by experiment name) and by `assignUniverse`.
1022
+ */
1023
+ evalExperiment(name, exp, user) {
1024
+ const paramDefaults = paramDefaultsFromSchema(
1025
+ this.expsBlob?.universes[exp.universe]?.param_schema
1026
+ );
857
1027
  const ov = this.experimentOverrides.get(name);
858
- if (ov) {
859
- if (!decode) return { inExperiment: true, group: ov.group, params: ov.params };
860
- try {
861
- return { inExperiment: true, group: ov.group, params: decode(ov.params) };
862
- } catch (err) {
863
- logger.warn(`[shipeasy] getExperiment('${name}') override decode failed:`, String(err));
864
- return notIn;
865
- }
866
- }
867
- if (!this.flagsBlob || !this.expsBlob) return notIn;
868
- const exp = this.expsBlob.experiments[name];
869
- if (!exp || exp.status !== "running") return notIn;
870
- if (exp.targetingGate) {
871
- const gate = this.flagsBlob.gates[exp.targetingGate];
872
- if (!gate || !evalGateInternal(gate, user)) return notIn;
873
- }
874
- const uid = pickIdentifier(user, exp.bucketBy);
875
- if (!uid) return notIn;
876
- const universe = this.expsBlob.universes[exp.universe];
877
- const holdoutRange = universe?.holdout_range ?? null;
878
- if (holdoutRange) {
879
- const seg = murmur3(`${exp.universe}:${uid}`) % 1e4;
880
- const [lo, hi] = holdoutRange;
881
- if (seg >= lo && seg <= hi) return notIn;
882
- }
883
- const asResult = (g) => {
884
- if (!decode) return { inExperiment: true, group: g.name, params: g.params };
885
- try {
886
- return { inExperiment: true, group: g.name, params: decode(g.params) };
887
- } catch (err) {
888
- logger.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
889
- return notIn;
890
- }
1028
+ if (ov) return { state: "group", group: ov.group, params: mergeParams(paramDefaults, ov.params) };
1029
+ if (!this.flagsBlob || !this.expsBlob) return { state: "out" };
1030
+ if (exp.status !== "running") return { state: "out" };
1031
+ const holdoutRange = this.expsBlob.universes[exp.universe]?.holdout_range ?? null;
1032
+ const evalGate = (gname) => {
1033
+ const gate = this.flagsBlob?.gates[gname];
1034
+ return gate ? evalGateInternal(gate, user) : false;
891
1035
  };
892
- const salt8 = exp.salt.slice(0, 8);
893
- if (this.stickyStore) {
894
- const entry = this.stickyStore.get(uid)?.[name];
895
- if (entry && entry.s === salt8) {
896
- const g = exp.groups.find((x) => x.name === entry.g);
897
- if (g) return asResult(g);
898
- }
899
- }
900
- if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return notIn;
901
- const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
902
- let cumulative = 0;
903
- for (let i = 0; i < exp.groups.length; i++) {
904
- const g = exp.groups[i];
905
- cumulative += g.weight;
906
- if (groupHash < cumulative || i === exp.groups.length - 1) {
907
- this.stickyStore?.set(uid, name, { g: g.name, s: salt8 });
908
- return asResult(g);
1036
+ const uid = pickIdentifier(user, exp.bucketBy);
1037
+ const sticky = uid ? this.bindSticky(name, uid) : void 0;
1038
+ return classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky);
1039
+ }
1040
+ /**
1041
+ * Assign `user` within `universeName`. A universe is a mutual-exclusion pool,
1042
+ * so a unit lands in **at most one** experiment; the returned {@link Assignment}
1043
+ * exposes the variant + resolved params and auto-logs a single exposure when
1044
+ * enrolled. An un-enrolled unit still resolves `get()` to the universe defaults.
1045
+ * Never throws. This is the sole experiment read path (there is no
1046
+ * `getExperiment` a caller asks a universe, not an experiment).
1047
+ */
1048
+ assignUniverse(universeName, user) {
1049
+ this.telemetry.emit("experiment", universeName);
1050
+ const paramDefaults = paramDefaultsFromSchema(
1051
+ this.expsBlob?.universes[universeName]?.param_schema
1052
+ );
1053
+ const notEnrolled = () => new AssignmentImpl(null, null, paramDefaults ?? {});
1054
+ if (!this.expsBlob) return notEnrolled();
1055
+ const candidates = Object.entries(this.expsBlob.experiments).filter(([, e]) => e.universe === universeName && e.status === "running").sort(
1056
+ (a, b) => (a[1].poolOffsetBp ?? 0) - (b[1].poolOffsetBp ?? 0) || a[0].localeCompare(b[0])
1057
+ );
1058
+ for (const [name, exp] of candidates) {
1059
+ const c = this.evalExperiment(name, exp, user);
1060
+ if (c.state === "group") {
1061
+ this.postExposure(user, name, c.group);
1062
+ return new AssignmentImpl(name, c.group, c.params ?? {});
909
1063
  }
910
1064
  }
911
- return notIn;
1065
+ return notEnrolled();
1066
+ }
1067
+ /**
1068
+ * The universe-first experiment read entry point:
1069
+ * `engine.universe("checkout").assign(user)`. Returns a reusable handle bound
1070
+ * to one universe; `assign(user)` picks the ≤1 experiment the unit is pooled
1071
+ * into and auto-logs a single exposure. See {@link assignUniverse}.
1072
+ */
1073
+ universe(name) {
1074
+ return { assign: (user) => this.assignUniverse(name, user) };
912
1075
  }
913
1076
  /** Drop caller-marked private attributes from an outbound props bag. */
914
1077
  stripPrivate(props) {
@@ -920,7 +1083,7 @@ var Engine = class _Engine {
920
1083
  return out;
921
1084
  }
922
1085
  track(userId, eventName, props) {
923
- if (this.testMode) return;
1086
+ if (this.offline) return;
924
1087
  const safeProps = this.stripPrivate(props);
925
1088
  const body = JSON.stringify({
926
1089
  events: [
@@ -940,26 +1103,26 @@ var Engine = class _Engine {
940
1103
  }).catch((err) => logger.warn("[shipeasy] track failed:", String(err)));
941
1104
  }
942
1105
  /**
943
- * Emit an exposure event for an experiment at the server-side decision point
944
- * (parity with the browser's auto-exposure). The server is stateless and
945
- * never auto-logs, so call this when you actually present the treatment.
946
- * Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
947
- * as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
948
- * `/collect`. No-op in test mode or when the user isn't enrolled.
1106
+ * POST a single exposure for an enrolled `(user, experiment, group)`. Deduped
1107
+ * per process (bounded set) so repeated `assign()` calls in one server don't
1108
+ * spam `/collect`. Fire-and-forget; no-op when the network is disabled. This
1109
+ * is how `assignUniverse` auto-logs the browser's auto-exposure parity for SSR.
949
1110
  */
950
- logExposure(user, name) {
951
- if (this.testMode) return;
952
- const u = typeof user === "string" ? { user_id: user } : user;
953
- const result = this.getExperiment(name, u, {});
954
- if (!result.inExperiment) return;
1111
+ postExposure(user, experiment, group) {
1112
+ if (this.offline) return;
1113
+ const uid = user.user_id ?? user.anonymous_id;
1114
+ const dedupKey = `${uid ?? ""}:${experiment}:${group}`;
1115
+ if (this.exposureSeen.has(dedupKey)) return;
1116
+ if (this.exposureSeen.size > 5e3) this.exposureSeen.clear();
1117
+ this.exposureSeen.add(dedupKey);
955
1118
  const body = JSON.stringify({
956
1119
  events: [
957
1120
  {
958
1121
  type: "exposure",
959
- experiment: name,
960
- group: result.group,
961
- ...u.user_id !== void 0 ? { user_id: u.user_id } : {},
962
- ...u.anonymous_id !== void 0 ? { anonymous_id: u.anonymous_id } : {},
1122
+ experiment,
1123
+ group,
1124
+ ...user.user_id !== void 0 ? { user_id: user.user_id } : {},
1125
+ ...user.anonymous_id !== void 0 ? { anonymous_id: user.anonymous_id } : {},
963
1126
  ts: Date.now()
964
1127
  }
965
1128
  ]
@@ -968,7 +1131,7 @@ var Engine = class _Engine {
968
1131
  method: "POST",
969
1132
  headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
970
1133
  body
971
- }).catch((err) => logger.warn("[shipeasy] logExposure failed:", String(err)));
1134
+ }).catch((err) => logger.warn("[shipeasy] exposure send failed:", String(err)));
972
1135
  }
973
1136
  /**
974
1137
  * Report a structured error into the errors primitive. Fire-and-forget —
@@ -976,6 +1139,7 @@ var Engine = class _Engine {
976
1139
  * dedup window + per-process cap.
977
1140
  */
978
1141
  reportError(problem, consequence, extras, kind) {
1142
+ if (this.offline) return;
979
1143
  try {
980
1144
  const correlationId = seeContext.getStore()?.correlationId;
981
1145
  const ev = buildSeeEvent(problem, consequence, extras, {
@@ -1014,8 +1178,17 @@ var Engine = class _Engine {
1014
1178
  this.telemetry.emit("config", name);
1015
1179
  configs[name] = entry.value;
1016
1180
  }
1017
- for (const [name] of Object.entries(this.expsBlob?.experiments ?? {})) {
1018
- experiments[name] = this.getExperiment(name, user, {});
1181
+ const universes = {};
1182
+ for (const [name, exp] of Object.entries(this.expsBlob?.experiments ?? {})) {
1183
+ this.telemetry.emit("experiment", exp.universe);
1184
+ const uniName = exp.universe;
1185
+ if (!(uniName in universes)) {
1186
+ universes[uniName] = {
1187
+ defaults: paramDefaultsFromSchema(this.expsBlob?.universes[uniName]?.param_schema) ?? {}
1188
+ };
1189
+ }
1190
+ const c = this.evalExperiment(name, exp, user);
1191
+ experiments[name] = c.state === "group" ? { inExperiment: true, group: c.group, params: c.params ?? {}, universe: uniName } : { inExperiment: false, group: "control", params: {}, universe: uniName };
1019
1192
  }
1020
1193
  for (const [name, ks] of Object.entries(this.flagsBlob?.killswitches ?? {})) {
1021
1194
  this.telemetry.emit("ks", name);
@@ -1032,10 +1205,11 @@ var Engine = class _Engine {
1032
1205
  Object.assign(flags2, ov.gates);
1033
1206
  Object.assign(configs, ov.configs);
1034
1207
  for (const [name, group] of Object.entries(ov.experiments)) {
1035
- experiments[name] = { inExperiment: true, group, params: {} };
1208
+ const uni = this.expsBlob?.experiments[name]?.universe;
1209
+ experiments[name] = { inExperiment: true, group, params: {}, ...uni ? { universe: uni } : {} };
1036
1210
  }
1037
1211
  }
1038
- return { flags: flags2, configs, experiments, killswitches };
1212
+ return { flags: flags2, configs, experiments, killswitches, universes };
1039
1213
  }
1040
1214
  getKillswitch(name, switchKey) {
1041
1215
  this.telemetry.emit("ks", name);
@@ -1182,7 +1356,9 @@ async function shipeasy(opts) {
1182
1356
  const profile = opts.i18nDefaultProfile ?? "en:prod";
1183
1357
  flags.configure({
1184
1358
  apiKey: serverKey,
1359
+ isNetworkEnabled: opts.isNetworkEnabled,
1185
1360
  disableTelemetry: opts.disableTelemetry,
1361
+ disableInternalErrorReporting: opts.disableInternalErrorReporting,
1186
1362
  privateAttributes: opts.privateAttributes,
1187
1363
  logLevel: opts.logLevel
1188
1364
  });
@@ -1327,13 +1503,22 @@ var flags = {
1327
1503
  () => _server?.getConfig(name, decodeOrOpts)
1328
1504
  );
1329
1505
  },
1330
- getExperiment(name, user, defaultParams, decode) {
1331
- const notIn = { inExperiment: false, group: "control", params: defaultParams };
1332
- return safeRun(
1333
- "flags.getExperiment",
1334
- notIn,
1335
- () => _server?.getExperiment(name, user, defaultParams, decode) ?? notIn
1336
- );
1506
+ /**
1507
+ * Assign `user` within a universe: `flags.universe("checkout").assign(user)`.
1508
+ * A universe is a mutual-exclusion pool, so the unit lands in ≤1 experiment;
1509
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
1510
+ * and auto-logs one exposure when enrolled. Before configure() (or on any
1511
+ * error) it returns a safe not-enrolled handle. This replaces the removed
1512
+ * `getExperiment` — read experiments by universe, never by name.
1513
+ */
1514
+ universe(name) {
1515
+ return {
1516
+ assign: (user) => safeRun(
1517
+ "flags.universe.assign",
1518
+ new AssignmentImpl(null, null, {}),
1519
+ () => _server?.assignUniverse(name, user) ?? new AssignmentImpl(null, null, {})
1520
+ )
1521
+ };
1337
1522
  },
1338
1523
  /**
1339
1524
  * Read a killswitch. Without `switchKey`, returns true when the whole
@@ -1346,11 +1531,6 @@ var flags = {
1346
1531
  track(userId, eventName, props) {
1347
1532
  safeRun("flags.track", void 0, () => _server?.track(userId, eventName, props));
1348
1533
  },
1349
- /** Emit an exposure for an enrolled experiment at the decision point. See
1350
- * {@link Engine.logExposure}. No-op before configure(). */
1351
- logExposure(user, name) {
1352
- safeRun("flags.logExposure", void 0, () => _server?.logExposure(user, name));
1353
- },
1354
1534
  /**
1355
1535
  * Evaluate all flags / configs / experiments for a user against the locally
1356
1536
  * cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
@@ -1467,13 +1647,20 @@ var Client = class {
1467
1647
  () => this.engine.getConfig(name, decodeOrOpts)
1468
1648
  );
1469
1649
  }
1470
- getExperiment(name, defaultParams, decode) {
1471
- const notIn = { inExperiment: false, group: "control", params: defaultParams };
1472
- return safeRun(
1473
- "Client.getExperiment",
1474
- notIn,
1475
- () => this.engine.getExperiment(name, this.attributes, defaultParams, decode)
1476
- );
1650
+ /**
1651
+ * Assign the bound user within a universe: `client.universe("checkout").assign()`.
1652
+ * The user is already bound at construction, so `assign()` takes no arg. Returns
1653
+ * an {@link Assignment} (`.group` / `.get(field, fallback)`) and auto-logs one
1654
+ * exposure when enrolled. Replaces the removed `getExperiment`.
1655
+ */
1656
+ universe(name) {
1657
+ return {
1658
+ assign: () => safeRun(
1659
+ "Client.universe.assign",
1660
+ new AssignmentImpl(null, null, {}),
1661
+ () => this.engine.assignUniverse(name, this.attributes)
1662
+ )
1663
+ };
1477
1664
  }
1478
1665
  /** Read a killswitch (not user-bound; mirrors {@link Engine.getKillswitch}). */
1479
1666
  getKillswitch(name, switchKey) {
@@ -1493,15 +1680,6 @@ var Client = class {
1493
1680
  this.engine.track(String(id), eventName, props);
1494
1681
  });
1495
1682
  }
1496
- /**
1497
- * Emit an exposure event for `name` at this server-side decision point for the
1498
- * bound user. Delegates to {@link Engine.logExposure} with the resolved
1499
- * attribute bag (re-evaluates enrolment; no-op when the user isn't enrolled or
1500
- * in test mode).
1501
- */
1502
- logExposure(name) {
1503
- safeRun("Client.logExposure", void 0, () => this.engine.logExposure(this.attributes, name));
1504
- }
1505
1683
  };
1506
1684
  function dispatchSee(problem, consequence, extras, kind) {
1507
1685
  if (!_server) {