@shipeasy/sdk 6.3.1 → 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.
- package/README.md +1 -4
- package/dist/client/index.d.mts +128 -22
- package/dist/client/index.d.ts +128 -22
- package/dist/client/index.js +238 -133
- package/dist/client/index.mjs +238 -133
- package/dist/openfeature-server/index.d.mts +117 -8
- package/dist/openfeature-server/index.d.ts +117 -8
- package/dist/openfeature-server/index.js +65 -33
- package/dist/openfeature-server/index.mjs +65 -33
- package/dist/openfeature-web/index.d.mts +89 -27
- package/dist/openfeature-web/index.d.ts +89 -27
- package/dist/server/index.d.mts +145 -21
- package/dist/server/index.d.ts +145 -21
- package/dist/server/index.js +305 -158
- package/dist/server/index.mjs +305 -158
- package/docs/skill/SKILL.md +14 -10
- package/package.json +3 -1
package/dist/server/index.js
CHANGED
|
@@ -122,53 +122,6 @@ 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
|
-
}
|
|
139
|
-
}
|
|
140
|
-
function write(method, args) {
|
|
141
|
-
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);
|
|
146
|
-
} 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;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
125
|
// src/see/core.ts
|
|
173
126
|
var SEE_MAX_MESSAGE = 500;
|
|
174
127
|
var SEE_MAX_STACK = 8e3;
|
|
@@ -286,7 +239,7 @@ function captureCallsiteStack() {
|
|
|
286
239
|
const kept = lines.slice(1).filter((l) => !/@shipeasy[\\/]sdk|see[\\/]core|captureCallsiteStack|\bsee\b\s*\(/.test(l));
|
|
287
240
|
return kept.length ? kept.join("\n") : void 0;
|
|
288
241
|
}
|
|
289
|
-
function buildSeeEvent(problem, consequence, extras,
|
|
242
|
+
function buildSeeEvent(problem, consequence, extras, ctx2, kindOverride, correlationId) {
|
|
290
243
|
let errorType;
|
|
291
244
|
let message;
|
|
292
245
|
let stack;
|
|
@@ -314,8 +267,8 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
|
|
|
314
267
|
message: truncate(message, SEE_MAX_MESSAGE),
|
|
315
268
|
subject: consequence.subject,
|
|
316
269
|
outcome: consequence.outcome,
|
|
317
|
-
side:
|
|
318
|
-
sdk_version:
|
|
270
|
+
side: ctx2.side,
|
|
271
|
+
sdk_version: ctx2.sdkVersion,
|
|
319
272
|
ts: Date.now()
|
|
320
273
|
};
|
|
321
274
|
if (stack) ev.stack = truncate(stack, SEE_MAX_STACK);
|
|
@@ -324,10 +277,10 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
|
|
|
324
277
|
if (causedBy) ev.caused_by = causedBy;
|
|
325
278
|
const cleanExtras = sanitizeExtras(extras);
|
|
326
279
|
if (cleanExtras) ev.extras = cleanExtras;
|
|
327
|
-
if (
|
|
328
|
-
if (
|
|
329
|
-
if (
|
|
330
|
-
if (
|
|
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;
|
|
331
284
|
markReported(problem, ev);
|
|
332
285
|
return ev;
|
|
333
286
|
}
|
|
@@ -420,6 +373,98 @@ var SeeLimiter = class {
|
|
|
420
373
|
}
|
|
421
374
|
};
|
|
422
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
|
+
|
|
423
468
|
// src/server/index.ts
|
|
424
469
|
var version = "4.0.0";
|
|
425
470
|
var C1 = 3432918353;
|
|
@@ -556,6 +601,78 @@ function evalGateInternal(gate, user) {
|
|
|
556
601
|
if (!uid) return gate.rolloutPct >= 1e4;
|
|
557
602
|
return murmur3(`${gate.salt}:${uid}`) % 1e4 < gate.rolloutPct;
|
|
558
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
|
+
};
|
|
559
676
|
var TRUE_RX = /^(true|on|1|yes)$/i;
|
|
560
677
|
var FALSE_RX = /^(false|off|0|no)$/i;
|
|
561
678
|
function parseOverrideBool(raw) {
|
|
@@ -626,6 +743,9 @@ var Engine = class _Engine {
|
|
|
626
743
|
flagOverrides = /* @__PURE__ */ new Map();
|
|
627
744
|
configOverrides = /* @__PURE__ */ new Map();
|
|
628
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();
|
|
629
749
|
// Change listeners fired after a background poll returns NEW data (200, not
|
|
630
750
|
// 304). Never fired in testMode/offline (no polling happens there).
|
|
631
751
|
changeListeners = /* @__PURE__ */ new Set();
|
|
@@ -637,6 +757,11 @@ var Engine = class _Engine {
|
|
|
637
757
|
this.privateAttributes = opts.privateAttributes ?? [];
|
|
638
758
|
this.stickyStore = opts.stickyStore;
|
|
639
759
|
this.testMode = opts.testMode === true;
|
|
760
|
+
setInternalReportContext({
|
|
761
|
+
side: "server",
|
|
762
|
+
sdkVersion: version,
|
|
763
|
+
enabled: !this.testMode && opts.disableInternalErrorReporting !== true
|
|
764
|
+
});
|
|
640
765
|
this.telemetry = new Telemetry({
|
|
641
766
|
endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
|
|
642
767
|
sdkKey: this.apiKey,
|
|
@@ -847,68 +972,77 @@ var Engine = class _Engine {
|
|
|
847
972
|
return "defaultValue" in opts ? opts.defaultValue : void 0;
|
|
848
973
|
}
|
|
849
974
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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)
|
|
856
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
|
+
);
|
|
857
998
|
const ov = this.experimentOverrides.get(name);
|
|
858
|
-
if (ov) {
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
}
|
|
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;
|
|
891
1006
|
};
|
|
892
|
-
const
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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 ?? {});
|
|
909
1034
|
}
|
|
910
1035
|
}
|
|
911
|
-
return
|
|
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) };
|
|
912
1046
|
}
|
|
913
1047
|
/** Drop caller-marked private attributes from an outbound props bag. */
|
|
914
1048
|
stripPrivate(props) {
|
|
@@ -940,26 +1074,26 @@ var Engine = class _Engine {
|
|
|
940
1074
|
}).catch((err) => logger.warn("[shipeasy] track failed:", String(err)));
|
|
941
1075
|
}
|
|
942
1076
|
/**
|
|
943
|
-
*
|
|
944
|
-
* (
|
|
945
|
-
*
|
|
946
|
-
*
|
|
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.
|
|
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.
|
|
949
1081
|
*/
|
|
950
|
-
|
|
1082
|
+
postExposure(user, experiment, group) {
|
|
951
1083
|
if (this.testMode) return;
|
|
952
|
-
const
|
|
953
|
-
const
|
|
954
|
-
if (
|
|
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);
|
|
955
1089
|
const body = JSON.stringify({
|
|
956
1090
|
events: [
|
|
957
1091
|
{
|
|
958
1092
|
type: "exposure",
|
|
959
|
-
experiment
|
|
960
|
-
group
|
|
961
|
-
...
|
|
962
|
-
...
|
|
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 } : {},
|
|
963
1097
|
ts: Date.now()
|
|
964
1098
|
}
|
|
965
1099
|
]
|
|
@@ -968,7 +1102,7 @@ var Engine = class _Engine {
|
|
|
968
1102
|
method: "POST",
|
|
969
1103
|
headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
|
|
970
1104
|
body
|
|
971
|
-
}).catch((err) => logger.warn("[shipeasy]
|
|
1105
|
+
}).catch((err) => logger.warn("[shipeasy] exposure send failed:", String(err)));
|
|
972
1106
|
}
|
|
973
1107
|
/**
|
|
974
1108
|
* Report a structured error into the errors primitive. Fire-and-forget —
|
|
@@ -1014,8 +1148,17 @@ var Engine = class _Engine {
|
|
|
1014
1148
|
this.telemetry.emit("config", name);
|
|
1015
1149
|
configs[name] = entry.value;
|
|
1016
1150
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
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 };
|
|
1019
1162
|
}
|
|
1020
1163
|
for (const [name, ks] of Object.entries(this.flagsBlob?.killswitches ?? {})) {
|
|
1021
1164
|
this.telemetry.emit("ks", name);
|
|
@@ -1032,10 +1175,11 @@ var Engine = class _Engine {
|
|
|
1032
1175
|
Object.assign(flags2, ov.gates);
|
|
1033
1176
|
Object.assign(configs, ov.configs);
|
|
1034
1177
|
for (const [name, group] of Object.entries(ov.experiments)) {
|
|
1035
|
-
experiments[name]
|
|
1178
|
+
const uni = this.expsBlob?.experiments[name]?.universe;
|
|
1179
|
+
experiments[name] = { inExperiment: true, group, params: {}, ...uni ? { universe: uni } : {} };
|
|
1036
1180
|
}
|
|
1037
1181
|
}
|
|
1038
|
-
return { flags: flags2, configs, experiments, killswitches };
|
|
1182
|
+
return { flags: flags2, configs, experiments, killswitches, universes };
|
|
1039
1183
|
}
|
|
1040
1184
|
getKillswitch(name, switchKey) {
|
|
1041
1185
|
this.telemetry.emit("ks", name);
|
|
@@ -1183,6 +1327,7 @@ async function shipeasy(opts) {
|
|
|
1183
1327
|
flags.configure({
|
|
1184
1328
|
apiKey: serverKey,
|
|
1185
1329
|
disableTelemetry: opts.disableTelemetry,
|
|
1330
|
+
disableInternalErrorReporting: opts.disableInternalErrorReporting,
|
|
1186
1331
|
privateAttributes: opts.privateAttributes,
|
|
1187
1332
|
logLevel: opts.logLevel
|
|
1188
1333
|
});
|
|
@@ -1327,13 +1472,22 @@ var flags = {
|
|
|
1327
1472
|
() => _server?.getConfig(name, decodeOrOpts)
|
|
1328
1473
|
);
|
|
1329
1474
|
},
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
+
)
|
|
1490
|
+
};
|
|
1337
1491
|
},
|
|
1338
1492
|
/**
|
|
1339
1493
|
* Read a killswitch. Without `switchKey`, returns true when the whole
|
|
@@ -1346,11 +1500,6 @@ var flags = {
|
|
|
1346
1500
|
track(userId, eventName, props) {
|
|
1347
1501
|
safeRun("flags.track", void 0, () => _server?.track(userId, eventName, props));
|
|
1348
1502
|
},
|
|
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
1503
|
/**
|
|
1355
1504
|
* Evaluate all flags / configs / experiments for a user against the locally
|
|
1356
1505
|
* cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
|
|
@@ -1467,13 +1616,20 @@ var Client = class {
|
|
|
1467
1616
|
() => this.engine.getConfig(name, decodeOrOpts)
|
|
1468
1617
|
);
|
|
1469
1618
|
}
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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
|
+
};
|
|
1477
1633
|
}
|
|
1478
1634
|
/** Read a killswitch (not user-bound; mirrors {@link Engine.getKillswitch}). */
|
|
1479
1635
|
getKillswitch(name, switchKey) {
|
|
@@ -1493,15 +1649,6 @@ var Client = class {
|
|
|
1493
1649
|
this.engine.track(String(id), eventName, props);
|
|
1494
1650
|
});
|
|
1495
1651
|
}
|
|
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
1652
|
};
|
|
1506
1653
|
function dispatchSee(problem, consequence, extras, kind) {
|
|
1507
1654
|
if (!_server) {
|