@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.
@@ -184,7 +184,7 @@ function captureCallsiteStack() {
184
184
  const kept = lines.slice(1).filter((l) => !/@shipeasy[\\/]sdk|see[\\/]core|captureCallsiteStack|\bsee\b\s*\(/.test(l));
185
185
  return kept.length ? kept.join("\n") : void 0;
186
186
  }
187
- function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlationId) {
187
+ function buildSeeEvent(problem, consequence, extras, ctx2, kindOverride, correlationId) {
188
188
  let errorType;
189
189
  let message;
190
190
  let stack;
@@ -212,8 +212,8 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
212
212
  message: truncate(message, SEE_MAX_MESSAGE),
213
213
  subject: consequence.subject,
214
214
  outcome: consequence.outcome,
215
- side: ctx.side,
216
- sdk_version: ctx.sdkVersion,
215
+ side: ctx2.side,
216
+ sdk_version: ctx2.sdkVersion,
217
217
  ts: Date.now()
218
218
  };
219
219
  if (stack) ev.stack = truncate(stack, SEE_MAX_STACK);
@@ -222,10 +222,10 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
222
222
  if (causedBy) ev.caused_by = causedBy;
223
223
  const cleanExtras = sanitizeExtras(extras);
224
224
  if (cleanExtras) ev.extras = cleanExtras;
225
- if (ctx.url) ev.url = truncate(ctx.url, SEE_MAX_SUBJECT);
226
- if (ctx.userId) ev.user_id = ctx.userId;
227
- if (ctx.anonId) ev.anonymous_id = ctx.anonId;
228
- if (ctx.env) ev.env = ctx.env;
225
+ if (ctx2.url) ev.url = truncate(ctx2.url, SEE_MAX_SUBJECT);
226
+ if (ctx2.userId) ev.user_id = ctx2.userId;
227
+ if (ctx2.anonId) ev.anonymous_id = ctx2.anonId;
228
+ if (ctx2.env) ev.env = ctx2.env;
229
229
  markReported(problem, ev);
230
230
  return ev;
231
231
  }
@@ -318,6 +318,98 @@ var SeeLimiter = class {
318
318
  }
319
319
  };
320
320
 
321
+ // src/internal-report.ts
322
+ var INGEST_URL = "https://api.shipeasy.ai/collect";
323
+ var PLACEHOLDER_KEY = "sdk_client_REPLACE_WITH_SHIPEASY_INTERNAL_ERROR_KEY";
324
+ var INGEST_KEY = "sdk_client_00bd4608a03e4084922978f9522614d5";
325
+ function keyConfigured() {
326
+ return !!INGEST_KEY && INGEST_KEY !== PLACEHOLDER_KEY;
327
+ }
328
+ var OUTCOME = "returned a safe default";
329
+ var SDK_ID = "ts";
330
+ var ctx = null;
331
+ var limiter = new SeeLimiter();
332
+ function setInternalReportContext(c) {
333
+ ctx = { side: c.side, sdkVersion: c.sdkVersion, enabled: c.enabled !== false };
334
+ }
335
+ function reportInternalError(label, err) {
336
+ try {
337
+ if (!ctx || !ctx.enabled || !keyConfigured()) return;
338
+ const ev = buildSeeEvent(
339
+ err,
340
+ causesThe(label).to(OUTCOME),
341
+ { sdk: SDK_ID },
342
+ { side: ctx.side, sdkVersion: ctx.sdkVersion },
343
+ "caught"
344
+ );
345
+ if (!limiter.shouldSend(ev)) return;
346
+ send2(INGEST_URL, INGEST_KEY, JSON.stringify({ events: [ev] }));
347
+ } catch {
348
+ }
349
+ }
350
+ function send2(url, key, body) {
351
+ try {
352
+ const f = globalThis.fetch;
353
+ if (typeof f !== "function") return;
354
+ void f(url, {
355
+ method: "POST",
356
+ headers: { "X-SDK-Key": key, "Content-Type": "text/plain" },
357
+ body,
358
+ keepalive: true
359
+ }).catch(() => {
360
+ });
361
+ } catch {
362
+ }
363
+ }
364
+
365
+ // src/logger.ts
366
+ var LOG_LEVELS = ["silent", "error", "warn", "info", "debug"];
367
+ var RANK = {
368
+ silent: 0,
369
+ error: 1,
370
+ warn: 2,
371
+ info: 3,
372
+ debug: 4
373
+ };
374
+ var currentLevel = "warn";
375
+ function setLogLevel(level) {
376
+ if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
377
+ currentLevel = level;
378
+ }
379
+ }
380
+ function write(method, args) {
381
+ try {
382
+ const c = globalThis.console;
383
+ if (!c) return;
384
+ const fn = c[method] ?? c.log;
385
+ if (typeof fn === "function") fn.call(c, ...args);
386
+ } catch {
387
+ }
388
+ }
389
+ var logger = {
390
+ error(...args) {
391
+ if (RANK[currentLevel] >= RANK.error) write("error", args);
392
+ },
393
+ warn(...args) {
394
+ if (RANK[currentLevel] >= RANK.warn) write("warn", args);
395
+ },
396
+ info(...args) {
397
+ if (RANK[currentLevel] >= RANK.info) write("info", args);
398
+ },
399
+ debug(...args) {
400
+ if (RANK[currentLevel] >= RANK.debug) write("debug", args);
401
+ }
402
+ };
403
+ function safeRun(label, fallback, fn) {
404
+ try {
405
+ return fn();
406
+ } catch (err) {
407
+ logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
408
+ reportInternalError(label, err);
409
+ return fallback;
410
+ }
411
+ }
412
+
321
413
  // src/server/index.ts
