@shipeasy/sdk 6.2.0 → 7.0.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.
@@ -34,6 +34,7 @@ __export(server_exports, {
34
34
  Client: () => Client,
35
35
  Engine: () => Engine,
36
36
  FLAG_REASONS: () => FLAG_REASONS,
37
+ LOG_LEVELS: () => LOG_LEVELS,
37
38
  _murmur3ForTests: () => _murmur3ForTests,
38
39
  _resetConfigureForTests: () => _resetConfigureForTests,
39
40
  _resetShipeasyServerForTests: () => _resetShipeasyServerForTests,
@@ -238,7 +239,7 @@ function captureCallsiteStack() {
238
239
  const kept = lines.slice(1).filter((l) => !/@shipeasy[\\/]sdk|see[\\/]core|captureCallsiteStack|\bsee\b\s*\(/.test(l));
239
240
  return kept.length ? kept.join("\n") : void 0;
240
241
  }
241
- function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlationId) {
242
+ function buildSeeEvent(problem, consequence, extras, ctx2, kindOverride, correlationId) {
242
243
  let errorType;
243
244
  let message;
244
245
  let stack;
@@ -266,8 +267,8 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
266
267
  message: truncate(message, SEE_MAX_MESSAGE),
267
268
  subject: consequence.subject,
268
269
  outcome: consequence.outcome,
269
- side: ctx.side,
270
- sdk_version: ctx.sdkVersion,
270
+ side: ctx2.side,
271
+ sdk_version: ctx2.sdkVersion,
271
272
  ts: Date.now()
272
273
  };
273
274
  if (stack) ev.stack = truncate(stack, SEE_MAX_STACK);
@@ -276,10 +277,10 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
276
277
  if (causedBy) ev.caused_by = causedBy;
277
278
  const cleanExtras = sanitizeExtras(extras);
278
279
  if (cleanExtras) ev.extras = cleanExtras;
279
- if (ctx.url) ev.url = truncate(ctx.url, SEE_MAX_SUBJECT);
280
- if (ctx.userId) ev.user_id = ctx.userId;
281
- if (ctx.anonId) ev.anonymous_id = ctx.anonId;
282
- if (ctx.env) ev.env = ctx.env;
280
+ if (ctx2.url) ev.url = truncate(ctx2.url, SEE_MAX_SUBJECT);
281
+ if (ctx2.userId) ev.user_id = ctx2.userId;
282
+ if (ctx2.anonId) ev.anonymous_id = ctx2.anonId;
283
+ if (ctx2.env) ev.env = ctx2.env;
283
284
  markReported(problem, ev);
284
285
  return ev;
285
286
  }
@@ -372,6 +373,98 @@ var SeeLimiter = class {
372
373
  }
373
374
  };
374
375
 
376
+ // src/internal-report.ts
377
+ var INGEST_URL = "https://api.shipeasy.ai/collect";
378
+ var PLACEHOLDER_KEY = "sdk_client_REPLACE_WITH_SHIPEASY_INTERNAL_ERROR_KEY";
379
+ var INGEST_KEY = "sdk_client_00bd4608a03e4084922978f9522614d5";
380
+ function keyConfigured() {
381
+ return !!INGEST_KEY && INGEST_KEY !== PLACEHOLDER_KEY;
382
+ }
383
+ var OUTCOME = "returned a safe default";
384
+ var SDK_ID = "ts";
385
+ var ctx = null;
386
+ var limiter = new SeeLimiter();
387
+ function setInternalReportContext(c) {
388
+ ctx = { side: c.side, sdkVersion: c.sdkVersion, enabled: c.enabled !== false };
389
+ }
390
+ function reportInternalError(label, err) {
391
+ try {
392
+ if (!ctx || !ctx.enabled || !keyConfigured()) return;
393
+ const ev = buildSeeEvent(
394
+ err,
395
+ causesThe(label).to(OUTCOME),
396
+ { sdk: SDK_ID },
397
+ { side: ctx.side, sdkVersion: ctx.sdkVersion },
398
+ "caught"
399
+ );
400
+ if (!limiter.shouldSend(ev)) return;
401
+ send2(INGEST_URL, INGEST_KEY, JSON.stringify({ events: [ev] }));
402
+ } catch {
403
+ }
404
+ }
405
+ function send2(url, key, body) {
406
+ try {
407
+ const f = globalThis.fetch;
408
+ if (typeof f !== "function") return;
409
+ void f(url, {
410
+ method: "POST",
411
+ headers: { "X-SDK-Key": key, "Content-Type": "text/plain" },
412
+ body,
413
+ keepalive: true
414
+ }).catch(() => {
415
+ });
416
+ } catch {
417
+ }
418
+ }
419
+
420
+ // src/logger.ts
421
+ var LOG_LEVELS = ["silent", "error", "warn", "info", "debug"];
422
+ var RANK = {
423
+ silent: 0,
424
+ error: 1,
425
+ warn: 2,
426
+ info: 3,
427
+ debug: 4
428
+ };
429
+ var currentLevel = "warn";
430
+ function setLogLevel(level) {
431
+ if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
432
+ currentLevel = level;
433
+ }
434
+ }
435
+ function write(method, args) {
436
+ try {
437
+ const c = globalThis.console;
438
+ if (!c) return;
439
+ const fn = c[method] ?? c.log;
440
+ if (typeof fn === "function") fn.call(c, ...args);
441
+ } catch {
442
+ }
443
+ }
444
+ var logger = {
445
+ error(...args) {
446
+ if (RANK[currentLevel] >= RANK.error) write("error", args);
447
+ },
448
+ warn(...args) {
449
+ if (RANK[currentLevel] >= RANK.warn) write("warn", args);
450
+ },
451
+ info(...args) {
452
+ if (RANK[currentLevel] >= RANK.info) write("info", args);
453
+ },
454
+ debug(...args) {
455
+ if (RANK[currentLevel] >= RANK.debug) write("debug", args);
456
+ }
457
+ };
458
+ function safeRun(label, fallback, fn) {
459
+ try {
460
+ return fn();
461
+ } catch (err) {
462
+ logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
463
+ reportInternalError(label, err);
464
+ return fallback;
465
+ }
466
+ }
467
+
375
468
  // src/server/index.ts
