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