@vercel/flags-core 1.8.0 → 1.8.1-873d051-20260908100141

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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @vercel/flags-core
2
2
 
3
+ ## 1.8.1-873d051-20260908100141
4
+
5
+ ### Patch Changes
6
+
7
+ - [#486](https://github.com/vercel/flags/pull/486) [`c9d2811`](https://github.com/vercel/flags/commit/c9d28116ebca661f4e3c73f301d4b2d35310c823) Thanks [@dferber90](https://github.com/dferber90)! - Add APIs for reporting flag exposures and override values.
8
+
9
+ - The `experimental_reportExposures` client option for supplying an exposure
10
+ reporter.
11
+ - The `experimental_reportOverride` client method for reporting values set by
12
+ the Flags SDK override cookie.
13
+ - The `experimental_exposureLogging` option on `evaluate()` and
14
+ `bulkEvaluate()` for disabling exposure reporting for an individual call.
15
+ - Experiment assignment metadata on `EvaluationResult.experiment`.
16
+ - The `experimental_EvaluationOptions`,
17
+ `experimental_ExperimentAssignment`, `experimental_Exposure`, and
18
+ `experimental_ReportExposures` types.
19
+
20
+ These APIs are not supported for general use yet. Do not use them unless
21
+ Vercel has explicitly enabled them for you.
22
+
23
+ - [#494](https://github.com/vercel/flags/pull/494) [`873d051`](https://github.com/vercel/flags/commit/873d05196e9e0fa5c251f745cb67c067ab890337) Thanks [@luismeyer](https://github.com/luismeyer)! - Request `Accept-Encoding: gzip` on the `/v1/stream` connection.
24
+
25
+ The stream is long-lived NDJSON and the server flushes the compressor after every message. Runtimes such as Bun advertise `br` by default but do not surface partially decoded brotli output until the response ends, so the initial datafile never arrived and every flag fell back to its default after the init timeout. gzip streams correctly on Node, Bun, and browsers.
26
+
3
27
  ## 1.8.0
4
28
 
5
29
  ### Minor Changes
package/README.md CHANGED
@@ -24,6 +24,8 @@ const result = await client.evaluate<boolean>('show-new-feature', false, {
24
24
  });
25
25
  ```
26
26
 
27
+ ## Evaluation Metrics
28
+
27
29
  To associate evaluation metrics with an environment, pass the
28
30
  `metricEnvironment` option:
29
31
 
@@ -327,7 +327,43 @@ function getVariant(definition, index) {
327
327
  variantId
328
328
  };
329
329
  }
330
- function handleOutcome(params, outcome) {
330
+ function getWeightedVariantIndex(params, assignment, seed) {
331
+ const lhs = access(assignment.base, params);
332
+ if (typeof lhs !== "string") return assignment.defaultVariant;
333
+ const bucket = hashInput(lhs, seed);
334
+ const boundaries = getSplitBoundaries(assignment);
335
+ for (let index = 0; index < boundaries.length; index++) {
336
+ if (bucket < boundaries[index]) return index;
337
+ }
338
+ return assignment.defaultVariant;
339
+ }
340
+ function experimentAssignment(experiment, variantId, assignmentReason) {
341
+ if (variantId === null) return void 0;
342
+ return {
343
+ id: experiment.id,
344
+ variantId,
345
+ base: experiment.base,
346
+ rampId: experiment.rampId,
347
+ rampPercentage: experiment.rampPercentage,
348
+ assignmentReason
349
+ };
350
+ }
351
+ function outcomeAssignmentReason(outcome) {
352
+ if (typeof outcome === "number") return "variant";
353
+ switch (outcome.type) {
354
+ case "experiment":
355
+ return "experiment";
356
+ case "split":
357
+ return "split";
358
+ case "rollout":
359
+ return "rollout";
360
+ default: {
361
+ const { type } = outcome;
362
+ return exhaustivenessCheck(type);
363
+ }
364
+ }
365
+ }
366
+ function resolveOutcome(params, outcome) {
331
367
  if (typeof outcome === "number") {
332
368
  const variant = getVariant(params.definition, outcome);
333
369
  return {
@@ -337,32 +373,48 @@ function handleOutcome(params, outcome) {
337
373
  }
338
374
  switch (outcome.type) {
339
375
  case "split": {
340
- const lhs = access(outcome.base, params);
341
- const defaultOutcome = getVariant(
342
- params.definition,
343
- outcome.defaultVariant
376
+ const index = getWeightedVariantIndex(
377
+ params,
378
+ outcome,
379
+ params.definition.seed
344
380
  );
345
- if (typeof lhs !== "string") {
346
- return {
347
- ...defaultOutcome,
348
- outcomeType: "split" /* SPLIT */
349
- };
350
- }
351
- const bucket = hashInput(lhs, params.definition.seed);
352
- const boundaries = getSplitBoundaries(outcome);
353
- for (let index = 0; index < boundaries.length; index++) {
354
- if (bucket < boundaries[index]) {
355
- return {
356
- ...getVariant(params.definition, index),
357
- outcomeType: "split" /* SPLIT */
358
- };
359
- }
360
- }
361
381
  return {
362
- ...defaultOutcome,
382
+ ...getVariant(params.definition, index),
363
383
  outcomeType: "split" /* SPLIT */
364
384
  };
365
385
  }
386
+ case "experiment": {
387
+ const experiment = params.definition.experiment;
388
+ if (!experiment) {
389
+ throw new Error("@vercel/flags-core: Experiment not found");
390
+ }
391
+ const unitValue = access(experiment.base, params);
392
+ const defaultVariant = getVariant(
393
+ params.definition,
394
+ experiment.defaultVariant
395
+ );
396
+ const assignment = (variant, assignmentReason) => ({
397
+ ...variant,
398
+ outcomeType: "experiment" /* EXPERIMENT */,
399
+ experiment: experimentAssignment(
400
+ experiment,
401
+ variant.variantId,
402
+ assignmentReason
403
+ )
404
+ });
405
+ if (typeof unitValue !== "string") {
406
+ return assignment(defaultVariant, "not-enrolled");
407
+ }
408
+ const rampPercentage = experiment.rampPercentage ?? 100;
409
+ const enrolled = rampPercentage >= 100 || rampPercentage > 0 && hashInput(unitValue, experiment.enrollmentSeed) < boundaryFor(rampPercentage, 100);
410
+ if (!enrolled) return assignment(defaultVariant, "not-enrolled");
411
+ const index = getWeightedVariantIndex(
412
+ params,
413
+ experiment,
414
+ params.definition.seed
415
+ );
416
+ return assignment(getVariant(params.definition, index), "experiment");
417
+ }
366
418
  case "rollout": {
367
419
  const lhs = access(outcome.base, params);
368
420
  const defaultOutcome = getVariant(
@@ -435,6 +487,19 @@ function handleOutcome(params, outcome) {
435
487
  }
436
488
  }
437
489
  }
490
+ function handleOutcome(params, outcome, assignmentReason) {
491
+ const result = resolveOutcome(params, outcome);
492
+ const experiment = params.definition.experiment;
493
+ if (!experiment || result.experiment) return result;
494
+ return {
495
+ ...result,
496
+ experiment: experimentAssignment(
497
+ experiment,
498
+ result.variantId,
499
+ assignmentReason ?? outcomeAssignmentReason(outcome)
500
+ )
501
+ };
502
+ }
438
503
  function evaluate(params, _visited) {
439
504
  const envConfig = params.definition.environments[params.environment];
440
505
  if (typeof envConfig === "number") {
@@ -474,7 +539,7 @@ function evaluate(params, _visited) {
474
539
  (targetList) => matchTargetList(targetList, params)
475
540
  );
476
541
  if (matchedIndex > -1) {
477
- return Object.assign(handleOutcome(params, matchedIndex), {
542
+ return Object.assign(handleOutcome(params, matchedIndex, "targeted"), {
478
543
  reason: "target_match" /* TARGET_MATCH */
479
544
  });
480
545
  }
@@ -508,7 +573,7 @@ function bulkEvaluate(flags, shared) {
508
573
  }
509
574
 
510
575
  // package.json
511
- var version = "1.8.0";
576
+ var version = "1.8.1-873d051-20260908100141";
512
577
 
513
578
  // src/lib/report-value.ts
514
579
  function internalReportValue(key, value, data) {
@@ -727,6 +792,8 @@ async function bulkEvaluate2(id, flags, entities) {
727
792
  }
728
793
 
729
794
  // src/create-raw-client.ts
795
+ import { waitUntil } from "@vercel/functions";
796
+ import { dequal } from "dequal/lite";
730
797
  var idCount = 0;
731
798
  async function performInitialize(instance, initFn) {
732
799
  try {
@@ -740,7 +807,8 @@ async function performInitialize(instance, initFn) {
740
807
  function createCreateRawClient(fns) {
741
808
  return function createRawClient({
742
809
  controller,
743
- origin
810
+ origin,
811
+ experimental_reportExposures
744
812
  }) {
745
813
  const id = idCount++;
746
814
  controllerInstanceMap.set(id, {
@@ -748,6 +816,35 @@ function createCreateRawClient(fns) {
748
816
  initialized: false,
749
817
  initPromise: null
750
818
  });
819
+ function report(exposures, entity) {
820
+ if (!experimental_reportExposures || exposures.length === 0) return;
821
+ const pending = (async () => {
822
+ try {
823
+ await experimental_reportExposures(exposures, entity);
824
+ } catch (error) {
825
+ console.error(
826
+ "@vercel/flags-core: Failed to report experiment exposures",
827
+ error
828
+ );
829
+ }
830
+ })();
831
+ try {
832
+ waitUntil(pending);
833
+ } catch {
834
+ }
835
+ }
836
+ function getExposure(flagKey, result) {
837
+ if (!result.experiment) return null;
838
+ return {
839
+ flagKey,
840
+ experimentId: result.experiment.id,
841
+ variantId: result.experiment.variantId,
842
+ base: result.experiment.base,
843
+ ...result.experiment.rampId === void 0 ? {} : { rampId: result.experiment.rampId },
844
+ ...result.experiment.rampPercentage === void 0 ? {} : { rampPercentage: result.experiment.rampPercentage },
845
+ assignmentReason: result.experiment.assignmentReason
846
+ };
847
+ }
751
848
  const api = {
752
849
  origin,
753
850
  initialize: async () => {
@@ -782,7 +879,7 @@ function createCreateRawClient(fns) {
782
879
  getFallbackDatafile: () => {
783
880
  return fns.getFallbackDatafile(id);
784
881
  },
785
- evaluate: async (flagKey, defaultValue, entities) => {
882
+ evaluate: async (flagKey, defaultValue, entities, options) => {
786
883
  const instance = controllerInstanceMap.get(id);
787
884
  if (!instance?.initialized) {
788
885
  try {
@@ -790,9 +887,22 @@ function createCreateRawClient(fns) {
790
887
  } catch {
791
888
  }
792
889
  }
793
- return fns.evaluate(id, flagKey, defaultValue, entities);
890
+ const entity = entities ?? {};
891
+ const result = await fns.evaluate(
892
+ id,
893
+ flagKey,
894
+ defaultValue,
895
+ entity
896
+ );
897
+ if (experimental_reportExposures && options?.experimental_exposureLogging !== false) {
898
+ const exposure = getExposure(flagKey, result);
899
+ if (exposure) {
900
+ report([exposure], entity);
901
+ }
902
+ }
903
+ return result;
794
904
  },
795
- bulkEvaluate: async (flags, entities) => {
905
+ bulkEvaluate: async (flags, entities, options) => {
796
906
  const instance = controllerInstanceMap.get(id);
797
907
  if (!instance?.initialized) {
798
908
  try {
@@ -800,7 +910,61 @@ function createCreateRawClient(fns) {
800
910
  } catch {
801
911
  }
802
912
  }
803
- return fns.bulkEvaluate(id, flags, entities);
913
+ const entity = entities ?? {};
914
+ const results = await fns.bulkEvaluate(id, flags, entity);
915
+ if (experimental_reportExposures && options?.experimental_exposureLogging !== false) {
916
+ const exposures = [];
917
+ const seen = /* @__PURE__ */ new Set();
918
+ for (const flag of flags) {
919
+ if (seen.has(flag.key)) continue;
920
+ seen.add(flag.key);
921
+ const result = results[flag.key];
922
+ if (!result) continue;
923
+ const exposure = getExposure(flag.key, result);
924
+ if (exposure) exposures.push(exposure);
925
+ }
926
+ report(exposures, entity);
927
+ }
928
+ return results;
929
+ },
930
+ experimental_reportOverride: async ({
931
+ key,
932
+ value,
933
+ entities
934
+ }) => {
935
+ if (!experimental_reportExposures) return;
936
+ try {
937
+ const instance = controllerInstanceMap.get(id);
938
+ if (!instance?.initialized) await api.initialize();
939
+ const datafile = await fns.getDatafile(id);
940
+ const definition = datafile.definitions[key];
941
+ const experiment = definition?.experiment;
942
+ if (!experiment) return;
943
+ const variantIndex = definition.variants.findIndex(
944
+ (variant) => dequal(variant, value)
945
+ );
946
+ const variantId = variantIndex < 0 ? null : definition.variantIds?.[variantIndex] ?? null;
947
+ const entity = entities ?? {};
948
+ report(
949
+ [
950
+ {
951
+ flagKey: key,
952
+ experimentId: experiment.id,
953
+ variantId,
954
+ base: experiment.base,
955
+ rampId: experiment.rampId,
956
+ rampPercentage: experiment.rampPercentage,
957
+ assignmentReason: "override"
958
+ }
959
+ ],
960
+ entity
961
+ );
962
+ } catch (error) {
963
+ console.error(
964
+ "@vercel/flags-core: Failed to report experiment override",
965
+ error
966
+ );
967
+ }
804
968
  }
805
969
  };
806
970
  return api;
@@ -979,7 +1143,7 @@ function getRequestContext() {
979
1143
  }
980
1144
 
981
1145
  // src/utils/scheduler.ts
982
- import { waitUntil } from "@vercel/functions";
1146
+ import { waitUntil as waitUntil2 } from "@vercel/functions";
983
1147
  var IDLE_FLUSH_WAIT_MS = 5e3;
984
1148
  var IDLE_FLUSH_JITTER_RATIO = 0.2;
985
1149
  var MAX_FLUSH_WAIT_MS = 6e4;
@@ -1002,7 +1166,7 @@ var Scheduler = class {
1002
1166
  await this.onFlush(reason);
1003
1167
  })();
1004
1168
  try {
1005
- waitUntil(this.pending);
1169
+ waitUntil2(this.pending);
1006
1170
  } catch {
1007
1171
  }
1008
1172
  this.maxTimeout = setTimeout(
@@ -1539,7 +1703,14 @@ async function connectStream(config, callbacks) {
1539
1703
  const headers = {
1540
1704
  Authorization: `Bearer ${token}`,
1541
1705
  "User-Agent": `VercelFlagsCore/${version}`,
1542
- "X-Retry-Attempt": String(retryCount)
1706
+ "X-Retry-Attempt": String(retryCount),
1707
+ // The stream is long-lived NDJSON; the server flushes the compressor
1708
+ // after every message. Some runtimes (Bun) advertise `br` by default
1709
+ // but do not surface partially decoded brotli output until the
1710
+ // response ends, so the first datafile never arrives and init times
1711
+ // out. gzip streams correctly everywhere, so request it explicitly.
1712
+ // See https://github.com/oven-sh/bun/issues/41439
1713
+ "Accept-Encoding": "gzip"
1543
1714
  };
1544
1715
  const vercelEnv = process.env.VERCEL_ENV;
1545
1716
  if (vercelEnv) {
@@ -2445,11 +2616,13 @@ function make(createRawClient) {
2445
2616
  const optionsOnly = typeof sdkKeyOrConnectionStringOrOptions === "object" && sdkKeyOrConnectionStringOrOptions !== null;
2446
2617
  const sdkKeyOrConnectionString = optionsOnly ? void 0 : sdkKeyOrConnectionStringOrOptions;
2447
2618
  const createClientOptions = optionsOnly ? sdkKeyOrConnectionStringOrOptions : options;
2619
+ const { experimental_reportExposures, ...controllerOptions } = createClientOptions ?? {};
2448
2620
  const auth = new Authentication(sdkKeyOrConnectionString);
2449
- const controller = new Controller({ auth, ...createClientOptions });
2621
+ const controller = new Controller({ auth, ...controllerOptions });
2450
2622
  return createRawClient({
2451
2623
  controller,
2452
- origin: { provider: "vercel", sdkKey: auth.sdkKey }
2624
+ origin: { provider: "vercel", sdkKey: auth.sdkKey },
2625
+ ...experimental_reportExposures ? { experimental_reportExposures } : {}
2453
2626
  });
2454
2627
  }
2455
2628
  function resetDefaultFlagsClient2() {
@@ -2499,4 +2672,4 @@ export {
2499
2672
  resetDefaultFlagsClient,
2500
2673
  createClient
2501
2674
  };
2502
- //# sourceMappingURL=chunk-ZROGU3V7.js.map
2675
+ //# sourceMappingURL=chunk-2KQT4NSN.js.map