@vercel/flags-core 1.8.1-9d0e81e-20260908151858 → 1.8.1

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,10 +1,28 @@
1
1
  # @vercel/flags-core
2
2
 
3
- ## 1.8.1-9d0e81e-20260908151858
3
+ ## 1.8.1
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - [#490](https://github.com/vercel/flags/pull/490) [`559c1e7`](https://github.com/vercel/flags/commit/559c1e7bb14765acb234bf004c8e839fadfc2922) Thanks [@AndyBitz](https://github.com/AndyBitz)! - Use the runtime-provided ingest transport when available
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) [`e0eebe6`](https://github.com/vercel/flags/commit/e0eebe6fbc296636761eb3dc31f2c4be01a398bf) Thanks [@luismeyer](https://github.com/luismeyer)! - Request an uncompressed `/v1/stream` body when running on Bun.
24
+
25
+ Bun's `fetch` negotiates brotli or gzip by default, but its streaming decoder withholds small decoded output until more compressed input arrives. The stream's first datafile is followed by silence until the next ping, so on Bun the initial datafile never surfaced, init timed out, and every flag fell back to its default. Sending `Accept-Encoding: identity` on Bun avoids the decoder entirely; other runtimes are unchanged.
8
26
 
9
27
  ## 1.8.0
10
28
 
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.1-9d0e81e-20260908151858";
576
+ var version = "1.8.1";
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;
@@ -881,17 +1045,6 @@ function getJitteredWaitMs(baseMs, ratio) {
881
1045
  return Math.floor(min + Math.random() * span);
882
1046
  }
883
1047
 
884
- // src/utils/runtime-ingest.ts
885
- var FLAGS_CONTEXT_SYMBOL = /* @__PURE__ */ Symbol.for("@vercel/flags-context");
886
- function getRuntimeIngest() {
887
- try {
888
- const context = globalThis[FLAGS_CONTEXT_SYMBOL];
889
- return typeof context?.ingest === "function" ? context.ingest : void 0;
890
- } catch {
891
- return void 0;
892
- }
893
- }
894
-
895
1048
  // src/utils/ingest.ts
896
1049
  var MAX_RETRIES = 3;
897
1050
  var MAX_EVENTS_PER_REQUEST = 2e3;
@@ -925,28 +1078,8 @@ async function getIngestHeaders(options, flushReason) {
925
1078
  ...isDebugMode ? { "x-vercel-debug-ingest": "1" } : null
926
1079
  };
927
1080
  }
