@vercel/flags-core 1.8.0 → 1.9.0-b67dc74-20260831122711

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +44 -0
  3. package/dist/{chunk-ZV3PRKK2.js → chunk-6UH74J6U.js} +248 -32
  4. package/dist/chunk-6UH74J6U.js.map +1 -0
  5. package/dist/{chunk-ZROGU3V7.js → chunk-HWAPXVEL.js} +248 -32
  6. package/dist/chunk-HWAPXVEL.js.map +1 -0
  7. package/dist/{chunk-ID4ZKPA3.cjs → chunk-IBS2OQZ6.cjs} +269 -53
  8. package/dist/chunk-IBS2OQZ6.cjs.map +1 -0
  9. package/dist/{chunk-C3FUDFOP.cjs → chunk-IUF4BSJS.cjs} +269 -53
  10. package/dist/chunk-IUF4BSJS.cjs.map +1 -0
  11. package/dist/index.default.cjs +4 -2
  12. package/dist/index.default.cjs.map +1 -1
  13. package/dist/index.default.d.cts +16 -6
  14. package/dist/index.default.d.ts +16 -6
  15. package/dist/index.default.js +3 -1
  16. package/dist/index.next-js.cjs +4 -2
  17. package/dist/index.next-js.cjs.map +1 -1
  18. package/dist/index.next-js.d.cts +16 -6
  19. package/dist/index.next-js.d.ts +16 -6
  20. package/dist/index.next-js.js +3 -1
  21. package/dist/openfeature.default.cjs +2 -2
  22. package/dist/openfeature.default.d.cts +1 -1
  23. package/dist/openfeature.default.d.ts +1 -1
  24. package/dist/openfeature.default.js +1 -1
  25. package/dist/openfeature.next-js.cjs +2 -2
  26. package/dist/openfeature.next-js.d.cts +1 -1
  27. package/dist/openfeature.next-js.d.ts +1 -1
  28. package/dist/openfeature.next-js.js +1 -1
  29. package/dist/{types-C2-sIv2D.d.cts → types-4izb97GM.d.cts} +78 -5
  30. package/dist/{types-C2-sIv2D.d.ts → types-4izb97GM.d.ts} +78 -5
  31. package/package.json +1 -1
  32. package/dist/chunk-C3FUDFOP.cjs.map +0 -1
  33. package/dist/chunk-ID4ZKPA3.cjs.map +0 -1
  34. package/dist/chunk-ZROGU3V7.js.map +0 -1
  35. package/dist/chunk-ZV3PRKK2.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @vercel/flags-core
2
2
 
3
+ ## 1.9.0-b67dc74-20260831122711
4
+
5
+ ### Minor Changes
6
+
7
+ - [#486](https://github.com/vercel/flags/pull/486) [`8505d17`](https://github.com/vercel/flags/commit/8505d179b6d1e43d3401cd7d6a7b89bf5ae92854) Thanks [@dferber90](https://github.com/dferber90)! - Add randomized experiment enrollment, assignment reasons for every experiment-managed flag outcome, readiness-aware cookie override exposure reporting, and per-evaluation exposure logging controls.
8
+
3
9
  ## 1.8.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -24,6 +24,50 @@ const result = await client.evaluate<boolean>('show-new-feature', false, {
24
24
  });
