@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.
- package/README.md +1 -4
- package/dist/client/index.d.mts +166 -27
- package/dist/client/index.d.ts +166 -27
- package/dist/client/index.js +268 -132
- package/dist/client/index.mjs +268 -132
- package/dist/openfeature-server/index.d.mts +144 -14
- package/dist/openfeature-server/index.d.ts +144 -14
- package/dist/openfeature-server/index.js +65 -33
- package/dist/openfeature-server/index.mjs +65 -33
- package/dist/openfeature-web/index.d.mts +110 -30
- package/dist/openfeature-web/index.d.ts +110 -30
- package/dist/server/index.d.mts +181 -28
- package/dist/server/index.d.ts +181 -28
- package/dist/server/index.js +337 -159
- package/dist/server/index.mjs +337 -159
- package/docs/skill/SKILL.md +20 -10
- package/package.json +3 -1
package/dist/server/index.js
CHANGED
|
@@ -122,50 +122,21 @@ function send(url) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
// src/
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
131
|
+
function readNativeEnv() {
|
|
141
132
|
try {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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,
|
|
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:
|
|
318
|
-
sdk_version:
|
|
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 (
|
|
328
|
-
if (
|
|
329
|
-
if (
|
|
330
|
-
if (
|
|
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
|
-
//
|
|
646
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
|
|
951
|
-
if (this.
|
|
952
|
-
const
|
|
953
|
-
const
|
|
954
|
-
if (
|
|
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
|
|
960
|
-
group
|
|
961
|
-
...
|
|
962
|
-
...
|
|
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]
|
|
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
|
-
|
|
1018
|
-
|
|
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]
|
|
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
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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) {
|