@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.mjs
CHANGED
|
@@ -67,50 +67,21 @@ function send(url) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// src/
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
warn: 2,
|
|
76
|
-
info: 3,
|
|
77
|
-
debug: 4
|
|
78
|
-
};
|
|
79
|
-
var currentLevel = "warn";
|
|
80
|
-
function setLogLevel(level) {
|
|
81
|
-
if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
|
|
82
|
-
currentLevel = level;
|
|
83
|
-
}
|
|
70
|
+
// src/env.ts
|
|
71
|
+
function isProductionEnv(configuredEnv) {
|
|
72
|
+
const native = readNativeEnv();
|
|
73
|
+
if (native !== null) return native === "production" || native === "prod";
|
|
74
|
+
return (configuredEnv ?? "prod").toLowerCase() === "prod";
|
|
84
75
|
}
|
|
85
|
-
function
|
|
76
|
+
function readNativeEnv() {
|
|
86
77
|
try {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
78
|
+
const proc = globalThis.process;
|
|
79
|
+
const raw = proc?.env?.SHIPEASY_ENV ?? proc?.env?.NODE_ENV;
|
|
80
|
+
if (typeof raw !== "string") return null;
|
|
81
|
+
const v = raw.trim().toLowerCase();
|
|
82
|
+
return v.length ? v : null;
|
|
91
83
|
} catch {
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
var logger = {
|
|
95
|
-
error(...args) {
|
|
96
|
-
if (RANK[currentLevel] >= RANK.error) write("error", args);
|
|
97
|
-
},
|
|
98
|
-
warn(...args) {
|
|
99
|
-
if (RANK[currentLevel] >= RANK.warn) write("warn", args);
|
|
100
|
-
},
|
|
101
|
-
info(...args) {
|
|
102
|
-
if (RANK[currentLevel] >= RANK.info) write("info", args);
|
|
103
|
-
},
|
|
104
|
-
debug(...args) {
|
|
105
|
-
if (RANK[currentLevel] >= RANK.debug) write("debug", args);
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
function safeRun(label, fallback, fn) {
|
|
109
|
-
try {
|
|
110
|
-
return fn();
|
|
111
|
-
} catch (err) {
|
|
112
|
-
logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
|
|
113
|
-
return fallback;
|
|
84
|
+
return null;
|
|
114
85
|
}
|
|
115
86
|
}
|
|
116
87
|
|
|
@@ -231,7 +202,7 @@ function captureCallsiteStack() {
|
|
|
231
202
|
const kept = lines.slice(1).filter((l) => !/@shipeasy[\\/]sdk|see[\\/]core|captureCallsiteStack|\bsee\b\s*\(/.test(l));
|
|
232
203
|
return kept.length ? kept.join("\n") : void 0;
|
|
233
204
|
}
|
|
234
|
-
function buildSeeEvent(problem, consequence, extras,
|
|
205
|
+
function buildSeeEvent(problem, consequence, extras, ctx2, kindOverride, correlationId) {
|
|
235
206
|
let errorType;
|
|
236
207
|
let message;
|
|
237
208
|
let stack;
|
|
@@ -259,8 +230,8 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
|
|
|
259
230
|
message: truncate(message, SEE_MAX_MESSAGE),
|
|
260
231
|
subject: consequence.subject,
|
|
261
232
|
outcome: consequence.outcome,
|
|
262
|
-
side:
|
|
263
|
-
sdk_version:
|
|
233
|
+
side: ctx2.side,
|
|
234
|
+
sdk_version: ctx2.sdkVersion,
|
|
264
235
|
ts: Date.now()
|
|
265
236
|
};
|
|
266
237
|
if (stack) ev.stack = truncate(stack, SEE_MAX_STACK);
|
|
@@ -269,10 +240,10 @@ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride, correlat
|
|
|
269
240
|
if (causedBy) ev.caused_by = causedBy;
|
|
270
241
|
const cleanExtras = sanitizeExtras(extras);
|
|
271
242
|
if (cleanExtras) ev.extras = cleanExtras;
|
|
272
|
-
if (
|
|
273
|
-
if (
|
|
274
|
-
if (
|
|
275
|
-
if (
|
|
243
|
+
if (ctx2.url) ev.url = truncate(ctx2.url, SEE_MAX_SUBJECT);
|
|
244
|
+
if (ctx2.userId) ev.user_id = ctx2.userId;
|
|
245
|
+
if (ctx2.anonId) ev.anonymous_id = ctx2.anonId;
|
|
246
|
+
if (ctx2.env) ev.env = ctx2.env;
|
|
276
247
|
markReported(problem, ev);
|
|
277
248
|
return ev;
|
|
278
249
|
}
|
|
@@ -365,6 +336,98 @@ var SeeLimiter = class {
|
|
|
365
336
|
}
|
|
366
337
|
};
|
|
367
338
|
|
|
339
|
+
// src/internal-report.ts
|
|
340
|
+
var INGEST_URL = "https://api.shipeasy.ai/collect";
|
|
341
|
+
var PLACEHOLDER_KEY = "sdk_client_REPLACE_WITH_SHIPEASY_INTERNAL_ERROR_KEY";
|
|
342
|
+
var INGEST_KEY = "sdk_client_00bd4608a03e4084922978f9522614d5";
|
|
343
|
+
function keyConfigured() {
|
|
344
|
+
return !!INGEST_KEY && INGEST_KEY !== PLACEHOLDER_KEY;
|
|
345
|
+
}
|
|
346
|
+
var OUTCOME = "returned a safe default";
|
|
347
|
+
var SDK_ID = "ts";
|
|
348
|
+
var ctx = null;
|
|
349
|
+
var limiter = new SeeLimiter();
|
|
350
|
+
function setInternalReportContext(c) {
|
|
351
|
+
ctx = { side: c.side, sdkVersion: c.sdkVersion, enabled: c.enabled !== false };
|
|
352
|
+
}
|
|
353
|
+
function reportInternalError(label, err) {
|
|
354
|
+
try {
|
|
355
|
+
if (!ctx || !ctx.enabled || !keyConfigured()) return;
|
|
356
|
+
const ev = buildSeeEvent(
|
|
357
|
+
err,
|
|
358
|
+
causesThe(label).to(OUTCOME),
|
|
359
|
+
{ sdk: SDK_ID },
|
|
360
|
+
{ side: ctx.side, sdkVersion: ctx.sdkVersion },
|
|
361
|
+
"caught"
|
|
362
|
+
);
|
|
363
|
+
if (!limiter.shouldSend(ev)) return;
|
|
364
|
+
send2(INGEST_URL, INGEST_KEY, JSON.stringify({ events: [ev] }));
|
|
365
|
+
} catch {
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function send2(url, key, body) {
|
|
369
|
+
try {
|
|
370
|
+
const f = globalThis.fetch;
|
|
371
|
+
if (typeof f !== "function") return;
|
|
372
|
+
void f(url, {
|
|
373
|
+
method: "POST",
|
|
374
|
+
headers: { "X-SDK-Key": key, "Content-Type": "text/plain" },
|
|
375
|
+
body,
|
|
376
|
+
keepalive: true
|
|
377
|
+
}).catch(() => {
|
|
378
|
+
});
|
|
379
|
+
} catch {
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// src/logger.ts
|
|
384
|
+
var LOG_LEVELS = ["silent", "error", "warn", "info", "debug"];
|
|
385
|
+
var RANK = {
|
|
386
|
+
silent: 0,
|
|
387
|
+
error: 1,
|
|
388
|
+
warn: 2,
|
|
389
|
+
info: 3,
|
|
390
|
+
debug: 4
|
|
391
|
+
};
|
|
392
|
+
var currentLevel = "warn";
|
|
393
|
+
function setLogLevel(level) {
|
|
394
|
+
if (typeof level === "string" && Object.prototype.hasOwnProperty.call(RANK, level)) {
|
|
395
|
+
currentLevel = level;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function write(method, args) {
|
|
399
|
+
try {
|
|
400
|
+
const c = globalThis.console;
|
|
401
|
+
if (!c) return;
|
|
402
|
+
const fn = c[method] ?? c.log;
|
|
403
|
+
if (typeof fn === "function") fn.call(c, ...args);
|
|
404
|
+
} catch {
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
var logger = {
|
|
408
|
+
error(...args) {
|
|
409
|
+
if (RANK[currentLevel] >= RANK.error) write("error", args);
|
|
410
|
+
},
|
|
411
|
+
warn(...args) {
|
|
412
|
+
if (RANK[currentLevel] >= RANK.warn) write("warn", args);
|
|
413
|
+
},
|
|
414
|
+
info(...args) {
|
|
415
|
+
if (RANK[currentLevel] >= RANK.info) write("info", args);
|
|
416
|
+
},
|
|
417
|
+
debug(...args) {
|
|
418
|
+
if (RANK[currentLevel] >= RANK.debug) write("debug", args);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
function safeRun(label, fallback, fn) {
|
|
422
|
+
try {
|
|
423
|
+
return fn();
|
|
424
|
+
} catch (err) {
|
|
425
|
+
logger.error(`[shipeasy] ${label} failed \u2014 returning safe default:`, String(err));
|
|
426
|
+
reportInternalError(label, err);
|
|
427
|
+
return fallback;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
368
431
|
// src/server/index.ts
|
|
369
432
|
var version = "4.0.0";
|
|
370
433
|
var C1 = 3432918353;
|
|
@@ -501,6 +564,78 @@ function evalGateInternal(gate, user) {
|
|
|
501
564
|
if (!uid) return gate.rolloutPct >= 1e4;
|
|
502
565
|
return murmur3(`${gate.salt}:${uid}`) % 1e4 < gate.rolloutPct;
|
|
503
566
|
}
|
|
567
|
+
function paramDefaultsFromSchema(schema) {
|
|
568
|
+
if (!schema || schema.length === 0) return null;
|
|
569
|
+
const out = {};
|
|
570
|
+
for (const p of schema) out[p.name] = p.default;
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
function mergeParams(paramDefaults, groupParams) {
|
|
574
|
+
return paramDefaults ? { ...paramDefaults, ...groupParams } : { ...groupParams };
|
|
575
|
+
}
|
|
576
|
+
function classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky) {
|
|
577
|
+
const asGroup = (g) => ({
|
|
578
|
+
state: "group",
|
|
579
|
+
group: g.name,
|
|
580
|
+
params: mergeParams(paramDefaults, g.params)
|
|
581
|
+
});
|
|
582
|
+
if (exp.targetingGate && !evalGate(exp.targetingGate)) return { state: "out" };
|
|
583
|
+
const uid = pickIdentifier(user, exp.bucketBy);
|
|
584
|
+
if (!uid) return { state: "out" };
|
|
585
|
+
const universeSeg = murmur3(`${exp.universe}:${uid}`) % 1e4;
|
|
586
|
+
if (holdoutRange) {
|
|
587
|
+
const [lo, hi] = holdoutRange;
|
|
588
|
+
if (universeSeg >= lo && universeSeg <= hi) return { state: "holdout" };
|
|
589
|
+
}
|
|
590
|
+
if (exp.holdoutGate && evalGate(exp.holdoutGate)) return { state: "holdout" };
|
|
591
|
+
const salt8 = exp.salt.slice(0, 8);
|
|
592
|
+
if (sticky) {
|
|
593
|
+
const entry = sticky.get();
|
|
594
|
+
if (entry && entry.s === salt8) {
|
|
595
|
+
const g = exp.groups.find((x) => x.name === entry.g);
|
|
596
|
+
if (g) return asGroup(g);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const pooled = (exp.hashVersion ?? 1) >= 2 && exp.poolOffsetBp != null && exp.poolSizeBp != null && exp.poolSizeBp > 0;
|
|
600
|
+
if (pooled) {
|
|
601
|
+
const lo = exp.poolOffsetBp;
|
|
602
|
+
const hi = lo + exp.poolSizeBp;
|
|
603
|
+
if (universeSeg < lo || universeSeg >= hi) return { state: "out" };
|
|
604
|
+
} else {
|
|
605
|
+
if (murmur3(`${exp.salt}:alloc:${uid}`) % 1e4 >= exp.allocationPct) return { state: "out" };
|
|
606
|
+
}
|
|
607
|
+
const reserved = Math.max(0, Math.min(1e4, exp.reservedHeadroomBp ?? 0));
|
|
608
|
+
const usable = 1e4 - reserved;
|
|
609
|
+
const groupHash = murmur3(`${exp.salt}:group:${uid}`) % 1e4;
|
|
610
|
+
if (groupHash >= usable) return { state: "out" };
|
|
611
|
+
let cumulative = 0;
|
|
612
|
+
for (let i = 0; i < exp.groups.length; i++) {
|
|
613
|
+
const g = exp.groups[i];
|
|
614
|
+
cumulative += g.weight;
|
|
615
|
+
if (groupHash < cumulative || i === exp.groups.length - 1) {
|
|
616
|
+
sticky?.set({ g: g.name, s: salt8 });
|
|
617
|
+
return asGroup(g);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return { state: "out" };
|
|
621
|
+
}
|
|
622
|
+
var AssignmentImpl = class {
|
|
623
|
+
constructor(name, group, params) {
|
|
624
|
+
this.name = name;
|
|
625
|
+
this.group = group;
|
|
626
|
+
this.params = params;
|
|
627
|
+
}
|
|
628
|
+
name;
|
|
629
|
+
group;
|
|
630
|
+
params;
|
|
631
|
+
get enrolled() {
|
|
632
|
+
return this.group !== null;
|
|
633
|
+
}
|
|
634
|
+
get(field, fallback) {
|
|
635
|
+
const v = this.params[field];
|
|
636
|
+
return v === void 0 ? fallback : v;
|
|
637
|
+
}
|
|
638
|
+
};
|
|
504
639
|
var TRUE_RX = /^(true|on|1|yes)$/i;
|
|
505
640
|
var FALSE_RX = /^(false|off|0|no)$/i;
|
|
506
641
|
function parseOverrideBool(raw) {
|
|
@@ -552,6 +687,13 @@ var Engine = class _Engine {
|
|
|
552
687
|
env;
|
|
553
688
|
privateAttributes;
|
|
554
689
|
stickyStore;
|
|
690
|
+
// Master network gate — false ⇒ the SDK never touches the network (no fetch,
|
|
691
|
+
// no track, no telemetry, no internal error reports). Defaults to
|
|
692
|
+
// environment-derived (prod-on), forced false by testMode.
|
|
693
|
+
networkEnabled;
|
|
694
|
+
// Convenience inverse of networkEnabled — the "no network at all" state that
|
|
695
|
+
// every fetch/track/init gate keys on. `testMode` implies offline.
|
|
696
|
+
offline;
|
|
555
697
|
telemetry;
|
|
556
698
|
seeLimiter = new SeeLimiter();
|
|
557
699
|
flagsBlob = null;
|
|
@@ -571,6 +713,9 @@ var Engine = class _Engine {
|
|
|
571
713
|
flagOverrides = /* @__PURE__ */ new Map();
|
|
572
714
|
configOverrides = /* @__PURE__ */ new Map();
|
|
573
715
|
experimentOverrides = /* @__PURE__ */ new Map();
|
|
716
|
+
// Bounded per-process exposure dedup (`uid:exp:group`) so auto-exposure from
|
|
717
|
+
// repeated assign() calls doesn't spam /collect. Cleared past a soft cap.
|
|
718
|
+
exposureSeen = /* @__PURE__ */ new Set();
|
|
574
719
|
// Change listeners fired after a background poll returns NEW data (200, not
|
|
575
720
|
// 304). Never fired in testMode/offline (no polling happens there).
|
|
576
721
|
changeListeners = /* @__PURE__ */ new Set();
|
|
@@ -582,15 +727,24 @@ var Engine = class _Engine {
|
|
|
582
727
|
this.privateAttributes = opts.privateAttributes ?? [];
|
|
583
728
|
this.stickyStore = opts.stickyStore;
|
|
584
729
|
this.testMode = opts.testMode === true;
|
|
730
|
+
const prod = isProductionEnv(this.env);
|
|
731
|
+
this.networkEnabled = this.testMode ? false : opts.isNetworkEnabled ?? prod;
|
|
732
|
+
this.offline = !this.networkEnabled;
|
|
733
|
+
setInternalReportContext({
|
|
734
|
+
side: "server",
|
|
735
|
+
sdkVersion: version,
|
|
736
|
+
enabled: this.networkEnabled && opts.disableInternalErrorReporting !== true
|
|
737
|
+
});
|
|
585
738
|
this.telemetry = new Telemetry({
|
|
586
739
|
endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
|
|
587
740
|
sdkKey: this.apiKey,
|
|
588
741
|
side: "server",
|
|
589
742
|
env: this.env,
|
|
590
|
-
//
|
|
591
|
-
|
|
743
|
+
// Off when the network is disabled; otherwise honour an explicit
|
|
744
|
+
// disableTelemetry, else default to prod-on (off outside production).
|
|
745
|
+
disabled: this.offline || (opts.disableTelemetry ?? !prod)
|
|
592
746
|
});
|
|
593
|
-
if (opts.initialBlob || this.
|
|
747
|
+
if (opts.initialBlob || this.offline) {
|
|
594
748
|
this.flagsBlob = opts.initialBlob ?? { version: "test", plan: "free", gates: {}, configs: {}, killswitches: {} };
|
|
595
749
|
this.expsBlob = this.expsBlob ?? { version: "test", universes: {}, experiments: {} };
|
|
596
750
|
this.initialized = true;
|
|
@@ -641,7 +795,7 @@ var Engine = class _Engine {
|
|
|
641
795
|
return _Engine.fromSnapshot(snapshot);
|
|
642
796
|
}
|
|
643
797
|
async init() {
|
|
644
|
-
if (this.
|
|
798
|
+
if (this.offline) {
|
|
645
799
|
this.initialized = true;
|
|
646
800
|
return;
|
|
647
801
|
}
|
|
@@ -650,7 +804,7 @@ var Engine = class _Engine {
|
|
|
650
804
|
this.startPoll();
|
|
651
805
|
}
|
|
652
806
|
async initOnce() {
|
|
653
|
-
if (this.
|
|
807
|
+
if (this.offline || this.initialized) return;
|
|
654
808
|
await this.fetchAll();
|
|
655
809
|
this.initialized = true;
|
|
656
810
|
}
|
|
@@ -792,68 +946,77 @@ var Engine = class _Engine {
|
|
|
792
946
|
return "defaultValue" in opts ? opts.defaultValue : void 0;
|
|
793
947
|
}
|
|
794
948
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
949
|
+
/**
|
|
950
|
+
* Bind the per-experiment sticky seam over the configured {@link StickyBucketStore}
|
|
951
|
+
* for one (unit, experiment). Absent store ⇒ deterministic (no sticky).
|
|
952
|
+
*/
|
|
953
|
+
bindSticky(name, uid) {
|
|
954
|
+
const store = this.stickyStore;
|
|
955
|
+
if (!store) return void 0;
|
|
956
|
+
return {
|
|
957
|
+
get: () => store.get(uid)?.[name],
|
|
958
|
+
set: (entry) => store.set(uid, name, entry)
|
|
801
959
|
};
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Evaluate one experiment by name for `user` — override → full classify
|
|
963
|
+
* pipeline (targeting → universe holdout → holdout gate → sticky → allocation
|
|
964
|
+
* → group), merging the universe defaults under the assigned variant (§B2).
|
|
965
|
+
* Internal: the public surface is `universe(name).assign(user)`. Reused by the
|
|
966
|
+
* SSR `evaluate()` bootstrap (keyed by experiment name) and by `assignUniverse`.
|
|
967
|
+
*/
|
|
968
|
+
evalExperiment(name, exp, user) {
|
|
969
|
+
const paramDefaults = paramDefaultsFromSchema(
|
|
970
|
+
this.expsBlob?.universes[exp.universe]?.param_schema
|
|
971
|
+
);
|
|
802
972
|
const ov = this.experimentOverrides.get(name);
|
|
803
|
-
if (ov) {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
if (!this.flagsBlob || !this.expsBlob) return notIn;
|
|
813
|
-
const exp = this.expsBlob.experiments[name];
|
|
814
|
-
if (!exp || exp.status !== "running") return notIn;
|
|
815
|
-
if (exp.targetingGate) {
|
|
816
|
-
const gate = this.flagsBlob.gates[exp.targetingGate];
|
|
817
|
-
if (!gate || !evalGateInternal(gate, user)) return notIn;
|
|
818
|
-
}
|
|
819
|
-
const uid = pickIdentifier(user, exp.bucketBy);
|
|
820
|
-
if (!uid) return notIn;
|
|
821
|
-
const universe = this.expsBlob.universes[exp.universe];
|
|
822
|
-
const holdoutRange = universe?.holdout_range ?? null;
|
|
823
|
-
if (holdoutRange) {
|
|
824
|
-
const seg = murmur3(`${exp.universe}:${uid}`) % 1e4;
|
|
825
|
-
const [lo, hi] = holdoutRange;
|
|
826
|
-
if (seg >= lo && seg <= hi) return notIn;
|
|
827
|
-
}
|
|
828
|
-
const asResult = (g) => {
|
|
829
|
-
if (!decode) return { inExperiment: true, group: g.name, params: g.params };
|
|
830
|
-
try {
|
|
831
|
-
return { inExperiment: true, group: g.name, params: decode(g.params) };
|
|
832
|
-
} catch (err) {
|
|
833
|
-
logger.warn(`[shipeasy] getExperiment('${name}') decode failed:`, String(err));
|
|
834
|
-
return notIn;
|
|
835
|
-
}
|
|
973
|
+
if (ov) return { state: "group", group: ov.group, params: mergeParams(paramDefaults, ov.params) };
|
|
974
|
+
if (!this.flagsBlob || !this.expsBlob) return { state: "out" };
|
|
975
|
+
if (exp.status !== "running") return { state: "out" };
|
|
976
|
+
const holdoutRange = this.expsBlob.universes[exp.universe]?.holdout_range ?? null;
|
|
977
|
+
const evalGate = (gname) => {
|
|
978
|
+
const gate = this.flagsBlob?.gates[gname];
|
|
979
|
+
return gate ? evalGateInternal(gate, user) : false;
|
|
836
980
|
};
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
981
|
+
const uid = pickIdentifier(user, exp.bucketBy);
|
|
982
|
+
const sticky = uid ? this.bindSticky(name, uid) : void 0;
|
|
983
|
+
return classifyExperimentLocal(exp, user, holdoutRange, paramDefaults, evalGate, sticky);
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Assign `user` within `universeName`. A universe is a mutual-exclusion pool,
|
|
987
|
+
* so a unit lands in **at most one** experiment; the returned {@link Assignment}
|
|
988
|
+
* exposes the variant + resolved params and auto-logs a single exposure when
|
|
989
|
+
* enrolled. An un-enrolled unit still resolves `get()` to the universe defaults.
|
|
990
|
+
* Never throws. This is the sole experiment read path (there is no
|
|
991
|
+
* `getExperiment` — a caller asks a universe, not an experiment).
|
|
992
|
+
*/
|
|
993
|
+
assignUniverse(universeName, user) {
|
|
994
|
+
this.telemetry.emit("experiment", universeName);
|
|
995
|
+
const paramDefaults = paramDefaultsFromSchema(
|
|
996
|
+
this.expsBlob?.universes[universeName]?.param_schema
|
|
997
|
+
);
|
|
998
|
+
const notEnrolled = () => new AssignmentImpl(null, null, paramDefaults ?? {});
|
|
999
|
+
if (!this.expsBlob) return notEnrolled();
|
|
1000
|
+
const candidates = Object.entries(this.expsBlob.experiments).filter(([, e]) => e.universe === universeName && e.status === "running").sort(
|
|
1001
|
+
(a, b) => (a[1].poolOffsetBp ?? 0) - (b[1].poolOffsetBp ?? 0) || a[0].localeCompare(b[0])
|
|
1002
|
+
);
|
|
1003
|
+
for (const [name, exp] of candidates) {
|
|
1004
|
+
const c = this.evalExperiment(name, exp, user);
|
|
1005
|
+
if (c.state === "group") {
|
|
1006
|
+
this.postExposure(user, name, c.group);
|
|
1007
|
+
return new AssignmentImpl(name, c.group, c.params ?? {});
|
|
854
1008
|
}
|
|
855
1009
|
}
|
|
856
|
-
return
|
|
1010
|
+
return notEnrolled();
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* The universe-first experiment read entry point:
|
|
1014
|
+
* `engine.universe("checkout").assign(user)`. Returns a reusable handle bound
|
|
1015
|
+
* to one universe; `assign(user)` picks the ≤1 experiment the unit is pooled
|
|
1016
|
+
* into and auto-logs a single exposure. See {@link assignUniverse}.
|
|
1017
|
+
*/
|
|
1018
|
+
universe(name) {
|
|
1019
|
+
return { assign: (user) => this.assignUniverse(name, user) };
|
|
857
1020
|
}
|
|
858
1021
|
/** Drop caller-marked private attributes from an outbound props bag. */
|
|
859
1022
|
stripPrivate(props) {
|
|
@@ -865,7 +1028,7 @@ var Engine = class _Engine {
|
|
|
865
1028
|
return out;
|
|
866
1029
|
}
|
|
867
1030
|
track(userId, eventName, props) {
|
|
868
|
-
if (this.
|
|
1031
|
+
if (this.offline) return;
|
|
869
1032
|
const safeProps = this.stripPrivate(props);
|
|
870
1033
|
const body = JSON.stringify({
|
|
871
1034
|
events: [
|
|
@@ -885,26 +1048,26 @@ var Engine = class _Engine {
|
|
|
885
1048
|
}).catch((err) => logger.warn("[shipeasy] track failed:", String(err)));
|
|
886
1049
|
}
|
|
887
1050
|
/**
|
|
888
|
-
*
|
|
889
|
-
* (
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
* as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
|
|
893
|
-
* `/collect`. No-op in test mode or when the user isn't enrolled.
|
|
1051
|
+
* POST a single exposure for an enrolled `(user, experiment, group)`. Deduped
|
|
1052
|
+
* per process (bounded set) so repeated `assign()` calls in one server don't
|
|
1053
|
+
* spam `/collect`. Fire-and-forget; no-op when the network is disabled. This
|
|
1054
|
+
* is how `assignUniverse` auto-logs — the browser's auto-exposure parity for SSR.
|
|
894
1055
|
*/
|
|
895
|
-
|
|
896
|
-
if (this.
|
|
897
|
-
const
|
|
898
|
-
const
|
|
899
|
-
if (
|
|
1056
|
+
postExposure(user, experiment, group) {
|
|
1057
|
+
if (this.offline) return;
|
|
1058
|
+
const uid = user.user_id ?? user.anonymous_id;
|
|
1059
|
+
const dedupKey = `${uid ?? ""}:${experiment}:${group}`;
|
|
1060
|
+
if (this.exposureSeen.has(dedupKey)) return;
|
|
1061
|
+
if (this.exposureSeen.size > 5e3) this.exposureSeen.clear();
|
|
1062
|
+
this.exposureSeen.add(dedupKey);
|
|
900
1063
|
const body = JSON.stringify({
|
|
901
1064
|
events: [
|
|
902
1065
|
{
|
|
903
1066
|
type: "exposure",
|
|
904
|
-
experiment
|
|
905
|
-
group
|
|
906
|
-
...
|
|
907
|
-
...
|
|
1067
|
+
experiment,
|
|
1068
|
+
group,
|
|
1069
|
+
...user.user_id !== void 0 ? { user_id: user.user_id } : {},
|
|
1070
|
+
...user.anonymous_id !== void 0 ? { anonymous_id: user.anonymous_id } : {},
|
|
908
1071
|
ts: Date.now()
|
|
909
1072
|
}
|
|
910
1073
|
]
|
|
@@ -913,7 +1076,7 @@ var Engine = class _Engine {
|
|
|
913
1076
|
method: "POST",
|
|
914
1077
|
headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
|
|
915
1078
|
body
|
|
916
|
-
}).catch((err) => logger.warn("[shipeasy]
|
|
1079
|
+
}).catch((err) => logger.warn("[shipeasy] exposure send failed:", String(err)));
|
|
917
1080
|
}
|
|
918
1081
|
/**
|
|
919
1082
|
* Report a structured error into the errors primitive. Fire-and-forget —
|
|
@@ -921,6 +1084,7 @@ var Engine = class _Engine {
|
|
|
921
1084
|
* dedup window + per-process cap.
|
|
922
1085
|
*/
|
|
923
1086
|
reportError(problem, consequence, extras, kind) {
|
|
1087
|
+
if (this.offline) return;
|
|
924
1088
|
try {
|
|
925
1089
|
const correlationId = seeContext.getStore()?.correlationId;
|
|
926
1090
|
const ev = buildSeeEvent(problem, consequence, extras, {
|
|
@@ -959,8 +1123,17 @@ var Engine = class _Engine {
|
|
|
959
1123
|
this.telemetry.emit("config", name);
|
|
960
1124
|
configs[name] = entry.value;
|
|
961
1125
|
}
|
|
962
|
-
|
|
963
|
-
|
|
1126
|
+
const universes = {};
|
|
1127
|
+
for (const [name, exp] of Object.entries(this.expsBlob?.experiments ?? {})) {
|
|
1128
|
+
this.telemetry.emit("experiment", exp.universe);
|
|
1129
|
+
const uniName = exp.universe;
|
|
1130
|
+
if (!(uniName in universes)) {
|
|
1131
|
+
universes[uniName] = {
|
|
1132
|
+
defaults: paramDefaultsFromSchema(this.expsBlob?.universes[uniName]?.param_schema) ?? {}
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
const c = this.evalExperiment(name, exp, user);
|
|
1136
|
+
experiments[name] = c.state === "group" ? { inExperiment: true, group: c.group, params: c.params ?? {}, universe: uniName } : { inExperiment: false, group: "control", params: {}, universe: uniName };
|
|
964
1137
|
}
|
|
965
1138
|
for (const [name, ks] of Object.entries(this.flagsBlob?.killswitches ?? {})) {
|
|
966
1139
|
this.telemetry.emit("ks", name);
|
|
@@ -977,10 +1150,11 @@ var Engine = class _Engine {
|
|
|
977
1150
|
Object.assign(flags2, ov.gates);
|
|
978
1151
|
Object.assign(configs, ov.configs);
|
|
979
1152
|
for (const [name, group] of Object.entries(ov.experiments)) {
|
|
980
|
-
experiments[name]
|
|
1153
|
+
const uni = this.expsBlob?.experiments[name]?.universe;
|
|
1154
|
+
experiments[name] = { inExperiment: true, group, params: {}, ...uni ? { universe: uni } : {} };
|
|
981
1155
|
}
|
|
982
1156
|
}
|
|
983
|
-
return { flags: flags2, configs, experiments, killswitches };
|
|
1157
|
+
return { flags: flags2, configs, experiments, killswitches, universes };
|
|
984
1158
|
}
|
|
985
1159
|
getKillswitch(name, switchKey) {
|
|
986
1160
|
this.telemetry.emit("ks", name);
|
|
@@ -1127,7 +1301,9 @@ async function shipeasy(opts) {
|
|
|
1127
1301
|
const profile = opts.i18nDefaultProfile ?? "en:prod";
|
|
1128
1302
|
flags.configure({
|
|
1129
1303
|
apiKey: serverKey,
|
|
1304
|
+
isNetworkEnabled: opts.isNetworkEnabled,
|
|
1130
1305
|
disableTelemetry: opts.disableTelemetry,
|
|
1306
|
+
disableInternalErrorReporting: opts.disableInternalErrorReporting,
|
|
1131
1307
|
privateAttributes: opts.privateAttributes,
|
|
1132
1308
|
logLevel: opts.logLevel
|
|
1133
1309
|
});
|
|
@@ -1272,13 +1448,22 @@ var flags = {
|
|
|
1272
1448
|
() => _server?.getConfig(name, decodeOrOpts)
|
|
1273
1449
|
);
|
|
1274
1450
|
},
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1451
|
+
/**
|
|
1452
|
+
* Assign `user` within a universe: `flags.universe("checkout").assign(user)`.
|
|
1453
|
+
* A universe is a mutual-exclusion pool, so the unit lands in ≤1 experiment;
|
|
1454
|
+
* the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
|
|
1455
|
+
* and auto-logs one exposure when enrolled. Before configure() (or on any
|
|
1456
|
+
* error) it returns a safe not-enrolled handle. This replaces the removed
|
|
1457
|
+
* `getExperiment` — read experiments by universe, never by name.
|
|
1458
|
+
*/
|
|
1459
|
+
universe(name) {
|
|
1460
|
+
return {
|
|
1461
|
+
assign: (user) => safeRun(
|
|
1462
|
+
"flags.universe.assign",
|
|
1463
|
+
new AssignmentImpl(null, null, {}),
|
|
1464
|
+
() => _server?.assignUniverse(name, user) ?? new AssignmentImpl(null, null, {})
|
|
1465
|
+
)
|
|
1466
|
+
};
|
|
1282
1467
|
},
|
|
1283
1468
|
/**
|
|
1284
1469
|
* Read a killswitch. Without `switchKey`, returns true when the whole
|
|
@@ -1291,11 +1476,6 @@ var flags = {
|
|
|
1291
1476
|
track(userId, eventName, props) {
|
|
1292
1477
|
safeRun("flags.track", void 0, () => _server?.track(userId, eventName, props));
|
|
1293
1478
|
},
|
|
1294
|
-
/** Emit an exposure for an enrolled experiment at the decision point. See
|
|
1295
|
-
* {@link Engine.logExposure}. No-op before configure(). */
|
|
1296
|
-
logExposure(user, name) {
|
|
1297
|
-
safeRun("flags.logExposure", void 0, () => _server?.logExposure(user, name));
|
|
1298
|
-
},
|
|
1299
1479
|
/**
|
|
1300
1480
|
* Evaluate all flags / configs / experiments for a user against the locally
|
|
1301
1481
|
* cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
|
|
@@ -1412,13 +1592,20 @@ var Client = class {
|
|
|
1412
1592
|
() => this.engine.getConfig(name, decodeOrOpts)
|
|
1413
1593
|
);
|
|
1414
1594
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1595
|
+
/**
|
|
1596
|
+
* Assign the bound user within a universe: `client.universe("checkout").assign()`.
|
|
1597
|
+
* The user is already bound at construction, so `assign()` takes no arg. Returns
|
|
1598
|
+
* an {@link Assignment} (`.group` / `.get(field, fallback)`) and auto-logs one
|
|
1599
|
+
* exposure when enrolled. Replaces the removed `getExperiment`.
|
|
1600
|
+
*/
|
|
1601
|
+
universe(name) {
|
|
1602
|
+
return {
|
|
1603
|
+
assign: () => safeRun(
|
|
1604
|
+
"Client.universe.assign",
|
|
1605
|
+
new AssignmentImpl(null, null, {}),
|
|
1606
|
+
() => this.engine.assignUniverse(name, this.attributes)
|
|
1607
|
+
)
|
|
1608
|
+
};
|
|
1422
1609
|
}
|
|
1423
1610
|
/** Read a killswitch (not user-bound; mirrors {@link Engine.getKillswitch}). */
|
|
1424
1611
|
getKillswitch(name, switchKey) {
|
|
@@ -1438,15 +1625,6 @@ var Client = class {
|
|
|
1438
1625
|
this.engine.track(String(id), eventName, props);
|
|
1439
1626
|
});
|
|
1440
1627
|
}
|
|
1441
|
-
/**
|
|
1442
|
-
* Emit an exposure event for `name` at this server-side decision point for the
|
|
1443
|
-
* bound user. Delegates to {@link Engine.logExposure} with the resolved
|
|
1444
|
-
* attribute bag (re-evaluates enrolment; no-op when the user isn't enrolled or
|
|
1445
|
-
* in test mode).
|
|
1446
|
-
*/
|
|
1447
|
-
logExposure(name) {
|
|
1448
|
-
safeRun("Client.logExposure", void 0, () => this.engine.logExposure(this.attributes, name));
|
|
1449
|
-
}
|
|
1450
1628
|
};
|
|
1451
1629
|
function dispatchSee(problem, consequence, extras, kind) {
|
|
1452
1630
|
if (!_server) {
|