322
414
  var version = "4.0.0";
323
415
  var C1 = 3432918353;
@@ -454,6 +546,78 @@ function evalGateInternal(gate, user) {
454
546
  if (!uid) return gate.rolloutPct >= 1e4;
455
547
  return murmur3(`${gate.salt}:${uid}`) % 1e4 < gate.rolloutPct;
456
548
  }
549
+ function paramDefaultsFromSchema(schema) {
550
+ if (!schema || schema.length === 0) return null;
551
+ const out = {};
552
+ for (const p of schema) out[p.name] = p.default;
553
+ return out;
554
+ }
555
+ function mergeParams(paramDefaults, groupParams) {
556
+ return paramDefaults ? { ...paramDefaults, ...groupParams } : { ...groupParams };
557
+ }
558
+ function classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky) {
559
+ const asGroup = (g) => ({
560
+ state: "group",
561
+ group: g.name,
562
+ params: mergeParams(paramDefaults, g.params)
563
+ });
564
+ if (exp.targetingGate && !evalGate(exp.targetingGate)) return { state: "out" };
565
+ const uid = pickIdentifier(user, exp.bucketBy);
566
+ if (!uid) return { state: "out" };
567
+ const universeSeg = murmur3(`${exp.universe}:${uid}`) % 1e4;
568
+ if (holdoutRange) {
569
+ const [lo, hi] = holdoutRange;
570
+ if (universeSeg >= lo && universeSeg <= hi) return { state: "holdout" };
571
+ }
572
+ if (exp.holdoutGate && evalGate(exp.holdoutGate)) return { state: "holdout" };
573
+ const salt8 = exp.salt.slice(0, 8);
574
+ if (sticky) {
575
+ const entry = sticky.get();
576
+ if (entry && entry.s === salt8) {
577
+ const g = exp.groups.find((x) => x.name === entry.g);
578
+ if (g) return asGroup(g);
579
+ }
580
+ }
581
+ const pooled = (exp.hashVersion ?? 1) >= 2 && exp.poolOffsetBp != null && exp.poolSizeBp != null && exp.poolSizeBp > 0;
582
+ if (pooled) {
583
+ const lo = exp.poolOffsetBp;
584
+ const hi = lo + exp.poolSizeBp;
585
+ if (universeSeg < lo || universeSeg >= hi) return { state: "out" };
586
+ } else {
587
+ if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return { state: "out" };
588
+ }
589
+ const reserved = Math.max(0, Math.min(1e4, exp.reservedHeadroomBp ?? 0));
590
+ const usable = 1e4 - reserved;
591
+ const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
592
+ if (groupHash >= usable) return { state: "out" };
593
+ let cumulative = 0;
594
+ for (let i = 0; i < exp.groups.length; i++) {
595
+ const g = exp.groups[i];
596
+ cumulative += g.weight;
597
+ if (groupHash < cumulative || i === exp.groups.length - 1) {
598
+ sticky?.set({ g: g.name, s: salt8 });
599
+ return asGroup(g);
600
+ }
601
+ }
602
+ return { state: "out" };
603
+ }
604
+ var AssignmentImpl = class {
605
+ constructor(name, group, params) {
606
+ this.name = name;
607
+ this.group = group;
608
+ this.params = params;
609
+ }
610
+ name;
611
+ group;
612
+ params;
613
+ get enrolled() {
614
+ return this.group !== null;
615
+ }
616
+ get(field, fallback) {
617
+ const v = this.params[field];
618
+ return v === void 0 ? fallback : v;
619
+ }
620
+ };
457
621
  var TRUE_RX = /^(true|on|1|yes)$/i;