928
- function getRuntimeIngestHeaders(options, flushReason) {
929
- return {
930
- "Content-Type": "application/json",
931
- ...options.auth.sdkKey ? { Authorization: `Bearer ${options.auth.sdkKey}` } : null,
932
- "User-Agent": `VercelFlagsCore/${version}`,
933
- [FLUSH_REASON_HEADER]: flushReason,
934
- ...options.metricEnvironment ?? process.env.VERCEL_ENV ? {
935
- "X-Vercel-Env": options.metricEnvironment ?? process.env.VERCEL_ENV
936
- } : null,
937
- ...isDebugMode ? { "x-vercel-debug-ingest": "1" } : null
938
- };
939
- }
940
1081
  async function sendIngestEvents(options, events, flushId, flushReason) {
941
- let eventsToSend = events.map((event) => event.ingestEvent());
942
- const runtimeIngest = getRuntimeIngest();
943
- if (runtimeIngest) {
944
- const headers = getRuntimeIngestHeaders(options, flushReason);
945
- eventsToSend = eventsToSend.filter(
946
- (event) => !runtimeIngest({ headers, body: [event] })
947
- );
948
- if (eventsToSend.length === 0) return;
949
- }
1082
+ const eventsToSend = events.map((event) => event.ingestEvent());
950
1083
  for (let i = 0; i < eventsToSend.length; i += MAX_EVENTS_PER_REQUEST) {
951
1084
  await sendIngestChunk(
952
1085
  options,
@@ -1010,7 +1143,7 @@ function getRequestContext() {
1010
1143
  }
1011
1144
 
1012
1145
  // src/utils/scheduler.ts
1013
- import { waitUntil } from "@vercel/functions";
1146
+ import { waitUntil as waitUntil2 } from "@vercel/functions";
1014
1147
  var IDLE_FLUSH_WAIT_MS = 5e3;
1015
1148
  var IDLE_FLUSH_JITTER_RATIO = 0.2;
1016
1149
  var MAX_FLUSH_WAIT_MS = 6e4;
@@ -1033,7 +1166,7 @@ var Scheduler = class {
1033
1166
  await this.onFlush(reason);
1034
1167
  })();
1035
1168
  try {
1036
- waitUntil(this.pending);
1169
+ waitUntil2(this.pending);
1037
1170
  } catch {
1038
1171
  }
1039
1172
  this.maxTimeout = setTimeout(
@@ -1205,7 +1338,7 @@ var UsageTracker = class {
1205
1338
  if (this.trackedRequests.has(ctx)) return;
1206
1339
  this.trackedRequests.add(ctx);
1207
1340
  this.readEvents.push(new FlagsConfigReadEvent(headers, options));
1208
- this.requestFlush();
1341
+ this.scheduler.scheduleFlush();
1209
1342
  } catch (error) {
1210
1343
  console.error("@vercel/flags-core: Failed to record event:", error);
1211
1344
  }
@@ -1229,7 +1362,7 @@ var UsageTracker = class {
1229
1362
  new FlagsEvaluationEvent(bucketedOptions)
1230
1363
  );
1231
1364
  }
1232
- this.requestFlush();
1365
+ this.scheduler.scheduleFlush();
1233
1366
  } catch (error) {
1234
1367
  console.error(
1235
1368
  "@vercel/flags-core: Failed to record evaluation event:",
@@ -1237,19 +1370,6 @@ var UsageTracker = class {
1237
1370
  );
1238
1371
  }
1239
1372
  }
1240
- /**
1241
- * Flushes immediately when the runtime provides an ingest transport,
1242
- * otherwise falls back to the time-based scheduler.
1243
- */
1244
- requestFlush() {
1245
- if (getRuntimeIngest()) {
1246
- void this.flushEvents("immediate").catch((error) => {
1247
- console.error("@vercel/flags-core: Failed to flush events:", error);
1248
- });
1249
- } else {
1250
- this.scheduler.scheduleFlush();
1251
- }
1252
- }
1253
1373
  /**
1254
1374
  * Send all events to the ingest service
1255
1375
  */
@@ -1501,6 +1621,11 @@ var PollingSource = class extends TypedEmitter {
1501
1621
  }
1502
1622
  };
1503
1623
 
1624
+ // src/utils/runtime.ts
1625
+ function isBun() {
1626
+ return typeof globalThis.Bun !== "undefined";
1627
+ }
1628
+
1504
1629
  // src/utils/sleep.ts
1505
1630
  function sleep(ms) {
1506
1631
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -1585,6 +1710,9 @@ async function connectStream(config, callbacks) {
1585
1710
  "User-Agent": `VercelFlagsCore/${version}`,
1586
1711
  "X-Retry-Attempt": String(retryCount)
1587
1712
  };
1713
+ if (isBun()) {
1714
+ headers["Accept-Encoding"] = "identity";
1715
+ }
1588
1716
  const vercelEnv = process.env.VERCEL_ENV;
1589
1717
  if (vercelEnv) {
1590
1718
  headers["X-Vercel-Env"] = vercelEnv;
@@ -2489,11 +2617,13 @@ function make(createRawClient) {
2489
2617
  const optionsOnly = typeof sdkKeyOrConnectionStringOrOptions === "object" && sdkKeyOrConnectionStringOrOptions !== null;
2490
2618
  const sdkKeyOrConnectionString = optionsOnly ? void 0 : sdkKeyOrConnectionStringOrOptions;
2491
2619
  const createClientOptions = optionsOnly ? sdkKeyOrConnectionStringOrOptions : options;
2620
+ const { experimental_reportExposures, ...controllerOptions } = createClientOptions ?? {};
2492
2621
  const auth = new Authentication(sdkKeyOrConnectionString);
2493
- const controller = new Controller({ auth, ...createClientOptions });
2622
+ const controller = new Controller({ auth, ...controllerOptions });
2494
2623
  return createRawClient({
2495
2624
  controller,
2496
- origin: { provider: "vercel", sdkKey: auth.sdkKey }
2625
+ origin: { provider: "vercel", sdkKey: auth.sdkKey },
2626
+ ...experimental_reportExposures ? { experimental_reportExposures } : {}
2497
2627
  });
2498
2628
  }
2499
2629
  function resetDefaultFlagsClient2() {
@@ -2543,4 +2673,4 @@ export {
2543
2673
  resetDefaultFlagsClient,
2544
2674
  createClient
2545
2675
  };
2546
- //# sourceMappingURL=chunk-YBQ6KBYF.js.map
2676
+ //# sourceMappingURL=chunk-5BWCBI7I.js.map