25
25
  ```
26
26
 
27
+ ## Experiment exposures
28
+
29
+ Flags linked to an experiment report exposures automatically, regardless of
30
+ whether the evaluated value came from a fixed variant, target, split, rollout,
31
+ or fallthrough. Provide a custom reporter to send them to your analytics
32
+ system:
33
+
34
+ ```ts
35
+ const client = createClient(process.env.FLAGS!, {
36
+ reportExposures: async (exposures, entity) => {
37
+ await analytics.reportExposures(exposures, entity);
38
+ },
39
+ });
40
+ ```
41
+
42
+ `evaluate()` reports at most one exposure. `bulkEvaluate()` reports all
43
+ experiment exposures in one callback with the single entity object shared by
44
+ the evaluations. The default reporter currently maps exposures to the Vercel
45
+ Web Analytics shape and logs them through a temporary console-backed tracker.
46
+
47
+ Disable exposure logging for an evaluation when evaluating speculatively or
48
+ prefetching:
49
+
50
+ ```ts
51
+ const result = await client.evaluate(
52
+ 'show-new-feature',
53
+ false,
54
+ { user: { key: 'user-123' } },
55
+ { exposureLogging: false },
56
+ );
57
+ ```
58
+
59
+ The same option is supported by `bulkEvaluate()`:
60
+
61
+ ```ts
62
+ await client.bulkEvaluate(
63
+ [{ key: 'show-new-feature', defaultValue: false }],
64
+ { user: { key: 'user-123' } },
65
+ { exposureLogging: false },
66
+ );
67
+ ```
68
+
69
+ ## Evaluation Metrics
70
+
27
71
  To associate evaluation metrics with an environment, pass the
28
72
  `metricEnvironment` option:
29
73
 
@@ -312,7 +312,43 @@ function getVariant(definition, index) {
312
312
  variantId
313
313
  };
314
314
  }
315
- function handleOutcome(params, outcome) {
315
+ function getWeightedVariantIndex(params, assignment, seed) {
316
+ const lhs = access(assignment.base, params);
317
+ if (typeof lhs !== "string") return assignment.defaultVariant;
318
+ const bucket = hashInput(lhs, seed);
319
+ const boundaries = getSplitBoundaries(assignment);
320
+ for (let index = 0; index < boundaries.length; index++) {
321
+ if (bucket < boundaries[index]) return index;
322
+ }
323
+ return assignment.defaultVariant;
324
+ }
325
+ function experimentAssignment(experiment, variantId, assignmentReason) {
326
+ if (variantId === null) return void 0;
327
+ return {
328
+ id: experiment.id,
329
+ variantId,
330
+ base: experiment.base,
331
+ rampId: experiment.rampId,
332
+ rampPercentage: experiment.rampPercentage,
333
+ assignmentReason
334
+ };
335
+ }
336
+ function outcomeAssignmentReason(outcome) {
337
+ if (typeof outcome === "number") return "variant";
338
+ switch (outcome.type) {
339
+ case "experiment":
340
+ return "experiment";
341
+ case "split":
342
+ return "split";
343
+ case "rollout":
344
+ return "rollout";
345
+ default: {
346
+ const { type } = outcome;
347
+ return exhaustivenessCheck(type);
348
+ }
349
+ }
350
+ }
351
+ function resolveOutcome(params, outcome) {
316
352
  if (typeof outcome === "number") {
317
353
  const variant = getVariant(params.definition, outcome);
318
354
  return {
@@ -322,32 +358,48 @@ function handleOutcome(params, outcome) {
322
358
  }
323
359
  switch (outcome.type) {
324
360
  case "split": {
325
- const lhs = access(outcome.base, params);
326
- const defaultOutcome = getVariant(
327
- params.definition,
328
- outcome.defaultVariant
361
+ const index = getWeightedVariantIndex(
362
+ params,
363
+ outcome,
364
+ params.definition.seed
329
365
  );
330
- if (typeof lhs !== "string") {
331
- return {
332
- ...defaultOutcome,
333
- outcomeType: "split" /* SPLIT */
334
- };
335
- }
336
- const bucket = hashInput(lhs, params.definition.seed);
337
- const boundaries = getSplitBoundaries(outcome);
338
- for (let index = 0; index < boundaries.length; index++) {
339
- if (bucket < boundaries[index]) {
340
- return {
341
- ...getVariant(params.definition, index),
342
- outcomeType: "split" /* SPLIT */
343
- };
344
- }
345
- }
346
366
  return {
347
- ...defaultOutcome,
367
+ ...getVariant(params.definition, index),
348
368
  outcomeType: "split" /* SPLIT */
349
369
  };
350
370
  }
371
+ case "experiment": {
372
+ const experiment = params.definition.experiment;
373
+ if (!experiment) {
374
+ throw new Error("@vercel/flags-core: Experiment not found");
375
+ }
376
+ const unitValue = access(experiment.base, params);
377
+ const defaultVariant = getVariant(
378
+ params.definition,
379
+ experiment.defaultVariant
380
+ );
381
+ const assignment = (variant, assignmentReason) => ({
382
+ ...variant,
383
+ outcomeType: "experiment" /* EXPERIMENT */,
384
+ experiment: experimentAssignment(
385
+ experiment,
386
+ variant.variantId,
387
+ assignmentReason
388
+ )
389
+ });
390
+ if (typeof unitValue !== "string") {
391
+ return assignment(defaultVariant, "not-enrolled");
392
+ }
393
+ const rampPercentage = experiment.rampPercentage ?? 100;
394
+ const enrolled = rampPercentage >= 100 || rampPercentage > 0 && hashInput(unitValue, experiment.enrollmentSeed) < boundaryFor(rampPercentage, 100);
395
+ if (!enrolled) return assignment(defaultVariant, "not-enrolled");
396
+ const index = getWeightedVariantIndex(
397
+ params,
398
+ experiment,
399
+ params.definition.seed
400
+ );
401
+ return assignment(getVariant(params.definition, index), "experiment");
402
+ }
351
403
  case "rollout": {
352
404
  const lhs = access(outcome.base, params);
353
405
  const defaultOutcome = getVariant(
@@ -420,6 +472,19 @@ function handleOutcome(params, outcome) {
420
472
  }
421
473
  }
422
474
  }
475
+ function handleOutcome(params, outcome, assignmentReason) {
476
+ const result = resolveOutcome(params, outcome);
477
+ const experiment = params.definition.experiment;
478
+ if (!experiment || result.experiment) return result;
479
+ return {
480
+ ...result,
481
+ experiment: experimentAssignment(
482
+ experiment,
483
+ result.variantId,
484
+ assignmentReason ?? outcomeAssignmentReason(outcome)
485
+ )
486
+ };
487
+ }
423
488
  function evaluate(params, _visited) {
424
489
  const envConfig = params.definition.environments[params.environment];
425
490
  if (typeof envConfig === "number") {
@@ -459,7 +524,7 @@ function evaluate(params, _visited) {
459
524
  (targetList) => matchTargetList(targetList, params)
460
525
  );
461
526
  if (matchedIndex > -1) {
462
- return Object.assign(handleOutcome(params, matchedIndex), {
527
+ return Object.assign(handleOutcome(params, matchedIndex, "targeted"), {
463
528
  reason: "target_match" /* TARGET_MATCH */
464
529
  });
465
530
  }
@@ -493,7 +558,7 @@ function bulkEvaluate(flags, shared) {
493
558
  }
494
559
 
495
560
  // package.json
496
- var version = "1.8.0";
561
+ var version = "1.9.0-b67dc74-20260831122711";
497
562
 
498
563
  // src/lib/report-value.ts
499
564
  function internalReportValue(key, value, data) {
@@ -711,6 +776,66 @@ async function bulkEvaluate2(id, flags, entities) {
711
776
  return results;
712
777
  }
713
778
 
779
+ // src/exposure-reporting.ts
780
+ var FAKE_DEVICE_ID = "fake-device-id";
781
+ function getProperty2(entity, path) {
782
+ return path.reduce((value, key) => {
783
+ if (typeof value !== "object" || value === null || !(key in value)) {
784
+ return void 0;
785
+ }
786
+ return value[key];
787
+ }, entity);
788
+ }
789
+ function isBase(base, kind) {
790
+ return base.length === 2 && base[0] === kind && base[1] === "key";
791
+ }
792
+ function flattenBase(base) {
793
+ return base.map(String).map(
794
+ (part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)
795
+ ).join("");
796
+ }
797
+ function mapExposure(exposure, entity) {
798
+ let unitKey;
799
+ let unitValue;
800
+ if (isBase(exposure.base, "user")) {
801
+ unitKey = "user";
802
+ unitValue = getProperty2(entity, exposure.base);
803
+ } else if (isBase(exposure.base, "session")) {
804
+ unitKey = "session";
805
+ unitValue = getProperty2(entity, exposure.base);
806
+ } else if (isBase(exposure.base, "device")) {
807
+ unitKey = "device";
808
+ unitValue = FAKE_DEVICE_ID;
809
+ } else if (isBase(exposure.base, "team")) {
810
+ unitKey = "group";
811
+ unitValue = getProperty2(entity, exposure.base);
812
+ } else {
813
+ const flattenedBase = flattenBase(exposure.base);
814
+ if (!flattenedBase) return null;
815
+ unitKey = `event_data.${flattenedBase}`;
816
+ unitValue = getProperty2(entity, exposure.base);
817
+ }
818
+ if (typeof unitValue !== "string") return null;
819
+ return {
820
+ experimentId: exposure.experimentId,
821
+ variantId: exposure.variantId ?? "override",
822
+ unitKey,
823
+ unitValue,
824
+ ...exposure.rampId === void 0 ? {} : { rampId: exposure.rampId },
825
+ ...exposure.rampPercentage === void 0 ? {} : { rampPercentage: exposure.rampPercentage },
826
+ assignmentReason: exposure.assignmentReason
827
+ };
828
+ }
829
+ function trackExposure(exposure) {
830
+ console.log("@vercel/flags-core: trackExposure", exposure);
831
+ }
832
+ var defaultReportExposures = (exposures, entity) => {
833
+ for (const exposure of exposures) {
834
+ const mapped = mapExposure(exposure, entity);
835
+ if (mapped) trackExposure(mapped);
836
+ }
837
+ };
838
+
714
839
  // src/create-raw-client.ts
715
840
  var idCount = 0;
716
841
  async function performInitialize(instance, initFn) {
@@ -725,7 +850,8 @@ async function performInitialize(instance, initFn) {
725
850
  function createCreateRawClient(fns) {
726
851
  return function createRawClient({
727
852
  controller,
728
- origin
853
+ origin,
854
+ reportExposures
729
855
  }) {
730
856
  const id = idCount++;
731
857
  controllerInstanceMap.set(id, {
@@ -733,6 +859,30 @@ function createCreateRawClient(fns) {
733
859
  initialized: false,
734
860
  initPromise: null
735
861
  });
862
+ const exposureReporter = reportExposures ?? defaultReportExposures;
863
+ async function report(exposures, entity) {
864
+ if (exposures.length === 0) return;
865
+ try {
866
+ await exposureReporter(exposures, entity);
867
+ } catch (error) {
868
+ console.error(
869
+ "@vercel/flags-core: Failed to report experiment exposures",
870
+ error
871
+ );
872
+ }
873
+ }
874
+ function getExposure(flagKey, result) {
875
+ if (!result.experiment) return null;
876
+ return {
877
+ flagKey,
878
+ experimentId: result.experiment.id,
879
+ variantId: result.experiment.variantId,
880
+ base: result.experiment.base,
881
+ ...result.experiment.rampId === void 0 ? {} : { rampId: result.experiment.rampId },
882
+ ...result.experiment.rampPercentage === void 0 ? {} : { rampPercentage: result.experiment.rampPercentage },
883
+ assignmentReason: result.experiment.assignmentReason
884
+ };
885
+ }
736
886
  const api = {
737
887
  origin,
738
888
  initialize: async () => {
@@ -767,7 +917,7 @@ function createCreateRawClient(fns) {
767
917
  getFallbackDatafile: () => {
768
918
  return fns.getFallbackDatafile(id);
769
919
  },
770
- evaluate: async (flagKey, defaultValue, entities) => {
920
+ evaluate: async (flagKey, defaultValue, entities, options) => {
771
921
  const instance = controllerInstanceMap.get(id);
772
922
  if (!instance?.initialized) {
773
923
  try {
@@ -775,9 +925,22 @@ function createCreateRawClient(fns) {
775
925
  } catch {
776
926
  }
777
927
  }
778
- return fns.evaluate(id, flagKey, defaultValue, entities);
928
+ const entity = entities ?? {};
929
+ const result = await fns.evaluate(
930
+ id,
931
+ flagKey,
932
+ defaultValue,
933
+ entity
934
+ );
935
+ if (options?.exposureLogging !== false) {
936
+ const exposure = getExposure(flagKey, result);
937
+ if (exposure) {
938
+ await report([exposure], entity);
939
+ }
940
+ }
941
+ return result;
779
942
  },
780
- bulkEvaluate: async (flags, entities) => {
943
+ bulkEvaluate: async (flags, entities, options) => {
781
944
  const instance = controllerInstanceMap.get(id);
782
945
  if (!instance?.initialized) {
783
946
  try {
@@ -785,7 +948,57 @@ function createCreateRawClient(fns) {
785
948
  } catch {
786
949
  }
787
950
  }
788
- return fns.bulkEvaluate(id, flags, entities);
951
+ const entity = entities ?? {};
952
+ const results = await fns.bulkEvaluate(id, flags, entity);
953
+ if (options?.exposureLogging !== false) {
954
+ const exposures = [];
955
+ const seen = /* @__PURE__ */ new Set();
956
+ for (const flag of flags) {
957
+ if (seen.has(flag.key)) continue;
958
+ seen.add(flag.key);
959
+ const result = results[flag.key];
960
+ if (!result) continue;
961
+ const exposure = getExposure(flag.key, result);
962
+ if (exposure) exposures.push(exposure);
963
+ }
964
+ await report(exposures, entity);
965
+ }
966
+ return results;
967
+ },
968
+ reportOverride: async (flagKey, value, entities) => {
969
+ try {
970
+ const instance = controllerInstanceMap.get(id);
971
+ if (!instance?.initialized) await api.initialize();
972
+ const datafile = await fns.getDatafile(id);
973
+ const definition = datafile.definitions[flagKey];
974
+ const experiment = definition?.experiment;
975
+ if (!experiment) return;
976
+ const serializedValue = JSON.stringify(value);
977
+ const variantIndex = definition.variants.findIndex(
978
+ (variant) => Object.is(variant, value) || JSON.stringify(variant) === serializedValue
979
+ );
980
+ const variantId = variantIndex < 0 ? null : definition.variantIds?.[variantIndex] ?? null;
981
+ const entity = entities ?? {};
982
+ await report(
983
+ [
984
+ {
985
+ flagKey,
986
+ experimentId: experiment.id,
987
+ variantId,
988
+ base: experiment.base,
989
+ rampId: experiment.rampId,
990
+ rampPercentage: experiment.rampPercentage,
991
+ assignmentReason: "override"
992
+ }
993
+ ],
994
+ entity
995
+ );
996
+ } catch (error) {
997
+ console.error(
998
+ "@vercel/flags-core: Failed to report experiment override",
999
+ error
1000
+ );
1001
+ }
789
1002
  }
790
1003
  };
791
1004
  return api;
@@ -2430,11 +2643,13 @@ function make(createRawClient) {
2430
2643
  const optionsOnly = typeof sdkKeyOrConnectionStringOrOptions === "object" && sdkKeyOrConnectionStringOrOptions !== null;
2431
2644
  const sdkKeyOrConnectionString = optionsOnly ? void 0 : sdkKeyOrConnectionStringOrOptions;
2432
2645
  const createClientOptions = optionsOnly ? sdkKeyOrConnectionStringOrOptions : options;
2646
+ const { reportExposures, ...controllerOptions } = createClientOptions ?? {};
2433
2647
  const auth = new Authentication(sdkKeyOrConnectionString);
2434
- const controller = new Controller({ auth, ...createClientOptions });
2648
+ const controller = new Controller({ auth, ...controllerOptions });
2435
2649
  return createRawClient({
2436
2650
  controller,
2437
- origin: { provider: "vercel", sdkKey: auth.sdkKey }
2651
+ origin: { provider: "vercel", sdkKey: auth.sdkKey },
2652
+ ...reportExposures ? { reportExposures } : {}
2438
2653
  });
2439
2654
  }
2440
2655
  function resetDefaultFlagsClient2() {
@@ -2502,6 +2717,7 @@ var { flagsClient, resetDefaultFlagsClient, createClient } = make(
2502
2717
  export {
2503
2718
  ResolutionReason,
2504
2719
  evaluate,
2720
+ defaultReportExposures,
2505
2721
  FallbackNotFoundError,
2506
2722
  FallbackEntryNotFoundError,
2507
2723
  Controller,
@@ -2509,4 +2725,4 @@ export {
2509
2725
  resetDefaultFlagsClient,
2510
2726
  createClient
2511
2727
  };
2512
- //# sourceMappingURL=chunk-ZV3PRKK2.js.map
2728
+ //# sourceMappingURL=chunk-6UH74J6U.js.map