376
469
  var version = "4.0.0";
377
470
  var C1 = 3432918353;
@@ -508,6 +601,78 @@ function evalGateInternal(gate, user) {
508
601
  if (!uid) return gate.rolloutPct >= 1e4;
509
602
  return murmur3(`${gate.salt}:${uid}`) % 1e4 < gate.rolloutPct;
510
603
  }
604
+ function paramDefaultsFromSchema(schema) {
605
+ if (!schema || schema.length === 0) return null;
606
+ const out = {};
607
+ for (const p of schema) out[p.name] = p.default;
608
+ return out;
609
+ }
610
+ function mergeParams(paramDefaults, groupParams) {
611
+ return paramDefaults ? { ...paramDefaults, ...groupParams } : { ...groupParams };
612
+ }
613
+ function classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky) {
614
+ const asGroup = (g) => ({
615
+ state: "group",
616
+ group: g.name,
617
+ params: mergeParams(paramDefaults, g.params)
618
+ });
619
+ if (exp.targetingGate && !evalGate(exp.targetingGate)) return { state: "out" };
620
+ const uid = pickIdentifier(user, exp.bucketBy);
621
+ if (!uid) return { state: "out" };
622
+ const universeSeg = murmur3(`${exp.universe}:${uid}`) % 1e4;
623
+ if (holdoutRange) {
624
+ const [lo, hi] = holdoutRange;
625
+ if (universeSeg >= lo && universeSeg <= hi) return { state: "holdout" };
626
+ }
627
+ if (exp.holdoutGate && evalGate(exp.holdoutGate)) return { state: "holdout" };
628
+ const salt8 = exp.salt.slice(0, 8);
629
+ if (sticky) {
630
+ const entry = sticky.get();
631
+ if (entry && entry.s === salt8) {
632
+ const g = exp.groups.find((x) => x.name === entry.g);
633
+ if (g) return asGroup(g);
634
+ }
635
+ }
636
+ const pooled = (exp.hashVersion ?? 1) >= 2 && exp.poolOffsetBp != null && exp.poolSizeBp != null && exp.poolSizeBp > 0;
637
+ if (pooled) {
638
+ const lo = exp.poolOffsetBp;
639
+ const hi = lo + exp.poolSizeBp;
640
+ if (universeSeg < lo || universeSeg >= hi) return { state: "out" };
641
+ } else {
642
+ if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return { state: "out" };
643
+ }
644
+ const reserved = Math.max(0, Math.min(1e4, exp.reservedHeadroomBp ?? 0));
645
+ const usable = 1e4 - reserved;
646
+ const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
647
+ if (groupHash >= usable) return { state: "out" };
648
+ let cumulative = 0;
649
+ for (let i = 0; i < exp.groups.length; i++) {
650
+ const g = exp.groups[i];
651
+ cumulative += g.weight;
652
+ if (groupHash < cumulative || i === exp.groups.length - 1) {
653
+ sticky?.set({ g: g.name, s: salt8 });
654
+ return asGroup(g);
655
+ }
656
+ }
657
+ return { state: "out" };
658
+ }
659
+ var AssignmentImpl = class {
660
+ constructor(name, group, params) {
661
+ this.name = name;
662
+ this.group = group;
663
+ this.params = params;
664
+ }
665
+ name;
666
+ group;
667
+ params;
668
+ get enrolled() {
669
+ return this.group !== null;
670
+ }
671
+ get(field, fallback) {
672
+ const v = this.params[field];
673
+ return v === void 0 ? fallback : v;
674
+ }
675
+ };
511
676
  var TRUE_RX = /^(true|on|1|yes)$/i;
