@decocms/blocks 7.48.3 → 7.49.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/package.json +2 -1
- package/src/cms/loader.ts +1 -1
- package/src/cms/resolve.ts +15 -15
- package/src/sdk/experiments.test.ts +328 -0
- package/src/sdk/experiments.ts +314 -0
- package/src/sdk/flags.ts +38 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.49.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"./sdk/env": "./src/sdk/env.ts",
|
|
57
57
|
"./sdk/fetchCache": "./src/sdk/fetchCache.ts",
|
|
58
58
|
"./sdk/fetchTimeout": "./src/sdk/fetchTimeout.ts",
|
|
59
|
+
"./sdk/experiments": "./src/sdk/experiments.ts",
|
|
59
60
|
"./sdk/flags": "./src/sdk/flags.ts",
|
|
60
61
|
"./sdk/http": "./src/sdk/http.ts",
|
|
61
62
|
"./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts",
|
package/src/cms/loader.ts
CHANGED
|
@@ -403,7 +403,7 @@ export function getSiteSeo(): {
|
|
|
403
403
|
noIndexing?: boolean;
|
|
404
404
|
} {
|
|
405
405
|
const blocks = loadBlocks();
|
|
406
|
-
const site = blocks["Site"] as Record<string, unknown> | undefined;
|
|
406
|
+
const site = (blocks["Site"] ?? blocks["site"]) as Record<string, unknown> | undefined;
|
|
407
407
|
if (!site) return {};
|
|
408
408
|
const seo = site.seo as Record<string, unknown> | undefined;
|
|
409
409
|
if (!seo) return {};
|
package/src/cms/resolve.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override";
|
|
2
2
|
import { getMeter, MetricNames, withTracing } from "../middleware/observability";
|
|
3
3
|
import { djb2Hex } from "../sdk/djb2";
|
|
4
|
+
import { stickyDecide } from "../sdk/experiments";
|
|
4
5
|
import { parseSegmentCookie, SEGMENT_COOKIE, type StoredFlag, trafficToPct } from "../sdk/flags";
|
|
5
6
|
import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
6
7
|
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
@@ -758,21 +759,20 @@ function evaluateVariantRule(
|
|
|
758
759
|
// production concern (previews aren't cached), so skipping it here is fine.
|
|
759
760
|
if (!meta || hasMatchersOverride(ctx)) return evaluateMatcher(rule, ctx);
|
|
760
761
|
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
return value;
|
|
762
|
+
// Shared with sdk/experiments.ts' N-way resolution — same reuse / stay-sticky
|
|
763
|
+
// / re-roll precedence, so the two assignment paths cannot drift. This path
|
|
764
|
+
// keeps its own `trafficToPct` fingerprint: swapping it for the experiment
|
|
765
|
+
// weight hash would re-roll every live visitor on the next deploy.
|
|
766
|
+
return stickyDecide<boolean>({
|
|
767
|
+
name: meta.name,
|
|
768
|
+
fingerprint: meta.pct,
|
|
769
|
+
recorded: ctx.flags,
|
|
770
|
+
stored: parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]),
|
|
771
|
+
roll: () => Math.random() < meta.traffic,
|
|
772
|
+
// A matcher decision is always boolean; a string here means an experiment
|
|
773
|
+
// took the same cookie slot. Re-roll rather than coerce a variant id.
|
|
774
|
+
accepts: (v) => typeof v === "boolean",
|
|
775
|
+
}).value;
|
|
776
776
|
}
|
|
777
777
|
|
|
778
778
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
type ExperimentConfig,
|
|
4
|
+
pickWeightedVariant,
|
|
5
|
+
readExperimentConfig,
|
|
6
|
+
resolveExperimentVariant,
|
|
7
|
+
weightsFingerprint,
|
|
8
|
+
} from "./experiments";
|
|
9
|
+
import {
|
|
10
|
+
parseSegmentCookie,
|
|
11
|
+
type StoredFlag,
|
|
12
|
+
segmentCacheToken,
|
|
13
|
+
serializeSegmentCookie,
|
|
14
|
+
} from "./flags";
|
|
15
|
+
|
|
16
|
+
type Payload = { collectionId: string };
|
|
17
|
+
|
|
18
|
+
/** Contract 1's example document, verbatim. */
|
|
19
|
+
const CONFIG: ExperimentConfig<Payload> = {
|
|
20
|
+
version: 1,
|
|
21
|
+
experiments: [
|
|
22
|
+
{
|
|
23
|
+
key: "plp-ranking",
|
|
24
|
+
variants: [
|
|
25
|
+
{ id: "control", weight: 95, payload: { collectionId: "139" } },
|
|
26
|
+
{ id: "model-b", weight: 5, payload: { collectionId: "412" } },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Same experiment after a ramp stage advances 5% -> 10%. */
|
|
33
|
+
const RAMPED: ExperimentConfig<Payload> = {
|
|
34
|
+
version: 1,
|
|
35
|
+
experiments: [
|
|
36
|
+
{
|
|
37
|
+
key: "plp-ranking",
|
|
38
|
+
variants: [
|
|
39
|
+
{ id: "control", weight: 90, payload: { collectionId: "139" } },
|
|
40
|
+
{ id: "model-b", weight: 10, payload: { collectionId: "412" } },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One request: a cookie in, a fresh assignment sink, a fixed RNG sample.
|
|
48
|
+
* Mirrors what the framework threads through in production.
|
|
49
|
+
*/
|
|
50
|
+
function request(
|
|
51
|
+
config: ExperimentConfig<Payload> | null,
|
|
52
|
+
cookie: string | undefined,
|
|
53
|
+
rand: number,
|
|
54
|
+
) {
|
|
55
|
+
const assignments: StoredFlag[] = [];
|
|
56
|
+
const resolve = () =>
|
|
57
|
+
resolveExperimentVariant<Payload>("plp-ranking", {
|
|
58
|
+
config,
|
|
59
|
+
segmentCookie: cookie,
|
|
60
|
+
assignments,
|
|
61
|
+
random: () => rand,
|
|
62
|
+
});
|
|
63
|
+
// The cookie the framework would write back (persistFlags merges the same way).
|
|
64
|
+
const nextCookie = () =>
|
|
65
|
+
serializeSegmentCookie([
|
|
66
|
+
...parseSegmentCookie(cookie).filter((f) => !assignments.some((a) => a.name === f.name)),
|
|
67
|
+
...assignments,
|
|
68
|
+
]);
|
|
69
|
+
return { resolve, assignments, nextCookie };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("pickWeightedVariant", () => {
|
|
73
|
+
const variants = CONFIG.experiments[0].variants;
|
|
74
|
+
|
|
75
|
+
it("splits the range in proportion to the weights", () => {
|
|
76
|
+
expect(pickWeightedVariant(variants, 0)).toBe("control");
|
|
77
|
+
expect(pickWeightedVariant(variants, 0.94)).toBe("control");
|
|
78
|
+
expect(pickWeightedVariant(variants, 0.96)).toBe("model-b");
|
|
79
|
+
// 0.9999 must not fall off the end into `undefined`.
|
|
80
|
+
expect(pickWeightedVariant(variants, 0.9999)).toBe("model-b");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("holds the distribution over many draws", () => {
|
|
84
|
+
let modelB = 0;
|
|
85
|
+
for (let i = 0; i < 10_000; i++) {
|
|
86
|
+
if (pickWeightedVariant(variants, i / 10_000) === "model-b") modelB++;
|
|
87
|
+
}
|
|
88
|
+
expect(modelB / 10_000).toBeCloseTo(0.05, 2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("handles N > 2 arms", () => {
|
|
92
|
+
const three = [
|
|
93
|
+
{ id: "a", weight: 50, payload: {} },
|
|
94
|
+
{ id: "b", weight: 30, payload: {} },
|
|
95
|
+
{ id: "c", weight: 20, payload: {} },
|
|
96
|
+
];
|
|
97
|
+
expect(pickWeightedVariant(three, 0.1)).toBe("a");
|
|
98
|
+
expect(pickWeightedVariant(three, 0.6)).toBe("b");
|
|
99
|
+
expect(pickWeightedVariant(three, 0.9)).toBe("c");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("still splits proportionally when weights violate the sum-to-100 contract", () => {
|
|
103
|
+
const bad = [
|
|
104
|
+
{ id: "a", weight: 1, payload: {} },
|
|
105
|
+
{ id: "b", weight: 1, payload: {} },
|
|
106
|
+
];
|
|
107
|
+
expect(pickWeightedVariant(bad, 0.25)).toBe("a");
|
|
108
|
+
expect(pickWeightedVariant(bad, 0.75)).toBe("b");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("weightsFingerprint", () => {
|
|
113
|
+
it("changes when a weight changes", () => {
|
|
114
|
+
expect(weightsFingerprint(CONFIG.experiments[0].variants)).not.toBe(
|
|
115
|
+
weightsFingerprint(RAMPED.experiments[0].variants),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("is stable under variant reordering", () => {
|
|
120
|
+
const reversed = [...CONFIG.experiments[0].variants].reverse();
|
|
121
|
+
expect(weightsFingerprint(reversed)).toBe(weightsFingerprint(CONFIG.experiments[0].variants));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("never collides with the legacy -1 sentinel", () => {
|
|
125
|
+
expect(weightsFingerprint(CONFIG.experiments[0].variants)).toBeGreaterThanOrEqual(0);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("resolveExperimentVariant — first assignment", () => {
|
|
130
|
+
it("assigns a fresh visitor and reports isFresh", async () => {
|
|
131
|
+
const { resolve, assignments } = request(CONFIG, undefined, 0.99);
|
|
132
|
+
const variant = await resolve();
|
|
133
|
+
|
|
134
|
+
expect(variant).toEqual({
|
|
135
|
+
experimentKey: "plp-ranking",
|
|
136
|
+
variantId: "model-b",
|
|
137
|
+
payload: { collectionId: "412" },
|
|
138
|
+
isFresh: true,
|
|
139
|
+
});
|
|
140
|
+
expect(assignments).toEqual([
|
|
141
|
+
{
|
|
142
|
+
name: "plp-ranking",
|
|
143
|
+
value: "model-b",
|
|
144
|
+
pct: weightsFingerprint(CONFIG.experiments[0].variants),
|
|
145
|
+
},
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("routes the majority of fresh visitors to the heavy arm", async () => {
|
|
150
|
+
const { resolve } = request(CONFIG, undefined, 0.5);
|
|
151
|
+
expect((await resolve())?.variantId).toBe("control");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns null when no config is published", async () => {
|
|
155
|
+
expect(await request(null, undefined, 0.5).resolve()).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns null when the key is not among the active experiments", async () => {
|
|
159
|
+
const other: ExperimentConfig<Payload> = { version: 1, experiments: [] };
|
|
160
|
+
expect(await resolveExperimentVariant<Payload>("plp-ranking", { config: other })).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("resolves once per request even when several loaders ask", async () => {
|
|
164
|
+
const { resolve, assignments } = request(CONFIG, undefined, 0.99);
|
|
165
|
+
const [a, b] = [await resolve(), await resolve()];
|
|
166
|
+
|
|
167
|
+
expect(b?.variantId).toBe(a?.variantId);
|
|
168
|
+
expect(b?.isFresh).toBe(false); // only the first call assigned
|
|
169
|
+
expect(assignments).toHaveLength(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("resolveExperimentVariant — stickiness across requests", () => {
|
|
174
|
+
it("re-serves the stored variant without re-rolling", async () => {
|
|
175
|
+
const first = request(CONFIG, undefined, 0.99);
|
|
176
|
+
expect((await first.resolve())?.variantId).toBe("model-b");
|
|
177
|
+
const cookie = first.nextCookie();
|
|
178
|
+
|
|
179
|
+
// Next request: an RNG sample that WOULD have drawn "control".
|
|
180
|
+
const second = request(CONFIG, cookie, 0.1);
|
|
181
|
+
const variant = await second.resolve();
|
|
182
|
+
|
|
183
|
+
expect(variant?.variantId).toBe("model-b");
|
|
184
|
+
expect(variant?.payload).toEqual({ collectionId: "412" });
|
|
185
|
+
expect(variant?.isFresh).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("stays sticky over many requests with adversarial RNG", async () => {
|
|
189
|
+
let cookie = request(CONFIG, undefined, 0.99).nextCookie();
|
|
190
|
+
const first = request(CONFIG, undefined, 0.99);
|
|
191
|
+
await first.resolve();
|
|
192
|
+
cookie = first.nextCookie();
|
|
193
|
+
|
|
194
|
+
for (const rand of [0, 0.2, 0.5, 0.94, 0.999]) {
|
|
195
|
+
const req = request(CONFIG, cookie, rand);
|
|
196
|
+
expect((await req.resolve())?.variantId).toBe("model-b");
|
|
197
|
+
cookie = req.nextCookie();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("honours a classic-deco cookie that carries no fingerprint", async () => {
|
|
202
|
+
// pct absent -> parses as -1 -> must stick, not churn every legacy visitor.
|
|
203
|
+
const legacy = btoa(encodeURIComponent(JSON.stringify({ exp: { "plp-ranking": "model-b" } })));
|
|
204
|
+
const variant = await request(CONFIG, legacy, 0.1).resolve();
|
|
205
|
+
|
|
206
|
+
expect(variant?.variantId).toBe("model-b");
|
|
207
|
+
expect(variant?.isFresh).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("re-rolls a visitor holding a variant that no longer exists", async () => {
|
|
211
|
+
const stale = serializeSegmentCookie([
|
|
212
|
+
{
|
|
213
|
+
name: "plp-ranking",
|
|
214
|
+
value: "model-z",
|
|
215
|
+
pct: weightsFingerprint(CONFIG.experiments[0].variants),
|
|
216
|
+
},
|
|
217
|
+
]);
|
|
218
|
+
const variant = await request(CONFIG, stale, 0.1).resolve();
|
|
219
|
+
|
|
220
|
+
expect(variant?.variantId).toBe("control");
|
|
221
|
+
expect(variant?.isFresh).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("resolveExperimentVariant — re-roll once, then stick, when weights change", () => {
|
|
226
|
+
it("re-rolls on the ramp and re-sticks on the new weights", async () => {
|
|
227
|
+
// Assigned under 95/5.
|
|
228
|
+
const first = request(CONFIG, undefined, 0.99);
|
|
229
|
+
expect((await first.resolve())?.variantId).toBe("model-b");
|
|
230
|
+
const beforeRamp = first.nextCookie();
|
|
231
|
+
|
|
232
|
+
// Ramp to 90/10: the stored fingerprint goes stale -> exactly one re-roll.
|
|
233
|
+
const rerolled = request(RAMPED, beforeRamp, 0.5);
|
|
234
|
+
const afterRamp = await rerolled.resolve();
|
|
235
|
+
expect(afterRamp?.isFresh).toBe(true);
|
|
236
|
+
expect(afterRamp?.variantId).toBe("control"); // 0.5 lands in the 90% arm
|
|
237
|
+
const afterRampCookie = rerolled.nextCookie();
|
|
238
|
+
|
|
239
|
+
// Every subsequent request sticks — no second re-roll.
|
|
240
|
+
for (const rand of [0.99, 0.01, 0.5]) {
|
|
241
|
+
const req = request(RAMPED, afterRampCookie, rand);
|
|
242
|
+
const variant = await req.resolve();
|
|
243
|
+
expect(variant?.variantId).toBe("control");
|
|
244
|
+
expect(variant?.isFresh).toBe(false);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("writes the new fingerprint so the re-roll cannot repeat", async () => {
|
|
249
|
+
const first = request(CONFIG, undefined, 0.99);
|
|
250
|
+
await first.resolve();
|
|
251
|
+
|
|
252
|
+
const rerolled = request(RAMPED, first.nextCookie(), 0.5);
|
|
253
|
+
await rerolled.resolve();
|
|
254
|
+
|
|
255
|
+
expect(rerolled.assignments[0].pct).toBe(weightsFingerprint(RAMPED.experiments[0].variants));
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("deco_segment carries the assignment (contract 4)", () => {
|
|
260
|
+
it("stores the variant id under the experiment key", async () => {
|
|
261
|
+
const req = request(CONFIG, undefined, 0.99);
|
|
262
|
+
await req.resolve();
|
|
263
|
+
|
|
264
|
+
const payload = JSON.parse(decodeURIComponent(atob(req.nextCookie())));
|
|
265
|
+
expect(payload.exp).toEqual({ "plp-ranking": "model-b" });
|
|
266
|
+
// Boolean buckets stay untouched, so analytics reading `active` is unaffected.
|
|
267
|
+
expect(payload.active).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("coexists with a CMS boolean flag in the same cookie", async () => {
|
|
271
|
+
const withFlag = serializeSegmentCookie([{ name: "TestHero", value: true, pct: 50 }]);
|
|
272
|
+
const req = request(CONFIG, withFlag, 0.99);
|
|
273
|
+
await req.resolve();
|
|
274
|
+
|
|
275
|
+
const payload = JSON.parse(decodeURIComponent(atob(req.nextCookie())));
|
|
276
|
+
expect(payload.active).toEqual(["TestHero"]);
|
|
277
|
+
expect(payload.exp).toEqual({ "plp-ranking": "model-b" });
|
|
278
|
+
expect(parseSegmentCookie(req.nextCookie())).toEqual(
|
|
279
|
+
expect.arrayContaining([
|
|
280
|
+
{ name: "TestHero", value: true, pct: 50 },
|
|
281
|
+
{ name: "plp-ranking", value: "model-b", pct: expect.any(Number) },
|
|
282
|
+
]),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("folds into the CDN cache key so two variants never share cached HTML", async () => {
|
|
287
|
+
const a = request(CONFIG, undefined, 0.99);
|
|
288
|
+
const b = request(CONFIG, undefined, 0.1);
|
|
289
|
+
await a.resolve();
|
|
290
|
+
await b.resolve();
|
|
291
|
+
|
|
292
|
+
const tokenA = segmentCacheToken(parseSegmentCookie(a.nextCookie()));
|
|
293
|
+
const tokenB = segmentCacheToken(parseSegmentCookie(b.nextCookie()));
|
|
294
|
+
expect(tokenA).not.toBe(tokenB);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("keeps the cookie byte-identical to today when no experiment is assigned", () => {
|
|
298
|
+
const flags: StoredFlag[] = [{ name: "TestHero", value: true, pct: 50 }];
|
|
299
|
+
const payload = JSON.parse(decodeURIComponent(atob(serializeSegmentCookie(flags))));
|
|
300
|
+
expect("exp" in payload).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("readExperimentConfig", () => {
|
|
305
|
+
const kv = (value: unknown) => ({ get: async () => value as never });
|
|
306
|
+
|
|
307
|
+
it("reads contract 1's document", async () => {
|
|
308
|
+
expect(await readExperimentConfig(kv(CONFIG), "www.farmrio.com")).toEqual(CONFIG);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("returns null instead of throwing when the binding is missing", async () => {
|
|
312
|
+
expect(await readExperimentConfig(undefined, "www.farmrio.com")).toBeNull();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("returns null on an unset key or a malformed document", async () => {
|
|
316
|
+
expect(await readExperimentConfig(kv(null), "www.farmrio.com")).toBeNull();
|
|
317
|
+
expect(await readExperimentConfig(kv({ version: 1 }), "www.farmrio.com")).toBeNull();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("returns null when KV itself fails — never a thrown request", async () => {
|
|
321
|
+
const broken = {
|
|
322
|
+
get: async () => {
|
|
323
|
+
throw new Error("KV unavailable");
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
expect(await readExperimentConfig(broken, "www.farmrio.com")).toBeNull();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic variant resolution — sticky, self-healing, N-way, and independent of
|
|
3
|
+
* where the variant list comes from.
|
|
4
|
+
*
|
|
5
|
+
* ## What this is
|
|
6
|
+
*
|
|
7
|
+
* Two layers, deliberately separable:
|
|
8
|
+
*
|
|
9
|
+
* 1. {@link stickyDecide} — the assignment *policy*, extracted from
|
|
10
|
+
* `cms/resolve.ts`'s `evaluateVariantRule`: reuse the decision already made
|
|
11
|
+
* this request, else honour the `deco_segment` cookie while its fingerprint
|
|
12
|
+
* still matches, else roll fresh. Pure, synchronous, no CMS, no KV, no
|
|
13
|
+
* cookies. The CMS multivariate path and this module both call it, so the
|
|
14
|
+
* two cannot drift.
|
|
15
|
+
* 2. {@link resolveExperimentVariant} — the N-way experiment on top of it:
|
|
16
|
+
* reads config from KV, draws from a weight vector, records the assignment
|
|
17
|
+
* for the framework to persist into `deco_segment`.
|
|
18
|
+
*
|
|
19
|
+
* ## Self-healing re-roll
|
|
20
|
+
*
|
|
21
|
+
* The stored fingerprint is a hash of the *weight vector*. Change a weight (a
|
|
22
|
+
* ramp stage advancing 5% to 10%) and every stored assignment's fingerprint
|
|
23
|
+
* goes stale, so each visitor is re-rolled exactly once and then re-sticks on
|
|
24
|
+
* the new weights. The control plane never has to know this mechanic exists —
|
|
25
|
+
* hence "no fingerprint field" in the published config.
|
|
26
|
+
*
|
|
27
|
+
* ## Why not `sdk/abTesting.ts`
|
|
28
|
+
*
|
|
29
|
+
* That is a binary `"worker" | "fallback"` whole-request proxy for the
|
|
30
|
+
* migration period. Its bucket type, cookie, and fallback machinery are all
|
|
31
|
+
* two-way-specific. Only its `kv.get<T>(key, "json")` shape is borrowed here.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { djb2 } from "./djb2";
|
|
35
|
+
import { parseSegmentCookie, SEGMENT_COOKIE, type StoredFlag } from "./flags";
|
|
36
|
+
import { getRuntimeEnv } from "./otelAdapters";
|
|
37
|
+
import { RequestContext } from "./requestContext";
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Layer 1 — the extracted sticky/re-roll core
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** Outcome of a sticky decision. `isFresh` is true when it was rolled now. */
|
|
44
|
+
export interface StickyDecision<V> {
|
|
45
|
+
value: V;
|
|
46
|
+
isFresh: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sticky assignment with self-healing re-roll, over any value type.
|
|
51
|
+
*
|
|
52
|
+
* Precedence: a decision already recorded this request (so every resolve pass
|
|
53
|
+
* agrees) then the stored decision, if its fingerprint still matches, then a
|
|
54
|
+
* fresh roll. `fingerprint === -1` on the stored decision marks a classic-deco
|
|
55
|
+
* segment written without the blocks extension: honour it rather than
|
|
56
|
+
* re-rolling, or every legacy visitor churns on first contact.
|
|
57
|
+
*
|
|
58
|
+
* @param name Cohort identity — matcher block name or experiment key.
|
|
59
|
+
* @param fingerprint Current config fingerprint; a mismatch re-rolls once.
|
|
60
|
+
* @param recorded Decisions already made this request (mutated: the new one
|
|
61
|
+
* is pushed onto it). Undefined disables both dedupe and
|
|
62
|
+
* persistence.
|
|
63
|
+
* @param stored Decisions parsed from the visitor's cookie.
|
|
64
|
+
* @param roll Draws a fresh value. Called at most once.
|
|
65
|
+
* @param accepts Guards a stored value that is no longer valid — e.g. a
|
|
66
|
+
* variant deleted from the config. Rejected means re-roll.
|
|
67
|
+
*/
|
|
68
|
+
export function stickyDecide<V extends boolean | string>({
|
|
69
|
+
name,
|
|
70
|
+
fingerprint,
|
|
71
|
+
recorded,
|
|
72
|
+
stored,
|
|
73
|
+
roll,
|
|
74
|
+
accepts,
|
|
75
|
+
}: {
|
|
76
|
+
name: string;
|
|
77
|
+
fingerprint: number;
|
|
78
|
+
recorded: StoredFlag[] | undefined;
|
|
79
|
+
stored: StoredFlag[];
|
|
80
|
+
roll: () => V;
|
|
81
|
+
accepts?: (value: V) => boolean;
|
|
82
|
+
}): StickyDecision<V> {
|
|
83
|
+
const already = recorded?.find((f) => f.name === name && f.pct === fingerprint);
|
|
84
|
+
if (already) return { value: already.value as V, isFresh: false };
|
|
85
|
+
|
|
86
|
+
const prev = stored.find((f) => f.name === name);
|
|
87
|
+
const usable =
|
|
88
|
+
prev !== undefined &&
|
|
89
|
+
(prev.pct === -1 || prev.pct === fingerprint) &&
|
|
90
|
+
(!accepts || accepts(prev.value as V));
|
|
91
|
+
|
|
92
|
+
const value = usable ? (prev.value as V) : roll();
|
|
93
|
+
recorded?.push({ name, value, pct: fingerprint });
|
|
94
|
+
return { value, isFresh: !usable };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Layer 2 — experiments
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/** One arm of an experiment, as published by the control plane. */
|
|
102
|
+
export interface ExperimentVariant<P = unknown> {
|
|
103
|
+
id: string;
|
|
104
|
+
/** Integer share of traffic. Weights across an experiment must sum to 100. */
|
|
105
|
+
weight: number;
|
|
106
|
+
/** Opaque to the runtime — forwarded to the caller untouched. */
|
|
107
|
+
payload: P;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** An active experiment. */
|
|
111
|
+
export interface ExperimentDefinition<P = unknown> {
|
|
112
|
+
key: string;
|
|
113
|
+
variants: ExperimentVariant<P>[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Frozen contract 1 — the document written to KV by the control plane's
|
|
118
|
+
* `EXPERIMENT_ACTIVE_PUBLISH`, keyed by hostname. Only *active* experiments
|
|
119
|
+
* appear: status and time-window logic live in the control plane, so the
|
|
120
|
+
* runtime does no date math and parses no status.
|
|
121
|
+
*/
|
|
122
|
+
export interface ExperimentConfig<P = unknown> {
|
|
123
|
+
version: number;
|
|
124
|
+
experiments: ExperimentDefinition<P>[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Frozen contract 2 — what a caller gets back. */
|
|
128
|
+
export interface ResolvedVariant<P = unknown> {
|
|
129
|
+
experimentKey: string;
|
|
130
|
+
variantId: string;
|
|
131
|
+
payload: P;
|
|
132
|
+
/** True when assigned on this request — a first visit or a re-roll. */
|
|
133
|
+
isFresh: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The slice of a KV namespace this module uses (mirrors `abTesting.ts`). */
|
|
137
|
+
interface ExperimentKV {
|
|
138
|
+
get<T>(key: string, type: "json"): Promise<T | null>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Default Workers binding holding the published config. */
|
|
142
|
+
export const EXPERIMENTS_KV_BINDING = "EXPERIMENTS_KV";
|
|
143
|
+
|
|
144
|
+
/** Bag key for the per-request config read, so N loaders share one KV get. */
|
|
145
|
+
const CONFIG_BAG_KEY = "deco:experiments:config";
|
|
146
|
+
/** Bag key for assignments made this request, drained by the cookie writer. */
|
|
147
|
+
const ASSIGNMENTS_BAG_KEY = "deco:experiments:assignments";
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fingerprint of a weight vector. Order-independent (the control plane may
|
|
151
|
+
* reorder variants without meaning to re-roll anyone) and never `-1`, which is
|
|
152
|
+
* reserved for "legacy cookie, no fingerprint".
|
|
153
|
+
*/
|
|
154
|
+
export function weightsFingerprint(variants: ExperimentVariant[]): number {
|
|
155
|
+
const vector = [...variants]
|
|
156
|
+
.map((v) => `${v.id}:${v.weight}`)
|
|
157
|
+
.sort()
|
|
158
|
+
.join("|");
|
|
159
|
+
return djb2(vector) % 1_000_000;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Draw a variant id from the weight vector. `rand` is a 0-1 sample.
|
|
164
|
+
*
|
|
165
|
+
* Weights are treated as shares of their own total rather than of a hardcoded
|
|
166
|
+
* 100, so a config that violates the sum-to-100 contract still splits traffic
|
|
167
|
+
* in the intended proportions instead of collapsing onto the first arm.
|
|
168
|
+
*/
|
|
169
|
+
export function pickWeightedVariant(variants: ExperimentVariant[], rand: number): string {
|
|
170
|
+
const total = variants.reduce((sum, v) => sum + Math.max(0, v.weight), 0);
|
|
171
|
+
if (total <= 0) return variants[0].id;
|
|
172
|
+
|
|
173
|
+
let cursor = rand * total;
|
|
174
|
+
for (const v of variants) {
|
|
175
|
+
cursor -= Math.max(0, v.weight);
|
|
176
|
+
if (cursor < 0) return v.id;
|
|
177
|
+
}
|
|
178
|
+
return variants[variants.length - 1].id;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Read the published config for a hostname. Mirrors `abTesting.ts`'s
|
|
183
|
+
* `kv.get<T>(key, "json")` shape. Returns null when the binding is absent, the
|
|
184
|
+
* key is unset, or the document is unusable — every one of which must mean
|
|
185
|
+
* "no experiment", never a thrown request.
|
|
186
|
+
*/
|
|
187
|
+
export async function readExperimentConfig<P = unknown>(
|
|
188
|
+
kv: ExperimentKV | undefined,
|
|
189
|
+
hostname: string,
|
|
190
|
+
): Promise<ExperimentConfig<P> | null> {
|
|
191
|
+
if (!kv) return null;
|
|
192
|
+
try {
|
|
193
|
+
const config = await kv.get<ExperimentConfig<P>>(hostname, "json");
|
|
194
|
+
return Array.isArray(config?.experiments) ? config : null;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Explicit inputs, for tests and for callers outside a request context.
|
|
202
|
+
* Omitted fields fall back to the ambient {@link RequestContext} + Workers env.
|
|
203
|
+
*/
|
|
204
|
+
export interface ExperimentContext<P = unknown> {
|
|
205
|
+
/** Skips the KV read entirely when provided. */
|
|
206
|
+
config?: ExperimentConfig<P> | null;
|
|
207
|
+
kv?: ExperimentKV;
|
|
208
|
+
hostname?: string;
|
|
209
|
+
/** Raw `deco_segment` cookie value from the request. */
|
|
210
|
+
segmentCookie?: string;
|
|
211
|
+
/** Assignment sink; the cookie writer drains it. */
|
|
212
|
+
assignments?: StoredFlag[];
|
|
213
|
+
/** Injectable RNG — tests pass a fixed sample. */
|
|
214
|
+
random?: () => number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolve this visitor's variant for `experimentKey`.
|
|
219
|
+
*
|
|
220
|
+
* Returns null when no active experiment matches — no config published, the
|
|
221
|
+
* key absent, or the experiment has no variants. Callers must behave exactly
|
|
222
|
+
* as they do today on null.
|
|
223
|
+
*
|
|
224
|
+
* The assignment is recorded for persistence into the existing `deco_segment`
|
|
225
|
+
* cookie under `experimentKey` (frozen contract 4); **the caller does not set
|
|
226
|
+
* cookies itself**. Because it lands in that cookie, the CDN cache-key folding
|
|
227
|
+
* already wired in `tanstack/sdk/workerEntry.ts` (`segmentCacheToken` to
|
|
228
|
+
* `__abf`) covers it with no change, and analytics that already read
|
|
229
|
+
* `deco_segment` see it for free.
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* ```ts
|
|
233
|
+
* const variant = await resolveExperimentVariant<{ collectionId: string }>("plp-ranking");
|
|
234
|
+
* if (!variant) return vtexProductListingPage(props, req, ctx); // unchanged path
|
|
235
|
+
* props.selectedFacets = [
|
|
236
|
+
* ...(props.selectedFacets ?? []),
|
|
237
|
+
* { key: "productClusterIds", value: variant.payload.collectionId },
|
|
238
|
+
* ];
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
export async function resolveExperimentVariant<P = unknown>(
|
|
242
|
+
experimentKey: string,
|
|
243
|
+
ctx: ExperimentContext<P> = {},
|
|
244
|
+
): Promise<ResolvedVariant<P> | null> {
|
|
245
|
+
const config = ctx.config !== undefined ? ctx.config : await loadConfig<P>(ctx);
|
|
246
|
+
const experiment = config?.experiments.find((e) => e.key === experimentKey);
|
|
247
|
+
if (!experiment?.variants?.length) return null;
|
|
248
|
+
|
|
249
|
+
const stored = parseSegmentCookie(ctx.segmentCookie ?? ambientSegmentCookie());
|
|
250
|
+
const random = ctx.random ?? Math.random;
|
|
251
|
+
const { value: variantId, isFresh } = stickyDecide<string>({
|
|
252
|
+
name: experimentKey,
|
|
253
|
+
fingerprint: weightsFingerprint(experiment.variants),
|
|
254
|
+
recorded: ctx.assignments ?? ambientAssignments(),
|
|
255
|
+
stored,
|
|
256
|
+
roll: () => pickWeightedVariant(experiment.variants, random()),
|
|
257
|
+
// A variant retired mid-flight leaves visitors holding a dead id. Re-roll
|
|
258
|
+
// them onto a live arm instead of returning a payload that no longer exists.
|
|
259
|
+
accepts: (id) => experiment.variants.some((v) => v.id === id),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const variant = experiment.variants.find((v) => v.id === variantId) ?? experiment.variants[0];
|
|
263
|
+
return { experimentKey, variantId: variant.id, payload: variant.payload, isFresh };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Ambient request wiring
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* One KV read per request, shared by every caller, memoised on the in-flight
|
|
272
|
+
* promise so concurrent section loaders coalesce instead of racing.
|
|
273
|
+
*/
|
|
274
|
+
function loadConfig<P>(ctx: ExperimentContext<P>): Promise<ExperimentConfig<P> | null> {
|
|
275
|
+
const kv = ctx.kv ?? (getRuntimeEnv()?.[EXPERIMENTS_KV_BINDING] as ExperimentKV | undefined);
|
|
276
|
+
const hostname = ctx.hostname ?? ambientHostname();
|
|
277
|
+
if (!kv || !hostname) return Promise.resolve(null);
|
|
278
|
+
|
|
279
|
+
const cached = RequestContext.getBag<Promise<ExperimentConfig<P> | null>>(CONFIG_BAG_KEY);
|
|
280
|
+
if (cached) return cached;
|
|
281
|
+
|
|
282
|
+
const pending = readExperimentConfig<P>(kv, hostname);
|
|
283
|
+
RequestContext.setBag(CONFIG_BAG_KEY, pending);
|
|
284
|
+
return pending;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function ambientHostname(): string | undefined {
|
|
288
|
+
const request = RequestContext.current?.request;
|
|
289
|
+
return request ? new URL(request.url).hostname : undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function ambientSegmentCookie(): string | undefined {
|
|
293
|
+
const cookies = RequestContext.current?.request?.headers.get("cookie");
|
|
294
|
+
if (!cookies) return undefined;
|
|
295
|
+
const match = cookies.match(new RegExp(`(?:^|;\\s*)${SEGMENT_COOKIE}=([^;]+)`));
|
|
296
|
+
return match?.[1];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function ambientAssignments(): StoredFlag[] | undefined {
|
|
300
|
+
if (!RequestContext.current) return undefined;
|
|
301
|
+
const existing = RequestContext.getBag<StoredFlag[]>(ASSIGNMENTS_BAG_KEY);
|
|
302
|
+
if (existing) return existing;
|
|
303
|
+
const fresh: StoredFlag[] = [];
|
|
304
|
+
RequestContext.setBag(ASSIGNMENTS_BAG_KEY, fresh);
|
|
305
|
+
return fresh;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Assignments recorded this request, for the framework's cookie writer.
|
|
310
|
+
* Empty when nothing was assigned — callers must not re-issue the cookie then.
|
|
311
|
+
*/
|
|
312
|
+
export function takeExperimentAssignments(): StoredFlag[] {
|
|
313
|
+
return RequestContext.getBag<StoredFlag[]>(ASSIGNMENTS_BAG_KEY) ?? [];
|
|
314
|
+
}
|
package/src/sdk/flags.ts
CHANGED
|
@@ -23,6 +23,12 @@
|
|
|
23
23
|
* changes `traffic` and redeploys, the fingerprint no longer matches and the
|
|
24
24
|
* visitor is re-rolled once, then re-sticks — same self-healing scheme as
|
|
25
25
|
* {@link ./abTesting.ts}.
|
|
26
|
+
* - `exp` — `{ experimentKey: variantId }`, the N-way assignments written by
|
|
27
|
+
* {@link ./experiments.ts}. `active`/`inactiveDrawn` are boolean buckets and
|
|
28
|
+
* physically cannot hold a variant id, so string-valued decisions ride in
|
|
29
|
+
* their own map. It is deliberately the same shape Deco Analytics consumes,
|
|
30
|
+
* so pointing the collector at it later is a read, not a translation.
|
|
31
|
+
* Their re-roll fingerprints share the `pct` map, keyed by experiment key.
|
|
26
32
|
*
|
|
27
33
|
* The cookie MUST be written un-encoded (raw base64) — OneDollarStats reads it
|
|
28
34
|
* with `atob()` directly. Base64's `+ / =` are all valid cookie-value octets
|
|
@@ -35,11 +41,19 @@ export const SEGMENT_COOKIE = "deco_segment";
|
|
|
35
41
|
/** A recorded flag decision: which named flag, the branch taken, and the
|
|
36
42
|
* `traffic` fingerprint (0–100) it was decided under. */
|
|
37
43
|
export interface StoredFlag {
|
|
38
|
-
/** Matcher block name, e.g. "TestHero"
|
|
44
|
+
/** Matcher block name, e.g. "TestHero", or an experiment key, e.g.
|
|
45
|
+
* "plp-ranking". Stable identity for the cohort. */
|
|
39
46
|
name: string;
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The branch the visitor was assigned to. A boolean for CMS multivariate
|
|
49
|
+
* matchers (the historical case); a variant id for N-way experiments.
|
|
50
|
+
*/
|
|
51
|
+
value: boolean | string;
|
|
52
|
+
/**
|
|
53
|
+
* The fingerprint this decision was made under — `round(traffic * 100)` for
|
|
54
|
+
* a CMS matcher, a weight-vector hash for an experiment. A decision whose
|
|
55
|
+
* stored fingerprint no longer matches the current one is re-rolled once.
|
|
56
|
+
*/
|
|
43
57
|
pct: number;
|
|
44
58
|
}
|
|
45
59
|
|
|
@@ -49,6 +63,8 @@ interface DecoSegment {
|
|
|
49
63
|
inactiveDrawn?: string[];
|
|
50
64
|
/** blocks extension: per-flag traffic fingerprint. Ignored by analytics. */
|
|
51
65
|
pct?: Record<string, number>;
|
|
66
|
+
/** blocks extension: N-way experiment assignments, `{ key: variantId }`. */
|
|
67
|
+
exp?: Record<string, string>;
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
/** Convert a 0–1 traffic ratio to the 0–100 integer fingerprint. */
|
|
@@ -75,6 +91,9 @@ export function parseSegmentCookie(raw: string | undefined | null): StoredFlag[]
|
|
|
75
91
|
for (const name of seg.inactiveDrawn ?? []) {
|
|
76
92
|
out.push({ name, value: false, pct: pct[name] ?? -1 });
|
|
77
93
|
}
|
|
94
|
+
for (const [name, variantId] of Object.entries(seg.exp ?? {})) {
|
|
95
|
+
out.push({ name, value: variantId, pct: pct[name] ?? -1 });
|
|
96
|
+
}
|
|
78
97
|
return out;
|
|
79
98
|
} catch {
|
|
80
99
|
return [];
|
|
@@ -86,23 +105,34 @@ export function serializeSegmentCookie(flags: StoredFlag[]): string {
|
|
|
86
105
|
const active: string[] = [];
|
|
87
106
|
const inactiveDrawn: string[] = [];
|
|
88
107
|
const pct: Record<string, number> = {};
|
|
108
|
+
const exp: Record<string, string> = {};
|
|
89
109
|
for (const f of [...flags].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
90
|
-
(f.value
|
|
110
|
+
if (typeof f.value === "string") {
|
|
111
|
+
exp[f.name] = f.value;
|
|
112
|
+
} else {
|
|
113
|
+
(f.value ? active : inactiveDrawn).push(f.name);
|
|
114
|
+
}
|
|
91
115
|
pct[f.name] = f.pct;
|
|
92
116
|
}
|
|
93
117
|
const seg: DecoSegment = { active, inactiveDrawn, pct };
|
|
118
|
+
// Omit `exp` entirely when unused so the cookie stays byte-identical to what
|
|
119
|
+
// sites emit today — an added empty map would re-issue every visitor's cookie
|
|
120
|
+
// on deploy and, via segmentCacheToken, cold-start every edge cache entry.
|
|
121
|
+
if (Object.keys(exp).length) seg.exp = exp;
|
|
94
122
|
return btoa(encodeURIComponent(JSON.stringify(seg)));
|
|
95
123
|
}
|
|
96
124
|
|
|
97
125
|
/**
|
|
98
126
|
* Stable token for the cache key — includes the `pct` fingerprint so a
|
|
99
|
-
* `traffic` change lands cohorts in fresh buckets.
|
|
100
|
-
* no flags, so non-A/B pages keep sharing one
|
|
127
|
+
* `traffic` (or experiment weight) change lands cohorts in fresh buckets.
|
|
128
|
+
* Empty string when there are no flags, so non-A/B pages keep sharing one
|
|
129
|
+
* entry. Experiment assignments fold in on the same footing as boolean flags:
|
|
130
|
+
* without that, two visitors on different variants would share cached HTML.
|
|
101
131
|
*/
|
|
102
132
|
export function segmentCacheToken(flags: StoredFlag[]): string {
|
|
103
133
|
if (!flags.length) return "";
|
|
104
134
|
return [...flags]
|
|
105
135
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
106
|
-
.map((f) => `${f.name}:${f.value ? "1" : "0"}:${f.pct}`)
|
|
136
|
+
.map((f) => `${f.name}:${typeof f.value === "string" ? f.value : f.value ? "1" : "0"}:${f.pct}`)
|
|
107
137
|
.join(",");
|
|
108
138
|
}
|