458
622
  var FALSE_RX = /^(false|off|0|no)$/i;
459
623
  function parseOverrideBool(raw) {
@@ -524,16 +688,25 @@ var Engine = class _Engine {
524
688
  flagOverrides = /* @__PURE__ */ new Map();
525
689
  configOverrides = /* @__PURE__ */ new Map();
526
690
  experimentOverrides = /* @__PURE__ */ new Map();
691
+ // Bounded per-process exposure dedup (`uid:exp:group`) so auto-exposure from
692
+ // repeated assign() calls doesn't spam /collect. Cleared past a soft cap.
693
+ exposureSeen = /* @__PURE__ */ new Set();
527
694
  // Change listeners fired after a background poll returns NEW data (200, not
528
695
  // 304). Never fired in testMode/offline (no polling happens there).
529
696
  changeListeners = /* @__PURE__ */ new Set();
530
697
  constructor(opts) {
698
+ setLogLevel(opts.logLevel);
531
699
  this.apiKey = opts.apiKey;
532
700
  this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
533
701
  this.env = opts.env ?? "prod";
534
702
  this.privateAttributes = opts.privateAttributes ?? [];
535
703
  this.stickyStore = opts.stickyStore;
536
704
  this.testMode = opts.testMode === true;
705
+ setInternalReportContext({
706
+ side: "server",
707
+ sdkVersion: version,
708
+ enabled: !this.testMode && opts.disableInternalErrorReporting !== true
709
+ });
537
710
  this.telemetry = new Telemetry({
538
711
  endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
539
712
  sdkKey: this.apiKey,
@@ -651,14 +824,14 @@ var Engine = class _Engine {
651
824
  try {
652
825
  l();
653
826
  } catch (err) {
654
- console.warn("[shipeasy] onChange listener threw:", String(err));
827
+ logger.warn("[shipeasy] onChange listener threw:", String(err));
655
828
  }
656
829
  }
657
830
  }
658
831
  startPoll() {
659
832
  this.timer = setInterval(() => {
660
833
  this.fetchAll(true).catch(
661
- (err) => console.warn("[shipeasy] background poll failed:", String(err))
834
+ (err) => logger.warn("[shipeasy] background poll failed:", String(err))
662
835
  );
663
836
  }, this.pollInterval * 1e3);
664
837
  }
@@ -737,70 +910,84 @@ var Engine = class _Engine {
737
910
  return "defaultValue" in opts ? opts.defaultValue : void 0;
738
911
  }
739
912
  if (!opts.decode) return raw;
740
- return opts.decode(raw);
741
- }
742
- getExperiment(name, user, defaultParams, decode) {
743
- this.telemetry.emit("experiment", name);
744
- const notIn = {
745
- inExperiment: false,
746
- group: "control",
747
- params: defaultParams
913
+ try {
914
+ return opts.decode(raw);
915
+ } catch (err) {
916
+ logger.warn(`[shipeasy] getConfig('${name}') decode failed:`, String(err));
917
+ return "defaultValue" in opts ? opts.defaultValue : void 0;
918
+ }
919
+ }
920
+ /**
921
+ * Bind the per-experiment sticky seam over the configured {@link StickyBucketStore}
922
+ * for one (unit, experiment). Absent store ⇒ deterministic (no sticky).
923
+ */
924
+ bindSticky(name, uid) {
925
+ const store = this.stickyStore;
926
+ if (!store) return void 0;
927
+ return {
928
+ get: () => store.get(uid)?.[name],
929
+ set: (entry) => store.set(uid, name, entry)
748
930
  };
931
+ }
932
+ /**
933
+ * Evaluate one experiment by name for `user` — override → full classify
934
+ * pipeline (targeting → universe holdout → holdout gate → sticky → allocation
935
+ * → group), merging the universe defaults under the assigned variant (§B2).
936
+ * Internal: the public surface is `universe(name).assign(user)`. Reused by the
937
+ * SSR `evaluate()` bootstrap (keyed by experiment name) and by `assignUniverse`.
938
+ */
939
+ evalExperiment(name, exp, user) {
940
+ const paramDefaults = paramDefaultsFromSchema(
941
+ this.expsBlob?.universes[exp.universe]?.param_schema
942
+ );
749
943
  const ov = this.experimentOverrides.get(name);
750
- if (ov) {
751
- if (!decode) return { inExperiment: true, group: ov.group, params: ov.params };
752
- try {
753
- return { inExperiment: true, group: ov.group, params: decode(ov.params) };
754
- } catch (err) {
755
- console.warn(`[shipeasy] getExperiment('${name}') override decode failed:`, String(err));
756
- return notIn;
757
- }
758
- }
759
- if (!this.flagsBlob || !this.expsBlob) return notIn;
760
- const exp = this.expsBlob.experiments[name];
761
- if (!exp || exp.status !== "running") return notIn;
762
- if (exp.targetingGate) {
763
- const gate = this.flagsBlob.gates[exp.targetingGate];
764
- if (!gate || !evalGateInternal(gate, user)) return notIn;
765
- }
766
- const uid = pickIdentifier(user, exp.bucketBy);
767
- if (!uid) return notIn;
768
- const universe = this.expsBlob.universes[exp.universe];
769
- const holdoutRange = universe?.holdout_range ?? null;
770
- if (holdoutRange) {
771
- const seg = murmur3(`${exp.universe}:${uid}`) % 1e4;
772
- const [lo, hi] = holdoutRange;
773
- if (seg >= lo && seg <= hi) return notIn;
774
- }
775
- const asResult = (g) => {
776
- if (!decode) return { inExperiment: true, group: g.name, params: g.params };
777
- try {
778
- return { inExperiment: true, group: g.name, params: decode(g.params) };
779
- } catch (err) {
780
- console.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
781
- return notIn;
782
- }
944
+ if (ov) return { state: "group", group: ov.group, params: mergeParams(paramDefaults, ov.params) };
945
+ if (!this.flagsBlob || !this.expsBlob) return { state: "out" };
946
+ if (exp.status !== "running") return { state: "out" };
947
+ const holdoutRange = this.expsBlob.universes[exp.universe]?.holdout_range ?? null;
948
+ const evalGate = (gname) => {
949
+ const gate = this.flagsBlob?.gates[gname];
950
+ return gate ? evalGateInternal(gate, user) : false;
783
951
  };
784
- const salt8 = exp.salt.slice(0, 8);
785
- if (this.stickyStore) {
786
- const entry = this.stickyStore.get(uid)?.[name];
787
- if (entry && entry.s === salt8) {
788
- const g = exp.groups.find((x) => x.name === entry.g);
789
- if (g) return asResult(g);
790
- }
791
- }
792
- if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return notIn;
793
- const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
794
- let cumulative = 0;
795
- for (let i = 0; i < exp.groups.length; i++) {
796
- const g = exp.groups[i];
797
- cumulative += g.weight;
798
- if (groupHash < cumulative || i === exp.groups.length - 1) {
799
- this.stickyStore?.set(uid, name, { g: g.name, s: salt8 });
800
- return asResult(g);
952
+ const uid = pickIdentifier(user, exp.bucketBy);
953
+ const sticky = uid ? this.bindSticky(name, uid) : void 0;
954
+ return classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky);
955
+ }
956
+ /**
957
+ * Assign `user` within `universeName`. A universe is a mutual-exclusion pool,
958
+ * so a unit lands in **at most one** experiment; the returned {@link Assignment}
959
+ * exposes the variant + resolved params and auto-logs a single exposure when
960
+ * enrolled. An un-enrolled unit still resolves `get()` to the universe defaults.
961
+ * Never throws. This is the sole experiment read path (there is no
962
+ * `getExperiment` a caller asks a universe, not an experiment).
963
+ */
964
+ assignUniverse(universeName, user) {
965
+ this.telemetry.emit("experiment", universeName);
966
+ const paramDefaults = paramDefaultsFromSchema(
967
+ this.expsBlob?.universes[universeName]?.param_schema
968
+ );
969
+ const notEnrolled = () => new AssignmentImpl(null, null, paramDefaults ?? {});
970
+ if (!this.expsBlob) return notEnrolled();
971
+ const candidates = Object.entries(this.expsBlob.experiments).filter(([, e]) => e.universe === universeName && e.status === "running").sort(
972
+ (a, b) => (a[1].poolOffsetBp ?? 0) - (b[1].poolOffsetBp ?? 0) || a[0].localeCompare(b[0])
973
+ );
974
+ for (const [name, exp] of candidates) {
975
+ const c = this.evalExperiment(name, exp, user);
976
+ if (c.state === "group") {
977
+ this.postExposure(user, name, c.group);
978
+ return new AssignmentImpl(name, c.group, c.params ?? {});
801
979
  }
802
980
  }
803
- return notIn;
981
+ return notEnrolled();
982
+ }
983
+ /**
984
+ * The universe-first experiment read entry point:
985
+ * `engine.universe("checkout").assign(user)`. Returns a reusable handle bound
986
+ * to one universe; `assign(user)` picks the ≤1 experiment the unit is pooled
987
+ * into and auto-logs a single exposure. See {@link assignUniverse}.
988
+ */
989
+ universe(name) {
990
+ return { assign: (user) => this.assignUniverse(name, user) };
804
991
  }
805
992
  /** Drop caller-marked private attributes from an outbound props bag. */
806
993
  stripPrivate(props) {
@@ -829,29 +1016,29 @@ var Engine = class _Engine {
829
1016
  method: "POST",
830
1017
  headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
831
1018
  body
832
- }).catch((err) => console.warn("[shipeasy] track failed:", String(err)));
1019
+ }).catch((err) => logger.warn("[shipeasy] track failed:", String(err)));
833
1020
  }
834
1021
  /**
835
- * Emit an exposure event for an experiment at the server-side decision point
836
- * (parity with the browser's auto-exposure). The server is stateless and
837
- * never auto-logs, so call this when you actually present the treatment.
838
- * Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
839
- * as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
840
- * `/collect`. No-op in test mode or when the user isn't enrolled.
1022
+ * POST a single exposure for an enrolled `(user, experiment, group)`. Deduped
1023
+ * per process (bounded set) so repeated `assign()` calls in one server don't
1024
+ * spam `/collect`. Fire-and-forget; no-op in test mode. This is how
1025
+ * `assignUniverse` auto-logs the browser's auto-exposure parity for SSR.
841
1026
  */
842
- logExposure(user, name) {
1027
+ postExposure(user, experiment, group) {
843
1028
  if (this.testMode) return;
844
- const u = typeof user === "string" ? { user_id: user } : user;
845
- const result = this.getExperiment(name, u, {});
846
- if (!result.inExperiment) return;
1029
+ const uid = user.user_id ?? user.anonymous_id;
1030
+ const dedupKey = `${uid ?? ""}:${experiment}:${group}`;
1031
+ if (this.exposureSeen.has(dedupKey)) return;
1032
+ if (this.exposureSeen.size > 5e3) this.exposureSeen.clear();
1033
+ this.exposureSeen.add(dedupKey);
847
1034
  const body = JSON.stringify({
848
1035
  events: [
849
1036
  {
850
1037
  type: "exposure",
851
- experiment: name,
852
- group: result.group,
853
- ...u.user_id !== void 0 ? { user_id: u.user_id } : {},
854
- ...u.anonymous_id !== void 0 ? { anonymous_id: u.anonymous_id } : {},
1038
+ experiment,
1039
+ group,
1040
+ ...user.user_id !== void 0 ? { user_id: user.user_id } : {},
1041
+ ...user.anonymous_id !== void 0 ? { anonymous_id: user.anonymous_id } : {},
855
1042
  ts: Date.now()
856
1043
  }
857
1044
  ]
@@ -860,7 +1047,7 @@ var Engine = class _Engine {
860
1047
  method: "POST",
861
1048
  headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
862
1049
  body
863
- }).catch((err) => console.warn("[shipeasy] logExposure failed:", String(err)));
1050
+ }).catch((err) => logger.warn("[shipeasy] exposure send failed:", String(err)));
864
1051
  }
865
1052
  /**
866
1053
  * Report a structured error into the errors primitive. Fire-and-forget —
@@ -880,7 +1067,7 @@ var Engine = class _Engine {
880
1067
  method: "POST",
881
1068
  headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
882
1069
  body: JSON.stringify({ events: [ev] })
883
- }).catch((err) => console.warn("[shipeasy] see() send failed:", String(err)));
1070
+ }).catch((err) => logger.warn("[shipeasy] see() send failed:", String(err)));
884
1071
  } catch {
885
1072
  }
886
1073
  }
@@ -906,8 +1093,17 @@ var Engine = class _Engine {
906
1093
  this.telemetry.emit("config", name);
907
1094
  configs[name] = entry.value;
908
1095
  }
909
- for (const [name] of Object.entries(this.expsBlob?.experiments ?? {})) {
910
- experiments[name] = this.getExperiment(name, user, {});
1096
+ const universes = {};
1097
+ for (const [name, exp] of Object.entries(this.expsBlob?.experiments ?? {})) {
1098
+ this.telemetry.emit("experiment", exp.universe);
1099
+ const uniName = exp.universe;
1100
+ if (!(uniName in universes)) {
1101
+ universes[uniName] = {
1102
+ defaults: paramDefaultsFromSchema(this.expsBlob?.universes[uniName]?.param_schema) ?? {}
1103
+ };
1104
+ }
1105
+ const c = this.evalExperiment(name, exp, user);
1106
+ experiments[name] = c.state === "group" ? { inExperiment: true, group: c.group, params: c.params ?? {}, universe: uniName } : { inExperiment: false, group: "control", params: {}, universe: uniName };
911
1107
  }
912
1108
  for (const [name, ks] of Object.entries(this.flagsBlob?.killswitches ?? {})) {
913
1109
  this.telemetry.emit("ks", name);
@@ -924,10 +1120,11 @@ var Engine = class _Engine {
924
1120
  Object.assign(flags2, ov.gates);
925
1121
  Object.assign(configs, ov.configs);
926
1122
  for (const [name, group] of Object.entries(ov.experiments)) {
927
- experiments[name] = { inExperiment: true, group, params: {} };
1123
+ const uni = this.expsBlob?.experiments[name]?.universe;
1124
+ experiments[name] = { inExperiment: true, group, params: {}, ...uni ? { universe: uni } : {} };
928
1125
  }
929
1126
  }
930
- return { flags: flags2, configs, experiments, killswitches };
1127
+ return { flags: flags2, configs, experiments, killswitches, universes };
931
1128
  }
932
1129
  getKillswitch(name, switchKey) {
933
1130
  this.telemetry.emit("ks", name);
@@ -1067,7 +1264,7 @@ function _resetShipeasyServerForTests() {
1067
1264
  async function shipeasy(opts) {
1068
1265
  const serverKey = opts.serverKey ?? "";
1069
1266
  if (!serverKey) {
1070
- console.error(
1267
+ logger.error(
1071
1268
  "[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."
1072
1269
  );
1073
1270
  }
@@ -1075,7 +1272,9 @@ async function shipeasy(opts) {
1075
1272
  flags.configure({
1076
1273
  apiKey: serverKey,
1077
1274
  disableTelemetry: opts.disableTelemetry,
1078
- privateAttributes: opts.privateAttributes
1275
+ disableInternalErrorReporting: opts.disableInternalErrorReporting,
1276
+ privateAttributes: opts.privateAttributes,
1277
+ logLevel: opts.logLevel
1079
1278
  });
1080
1279
  let resolvedUrlOverrides = opts.urlOverrides;
1081
1280
  if (!resolvedUrlOverrides) {
@@ -1201,20 +1400,38 @@ var flags = {
1201
1400
  _server?.destroy();
1202
1401
  },
1203
1402
  get(name, user, defaultValue = false) {
1204
- return _server?.getFlag(name, user, defaultValue) ?? defaultValue;
1403
+ return safeRun("flags.get", defaultValue, () => _server?.getFlag(name, user, defaultValue) ?? defaultValue);
1205
1404
  },
1206
1405
  /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
1207
1406
  getDetail(name, user) {
1208
- return _server?.getFlagDetail(name, user) ?? { value: false, reason: "CLIENT_NOT_READY" };
1407
+ return safeRun(
1408
+ "flags.getDetail",
1409
+ { value: false, reason: "CLIENT_NOT_READY" },
1410
+ () => _server?.getFlagDetail(name, user) ?? { value: false, reason: "CLIENT_NOT_READY" }
1411
+ );
1209
1412
  },
1210
1413
  getConfig(name, decodeOrOpts) {
1211
- return _server?.getConfig(name, decodeOrOpts);
1414
+ return safeRun(
1415
+ "flags.getConfig",
1416
+ void 0,
1417
+ () => _server?.getConfig(name, decodeOrOpts)
1418
+ );
1212
1419
  },
1213
- getExperiment(name, user, defaultParams, decode) {
1214
- return _server?.getExperiment(name, user, defaultParams, decode) ?? {
1215
- inExperiment: false,
1216
- group: "control",
1217
- params: defaultParams
1420
+ /**
1421
+ * Assign `user` within a universe: `flags.universe("checkout").assign(user)`.
1422
+ * A universe is a mutual-exclusion pool, so the unit lands in ≤1 experiment;
1423
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
1424
+ * and auto-logs one exposure when enrolled. Before configure() (or on any
1425
+ * error) it returns a safe not-enrolled handle. This replaces the removed
1426
+ * `getExperiment` — read experiments by universe, never by name.
1427
+ */
1428
+ universe(name) {
1429
+ return {
1430
+ assign: (user) => safeRun(
1431
+ "flags.universe.assign",
1432
+ new AssignmentImpl(null, null, {}),
1433
+ () => _server?.assignUniverse(name, user) ?? new AssignmentImpl(null, null, {})
1434
+ )
1218
1435
  };
1219
1436
  },
1220
1437
  /**
@@ -1223,15 +1440,10 @@ var flags = {
1223
1440
  * switch is on. Unknown killswitches / switches return false.
1224
1441
  */
1225
1442
  ks(name, switchKey) {
1226
- return _server?.getKillswitch(name, switchKey) ?? false;
1443
+ return safeRun("flags.ks", false, () => _server?.getKillswitch(name, switchKey) ?? false);
1227
1444
  },
1228
1445
  track(userId, eventName, props) {
1229
- _server?.track(userId, eventName, props);
1230
- },
1231
- /** Emit an exposure for an enrolled experiment at the decision point. See
1232
- * {@link Engine.logExposure}. No-op before configure(). */
1233
- logExposure(user, name) {
1234
- _server?.logExposure(user, name);
1446
+ safeRun("flags.track", void 0, () => _server?.track(userId, eventName, props));
1235
1447
  },
1236
1448
  /**
1237
1449
  * Evaluate all flags / configs / experiments for a user against the locally
@@ -1239,12 +1451,8 @@ var flags = {
1239
1451
  * overrides. Returns an empty payload when the blob hasn't been fetched yet.
1240
1452
  */
1241
1453
  evaluate(user, rawUrl) {
1242
- return _server?.evaluate(user, rawUrl) ?? {
1243
- flags: {},
1244
- configs: {},
1245
- experiments: {},
1246
- killswitches: {}
1247
- };
1454
+ const empty = { flags: {}, configs: {}, experiments: {}, killswitches: {} };
1455
+ return safeRun("flags.evaluate", empty, () => _server?.evaluate(user, rawUrl) ?? empty);
1248
1456
  }
1249
1457
  };
1250
1458
  var _identityAttributes = (user) => user && typeof user === "object" ? user : {};
@@ -1333,20 +1541,44 @@ var Client = class {
1333
1541
  this.attributes = _attributes(user);
1334
1542
  }
1335
1543
  getFlag(name, defaultValue = false) {
1336
- return this.engine.getFlag(name, this.attributes, defaultValue);
1544
+ return safeRun(
1545
+ "Client.getFlag",
1546
+ defaultValue,
1547
+ () => this.engine.getFlag(name, this.attributes, defaultValue)
1548
+ );
1337
1549
  }
1338
1550
  getFlagDetail(name) {
1339
- return this.engine.getFlagDetail(name, this.attributes);
1551
+ return safeRun(
1552
+ "Client.getFlagDetail",
1553
+ { value: false, reason: "CLIENT_NOT_READY" },
1554
+ () => this.engine.getFlagDetail(name, this.attributes)
1555
+ );
1340
1556
  }
1341
1557
  getConfig(name, decodeOrOpts) {
1342
- return this.engine.getConfig(name, decodeOrOpts);
1558
+ return safeRun(
1559
+ "Client.getConfig",
1560
+ void 0,
1561
+ () => this.engine.getConfig(name, decodeOrOpts)
1562
+ );
1343
1563
  }
1344
- getExperiment(name, defaultParams, decode) {
1345
- return this.engine.getExperiment(name, this.attributes, defaultParams, decode);
1564
+ /**
1565
+ * Assign the bound user within a universe: `client.universe("checkout").assign()`.
1566
+ * The user is already bound at construction, so `assign()` takes no arg. Returns
1567
+ * an {@link Assignment} (`.group` / `.get(field, fallback)`) and auto-logs one
1568
+ * exposure when enrolled. Replaces the removed `getExperiment`.
1569
+ */
1570
+ universe(name) {
1571
+ return {
1572
+ assign: () => safeRun(
1573
+ "Client.universe.assign",
1574
+ new AssignmentImpl(null, null, {}),
1575
+ () => this.engine.assignUniverse(name, this.attributes)
1576
+ )
1577
+ };
1346
1578
  }
1347
1579
  /** Read a killswitch (not user-bound; mirrors {@link Engine.getKillswitch}). */
1348
1580
  getKillswitch(name, switchKey) {
1349
- return this.engine.getKillswitch(name, switchKey);
1581
+ return safeRun("Client.getKillswitch", false, () => this.engine.getKillswitch(name, switchKey));
1350
1582
  }
1351
1583
  /**
1352
1584
  * Record a conversion/metric event for the bound user. Derives the unit from
@@ -1356,23 +1588,16 @@ var Client = class {
1356
1588
  * mode.
1357
1589
  */
1358
1590
  track(eventName, props) {
1359
- const id = this.attributes.user_id ?? this.attributes.anonymous_id;
1360
- if (id === void 0) return;
1361
- this.engine.track(String(id), eventName, props);
1362
- }
1363
- /**
1364
- * Emit an exposure event for `name` at this server-side decision point for the
1365
- * bound user. Delegates to {@link Engine.logExposure} with the resolved
1366
- * attribute bag (re-evaluates enrolment; no-op when the user isn't enrolled or
1367
- * in test mode).
1368
- */
1369
- logExposure(name) {
1370
- this.engine.logExposure(this.attributes, name);
1591
+ safeRun("Client.track", void 0, () => {
1592
+ const id = this.attributes.user_id ?? this.attributes.anonymous_id;
1593
+ if (id === void 0) return;
1594
+ this.engine.track(String(id), eventName, props);
1595
+ });
1371
1596
  }
1372
1597
  };
1373
1598
  function dispatchSee(problem, consequence, extras, kind) {
1374
1599
  if (!_server) {
1375
- console.warn("[shipeasy] see() called before shipeasy({ serverKey }) \u2014 error dropped");
1600
+ logger.warn("[shipeasy] see() called before shipeasy({ serverKey }) \u2014 error dropped");
1376
1601
  return;
1377
1602
  }
1378
1603
  _server.reportError(problem, consequence, extras, kind);
@@ -1389,6 +1614,7 @@ export {
1389
1614
  Client,
1390
1615
  Engine,
1391
1616
  FLAG_REASONS,
1617
+ LOG_LEVELS,
1392
1618
  _murmur3ForTests,
1393
1619
  _resetConfigureForTests,
1394
1620
  _resetShipeasyServerForTests,