512
677
  var FALSE_RX = /^(false|off|0|no)$/i;
513
678
  function parseOverrideBool(raw) {
@@ -578,16 +743,25 @@ var Engine = class _Engine {
578
743
  flagOverrides = /* @__PURE__ */ new Map();
579
744
  configOverrides = /* @__PURE__ */ new Map();
580
745
  experimentOverrides = /* @__PURE__ */ new Map();
746
+ // Bounded per-process exposure dedup (`uid:exp:group`) so auto-exposure from
747
+ // repeated assign() calls doesn't spam /collect. Cleared past a soft cap.
748
+ exposureSeen = /* @__PURE__ */ new Set();
581
749
  // Change listeners fired after a background poll returns NEW data (200, not
582
750
  // 304). Never fired in testMode/offline (no polling happens there).
583
751
  changeListeners = /* @__PURE__ */ new Set();
584
752
  constructor(opts) {
753
+ setLogLevel(opts.logLevel);
585
754
  this.apiKey = opts.apiKey;
586
755
  this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
587
756
  this.env = opts.env ?? "prod";
588
757
  this.privateAttributes = opts.privateAttributes ?? [];
589
758
  this.stickyStore = opts.stickyStore;
590
759
  this.testMode = opts.testMode === true;
760
+ setInternalReportContext({
761
+ side: "server",
762
+ sdkVersion: version,
763
+ enabled: !this.testMode && opts.disableInternalErrorReporting !== true
764
+ });
591
765
  this.telemetry = new Telemetry({
592
766
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
593
767
  sdkKey: this.apiKey,
@@ -705,14 +879,14 @@ var Engine = class _Engine {
705
879
  try {
706
880
  l();
707
881
  } catch (err) {
708
- console.warn("[shipeasy] onChange listener threw:", String(err));
882
+ logger.warn("[shipeasy] onChange listener threw:", String(err));
709
883
  }
710
884
  }
711
885
  }
712
886
  startPoll() {
713
887
  this.timer = setInterval(() => {
714
888
  this.fetchAll(true).catch(
715
- (err) => console.warn("[shipeasy] background poll failed:", String(err))
889
+ (err) => logger.warn("[shipeasy] background poll failed:", String(err))
716
890
  );
717
891
  }, this.pollInterval * 1e3);
718
892
  }
@@ -791,70 +965,84 @@ var Engine = class _Engine {
791
965
  return "defaultValue" in opts ? opts.defaultValue : void 0;
792
966
  }
793
967
  if (!opts.decode) return raw;
794
- return opts.decode(raw);
795
- }
796
- getExperiment(name, user, defaultParams, decode) {
797
- this.telemetry.emit("experiment", name);
798
- const notIn = {
799
- inExperiment: false,
800
- group: "control",
801
- params: defaultParams
968
+ try {
969
+ return opts.decode(raw);
970
+ } catch (err) {
971
+ logger.warn(`[shipeasy] getConfig('${name}') decode failed:`, String(err));
972
+ return "defaultValue" in opts ? opts.defaultValue : void 0;
973
+ }
974
+ }
975
+ /**
976
+ * Bind the per-experiment sticky seam over the configured {@link StickyBucketStore}
977
+ * for one (unit, experiment). Absent store ⇒ deterministic (no sticky).
978
+ */
979
+ bindSticky(name, uid) {
980
+ const store = this.stickyStore;
981
+ if (!store) return void 0;
982
+ return {
983
+ get: () => store.get(uid)?.[name],
984
+ set: (entry) => store.set(uid, name, entry)
802
985
  };
986
+ }
987
+ /**
988
+ * Evaluate one experiment by name for `user` — override → full classify
989
+ * pipeline (targeting → universe holdout → holdout gate → sticky → allocation
990
+ * → group), merging the universe defaults under the assigned variant (§B2).
991
+ * Internal: the public surface is `universe(name).assign(user)`. Reused by the
992
+ * SSR `evaluate()` bootstrap (keyed by experiment name) and by `assignUniverse`.
993
+ */
994
+ evalExperiment(name, exp, user) {
995
+ const paramDefaults = paramDefaultsFromSchema(
996
+ this.expsBlob?.universes[exp.universe]?.param_schema
997
+ );
803
998
  const ov = this.experimentOverrides.get(name);
804
- if (ov) {
805
- if (!decode) return { inExperiment: true, group: ov.group, params: ov.params };
806
- try {
807
- return { inExperiment: true, group: ov.group, params: decode(ov.params) };
808
- } catch (err) {
809
- console.warn(`[shipeasy] getExperiment('${name}') override decode failed:`, String(err));
810
- return notIn;
811
- }
812
- }
813
- if (!this.flagsBlob || !this.expsBlob) return notIn;
814
- const exp = this.expsBlob.experiments[name];
815
- if (!exp || exp.status !== "running") return notIn;
816
- if (exp.targetingGate) {
817
- const gate = this.flagsBlob.gates[exp.targetingGate];
818
- if (!gate || !evalGateInternal(gate, user)) return notIn;
819
- }
820
- const uid = pickIdentifier(user, exp.bucketBy);
821
- if (!uid) return notIn;
822
- const universe = this.expsBlob.universes[exp.universe];
823
- const holdoutRange = universe?.holdout_range ?? null;
824
- if (holdoutRange) {
825
- const seg = murmur3(`${exp.universe}:${uid}`) % 1e4;
826
- const [lo, hi] = holdoutRange;
827
- if (seg >= lo && seg <= hi) return notIn;
828
- }
829
- const asResult = (g) => {
830
- if (!decode) return { inExperiment: true, group: g.name, params: g.params };
831
- try {
832
- return { inExperiment: true, group: g.name, params: decode(g.params) };
833
- } catch (err) {
834
- console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
835
- return notIn;
836
- }
999
+ if (ov) return { state: "group", group: ov.group, params: mergeParams(paramDefaults, ov.params) };
1000
+ if (!this.flagsBlob || !this.expsBlob) return { state: "out" };
1001
+ if (exp.status !== "running") return { state: "out" };
1002
+ const holdoutRange = this.expsBlob.universes[exp.universe]?.holdout_range ?? null;
1003
+ const evalGate = (gname) => {
1004
+ const gate = this.flagsBlob?.gates[gname];
1005
+ return gate ? evalGateInternal(gate, user) : false;
837
1006
  };
838
- const salt8 = exp.salt.slice(0, 8);
839
- if (this.stickyStore) {
840
- const entry = this.stickyStore.get(uid)?.[name];
841
- if (entry && entry.s === salt8) {
842
- const g = exp.groups.find((x) => x.name === entry.g);
843
- if (g) return asResult(g);
844
- }
845
- }
846
- if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return notIn;
847
- const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
848
- let cumulative = 0;
849
- for (let i = 0; i < exp.groups.length; i++) {
850
- const g = exp.groups[i];
851
- cumulative += g.weight;
852
- if (groupHash < cumulative || i === exp.groups.length - 1) {
853
- this.stickyStore?.set(uid, name, { g: g.name, s: salt8 });
854
- return asResult(g);
1007
+ const uid = pickIdentifier(user, exp.bucketBy);
1008
+ const sticky = uid ? this.bindSticky(name, uid) : void 0;
1009
+ return classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky);
1010
+ }
1011
+ /**
1012
+ * Assign `user` within `universeName`. A universe is a mutual-exclusion pool,
1013
+ * so a unit lands in **at most one** experiment; the returned {@link Assignment}
1014
+ * exposes the variant + resolved params and auto-logs a single exposure when
1015
+ * enrolled. An un-enrolled unit still resolves `get()` to the universe defaults.
1016
+ * Never throws. This is the sole experiment read path (there is no
1017
+ * `getExperiment` a caller asks a universe, not an experiment).
1018
+ */
1019
+ assignUniverse(universeName, user) {
1020
+ this.telemetry.emit("experiment", universeName);
1021
+ const paramDefaults = paramDefaultsFromSchema(
1022
+ this.expsBlob?.universes[universeName]?.param_schema
1023
+ );
1024
+ const notEnrolled = () => new AssignmentImpl(null, null, paramDefaults ?? {});
1025
+ if (!this.expsBlob) return notEnrolled();
1026
+ const candidates = Object.entries(this.expsBlob.experiments).filter(([, e]) => e.universe === universeName && e.status === "running").sort(
1027
+ (a, b) => (a[1].poolOffsetBp ?? 0) - (b[1].poolOffsetBp ?? 0) || a[0].localeCompare(b[0])
1028
+ );
1029
+ for (const [name, exp] of candidates) {
1030
+ const c = this.evalExperiment(name, exp, user);
1031
+ if (c.state === "group") {
1032
+ this.postExposure(user, name, c.group);
1033
+ return new AssignmentImpl(name, c.group, c.params ?? {});
855
1034
  }
856
1035
  }
857
- return notIn;
1036
+ return notEnrolled();
1037
+ }
1038
+ /**
1039
+ * The universe-first experiment read entry point:
1040
+ * `engine.universe("checkout").assign(user)`. Returns a reusable handle bound
1041
+ * to one universe; `assign(user)` picks the ≤1 experiment the unit is pooled
1042
+ * into and auto-logs a single exposure. See {@link assignUniverse}.
1043
+ */
1044
+ universe(name) {
1045
+ return { assign: (user) => this.assignUniverse(name, user) };
858
1046
  }
859
1047
  /** Drop caller-marked private attributes from an outbound props bag. */
860
1048
  stripPrivate(props) {
@@ -883,29 +1071,29 @@ var Engine = class _Engine {
883
1071
  method: "POST",
884
1072
  headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
885
1073
  body
886
- }).catch((err) => console.warn("[shipeasy] track failed:", String(err)));
1074
+ }).catch((err) => logger.warn("[shipeasy] track failed:", String(err)));
887
1075
  }
888
1076
  /**
889
- * Emit an exposure event for an experiment at the server-side decision point
890
- * (parity with the browser's auto-exposure). The server is stateless and
891
- * never auto-logs, so call this when you actually present the treatment.
892
- * Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
893
- * as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
894
- * `/collect`. No-op in test mode or when the user isn't enrolled.
1077
+ * POST a single exposure for an enrolled `(user, experiment, group)`. Deduped
1078
+ * per process (bounded set) so repeated `assign()` calls in one server don't
1079
+ * spam `/collect`. Fire-and-forget; no-op in test mode. This is how
1080
+ * `assignUniverse` auto-logs the browser's auto-exposure parity for SSR.
895
1081
  */
896
- logExposure(user, name) {
1082
+ postExposure(user, experiment, group) {
897
1083
  if (this.testMode) return;
898
- const u = typeof user === "string" ? { user_id: user } : user;
899
- const result = this.getExperiment(name, u, {});
900
- if (!result.inExperiment) return;
1084
+ const uid = user.user_id ?? user.anonymous_id;
1085
+ const dedupKey = `${uid ?? ""}:${experiment}:${group}`;
1086
+ if (this.exposureSeen.has(dedupKey)) return;
1087
+ if (this.exposureSeen.size > 5e3) this.exposureSeen.clear();
1088
+ this.exposureSeen.add(dedupKey);
901
1089
  const body = JSON.stringify({
902
1090
  events: [
903
1091
  {
904
1092
  type: "exposure",
905
- experiment: name,
906
- group: result.group,
907
- ...u.user_id !== void 0 ? { user_id: u.user_id } : {},
908
- ...u.anonymous_id !== void 0 ? { anonymous_id: u.anonymous_id } : {},
1093
+ experiment,
1094
+ group,
1095
+ ...user.user_id !== void 0 ? { user_id: user.user_id } : {},
1096
+ ...user.anonymous_id !== void 0 ? { anonymous_id: user.anonymous_id } : {},
909
1097
  ts: Date.now()
910
1098
  }
911
1099
  ]
@@ -914,7 +1102,7 @@ var Engine = class _Engine {
914
1102
  method: "POST",
915
1103
  headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
916
1104
  body
917
- }).catch((err) => console.warn("[shipeasy] logExposure failed:", String(err)));
1105
+ }).catch((err) => logger.warn("[shipeasy] exposure send failed:", String(err)));
918
1106
  }
919
1107
  /**
920
1108
  * Report a structured error into the errors primitive. Fire-and-forget —
@@ -934,7 +1122,7 @@ var Engine = class _Engine {
934
1122
  method: "POST",
935
1123
  headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
936
1124
  body: JSON.stringify({ events: [ev] })
937
- }).catch((err) => console.warn("[shipeasy] see() send failed:", String(err)));
1125
+ }).catch((err) => logger.warn("[shipeasy] see() send failed:", String(err)));
938
1126
  } catch {
939
1127
  }
940
1128
  }
@@ -960,8 +1148,17 @@ var Engine = class _Engine {
960
1148
  this.telemetry.emit("config", name);
961
1149
  configs[name] = entry.value;
962
1150
  }
963
- for (const [name] of Object.entries(this.expsBlob?.experiments ?? {})) {
964
- experiments[name] = this.getExperiment(name, user, {});
1151
+ const universes = {};
1152
+ for (const [name, exp] of Object.entries(this.expsBlob?.experiments ?? {})) {
1153
+ this.telemetry.emit("experiment", exp.universe);
1154
+ const uniName = exp.universe;
1155
+ if (!(uniName in universes)) {
1156
+ universes[uniName] = {
1157
+ defaults: paramDefaultsFromSchema(this.expsBlob?.universes[uniName]?.param_schema) ?? {}
1158
+ };
1159
+ }
1160
+ const c = this.evalExperiment(name, exp, user);
1161
+ experiments[name] = c.state === "group" ? { inExperiment: true, group: c.group, params: c.params ?? {}, universe: uniName } : { inExperiment: false, group: "control", params: {}, universe: uniName };
965
1162
  }
966
1163
  for (const [name, ks] of Object.entries(this.flagsBlob?.killswitches ?? {})) {
967
1164
  this.telemetry.emit("ks", name);
@@ -978,10 +1175,11 @@ var Engine = class _Engine {
978
1175
  Object.assign(flags2, ov.gates);
979
1176
  Object.assign(configs, ov.configs);
980
1177
  for (const [name, group] of Object.entries(ov.experiments)) {
981
- experiments[name] = { inExperiment: true, group, params: {} };
1178
+ const uni = this.expsBlob?.experiments[name]?.universe;
1179
+ experiments[name] = { inExperiment: true, group, params: {}, ...uni ? { universe: uni } : {} };
982
1180
  }
983
1181
  }
984
- return { flags: flags2, configs, experiments, killswitches };
1182
+ return { flags: flags2, configs, experiments, killswitches, universes };
985
1183
  }
986
1184
  getKillswitch(name, switchKey) {
987
1185
  this.telemetry.emit("ks", name);
@@ -1121,7 +1319,7 @@ function _resetShipeasyServerForTests() {
1121
1319
  async function shipeasy(opts) {
1122
1320
  const serverKey = opts.serverKey ?? "";
1123
1321
  if (!serverKey) {
1124
- console.error(
1322
+ logger.error(
1125
1323
  "[shipeasy] No server key \u2014 flags, experiments and SSR i18n skipped. Pass `serverKey` to shipeasy() from @shipeasy/sdk/server with your server key (SHIPEASY_SERVER_KEY). Set it as a Worker secret with `wrangler secret put SHIPEASY_SERVER_KEY` (or add it to .env for local dev). Do not pass a client key here \u2014 the server entrypoint only accepts the server key."
1126
1324
  );
1127
1325
  }
@@ -1129,7 +1327,9 @@ async function shipeasy(opts) {
1129
1327
  flags.configure({
1130
1328
  apiKey: serverKey,
1131
1329
  disableTelemetry: opts.disableTelemetry,
1132
- privateAttributes: opts.privateAttributes
1330
+ disableInternalErrorReporting: opts.disableInternalErrorReporting,
1331
+ privateAttributes: opts.privateAttributes,
1332
+ logLevel: opts.logLevel
1133
1333
  });
1134
1334
  let resolvedUrlOverrides = opts.urlOverrides;
1135
1335
  if (!resolvedUrlOverrides) {
@@ -1255,20 +1455,38 @@ var flags = {
1255
1455
  _server?.destroy();
1256
1456
  },
1257
1457
  get(name, user, defaultValue = false) {
1258
- return _server?.getFlag(name, user, defaultValue) ?? defaultValue;
1458
+ return safeRun("flags.get", defaultValue, () => _server?.getFlag(name, user, defaultValue) ?? defaultValue);
1259
1459
  },
1260
1460
  /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
1261
1461
  getDetail(name, user) {
1262
- return _server?.getFlagDetail(name, user) ?? { value: false, reason: "CLIENT_NOT_READY" };
1462
+ return safeRun(
1463
+ "flags.getDetail",
1464
+ { value: false, reason: "CLIENT_NOT_READY" },
1465
+ () => _server?.getFlagDetail(name, user) ?? { value: false, reason: "CLIENT_NOT_READY" }
1466
+ );
1263
1467
  },
1264
1468
  getConfig(name, decodeOrOpts) {
1265
- return _server?.getConfig(name, decodeOrOpts);
1469
+ return safeRun(
1470
+ "flags.getConfig",
1471
+ void 0,
1472
+ () => _server?.getConfig(name, decodeOrOpts)
1473
+ );
1266
1474
  },
1267
- getExperiment(name, user, defaultParams, decode) {
1268
- return _server?.getExperiment(name, user, defaultParams, decode) ?? {
1269
- inExperiment: false,
1270
- group: "control",
1271
- params: defaultParams
1475
+ /**
1476
+ * Assign `user` within a universe: `flags.universe("checkout").assign(user)`.
1477
+ * A universe is a mutual-exclusion pool, so the unit lands in ≤1 experiment;
1478
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
1479
+ * and auto-logs one exposure when enrolled. Before configure() (or on any
1480
+ * error) it returns a safe not-enrolled handle. This replaces the removed
1481
+ * `getExperiment` — read experiments by universe, never by name.
1482
+ */
1483
+ universe(name) {
1484
+ return {
1485
+ assign: (user) => safeRun(
1486
+ "flags.universe.assign",
1487
+ new AssignmentImpl(null, null, {}),
1488
+ () => _server?.assignUniverse(name, user) ?? new AssignmentImpl(null, null, {})
1489
+ )
1272
1490
  };
1273
1491
  },
1274
1492
  /**
@@ -1277,15 +1495,10 @@ var flags = {
1277
1495
  * switch is on. Unknown killswitches / switches return false.
1278
1496
  */
1279
1497
  ks(name, switchKey) {
1280
- return _server?.getKillswitch(name, switchKey) ?? false;
1498
+ return safeRun("flags.ks", false, () => _server?.getKillswitch(name, switchKey) ?? false);
1281
1499
  },
1282
1500
  track(userId, eventName, props) {
1283
- _server?.track(userId, eventName, props);
1284
- },
1285
- /** Emit an exposure for an enrolled experiment at the decision point. See
1286
- * {@link Engine.logExposure}. No-op before configure(). */
1287
- logExposure(user, name) {
1288
- _server?.logExposure(user, name);
1501
+ safeRun("flags.track", void 0, () => _server?.track(userId, eventName, props));
1289
1502
  },
1290
1503
  /**
1291
1504
  * Evaluate all flags / configs / experiments for a user against the locally
@@ -1293,12 +1506,8 @@ var flags = {
1293
1506
  * overrides. Returns an empty payload when the blob hasn't been fetched yet.
1294
1507
  */
1295
1508
  evaluate(user, rawUrl) {
1296
- return _server?.evaluate(user, rawUrl) ?? {
1297
- flags: {},
1298
- configs: {},
1299
- experiments: {},
1300
- killswitches: {}
1301
- };
1509
+ const empty = { flags: {}, configs: {}, experiments: {}, killswitches: {} };
1510
+ return safeRun("flags.evaluate", empty, () => _server?.evaluate(user, rawUrl) ?? empty);
1302
1511
  }
1303
1512
  };
1304
1513
  var _identityAttributes = (user) => user && typeof user === "object" ? user : {};
@@ -1387,20 +1596,44 @@ var Client = class {
1387
1596
  this.attributes = _attributes(user);
1388
1597
  }
1389
1598
  getFlag(name, defaultValue = false) {
1390
- return this.engine.getFlag(name, this.attributes, defaultValue);
1599
+ return safeRun(
1600
+ "Client.getFlag",
1601
+ defaultValue,
1602
+ () => this.engine.getFlag(name, this.attributes, defaultValue)
1603
+ );
1391
1604
  }
1392
1605
  getFlagDetail(name) {
1393
- return this.engine.getFlagDetail(name, this.attributes);
1606
+ return safeRun(
1607
+ "Client.getFlagDetail",
1608
+ { value: false, reason: "CLIENT_NOT_READY" },
1609
+ () => this.engine.getFlagDetail(name, this.attributes)
1610
+ );
1394
1611
  }
1395
1612
  getConfig(name, decodeOrOpts) {
1396
- return this.engine.getConfig(name, decodeOrOpts);
1613
+ return safeRun(
1614
+ "Client.getConfig",
1615
+ void 0,
1616
+ () => this.engine.getConfig(name, decodeOrOpts)
1617
+ );
1397
1618
  }
1398
- getExperiment(name, defaultParams, decode) {
1399
- return this.engine.getExperiment(name, this.attributes, defaultParams, decode);
1619
+ /**
1620
+ * Assign the bound user within a universe: `client.universe("checkout").assign()`.
1621
+ * The user is already bound at construction, so `assign()` takes no arg. Returns
1622
+ * an {@link Assignment} (`.group` / `.get(field, fallback)`) and auto-logs one
1623
+ * exposure when enrolled. Replaces the removed `getExperiment`.
1624
+ */
1625
+ universe(name) {
1626
+ return {
1627
+ assign: () => safeRun(
1628
+ "Client.universe.assign",
1629
+ new AssignmentImpl(null, null, {}),
1630
+ () => this.engine.assignUniverse(name, this.attributes)
1631
+ )
1632
+ };
1400
1633
  }
1401
1634
  /** Read a killswitch (not user-bound; mirrors {@link Engine.getKillswitch}). */
1402
1635
  getKillswitch(name, switchKey) {
1403
- return this.engine.getKillswitch(name, switchKey);
1636
+ return safeRun("Client.getKillswitch", false, () => this.engine.getKillswitch(name, switchKey));
1404
1637
  }
1405
1638
  /**
1406
1639
  * Record a conversion/metric event for the bound user. Derives the unit from
@@ -1410,23 +1643,16 @@ var Client = class {
1410
1643
  * mode.
1411
1644
  */
1412
1645
  track(eventName, props) {
1413
- const id = this.attributes.user_id ?? this.attributes.anonymous_id;
1414
- if (id === void 0) return;
1415
- this.engine.track(String(id), eventName, props);
1416
- }
1417
- /**
1418
- * Emit an exposure event for `name` at this server-side decision point for the
1419
- * bound user. Delegates to {@link Engine.logExposure} with the resolved
1420
- * attribute bag (re-evaluates enrolment; no-op when the user isn't enrolled or
1421
- * in test mode).
1422
- */
1423
- logExposure(name) {
1424
- this.engine.logExposure(this.attributes, name);
1646
+ safeRun("Client.track", void 0, () => {
1647
+ const id = this.attributes.user_id ?? this.attributes.anonymous_id;
1648
+ if (id === void 0) return;
1649
+ this.engine.track(String(id), eventName, props);
1650
+ });
1425
1651
  }
1426
1652
  };
1427
1653
  function dispatchSee(problem, consequence, extras, kind) {
1428
1654
  if (!_server) {
1429
- console.warn("[shipeasy] see() called before shipeasy({ serverKey }) \u2014 error dropped");
1655
+ logger.warn("[shipeasy] see() called before shipeasy({ serverKey }) \u2014 error dropped");
1430
1656
  return;
1431
1657
  }
1432
1658
  _server.reportError(problem, consequence, extras, kind);
@@ -1444,6 +1670,7 @@ var see = Object.assign(
1444
1670
  Client,
1445
1671
  Engine,
1446
1672
  FLAG_REASONS,
1673
+ LOG_LEVELS,
1447
1674
  _murmur3ForTests,
1448
1675
  _resetConfigureForTests,
1449
1676
  _resetShipeasyServerForTests,