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