@vercel/flags-core 1.6.0-fa24822-20260629103652 → 1.7.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/CHANGELOG.md CHANGED
@@ -1,10 +1,35 @@
1
1
  # @vercel/flags-core
2
2
 
3
- ## 1.6.0-fa24822-20260629103652
3
+ ## 1.7.0
4
4
 
5
5
  ### Minor Changes
6
6
 
7
- - [#401](https://github.com/vercel/flags/pull/401) [`1f8b03f`](https://github.com/vercel/flags/commit/1f8b03f2be1624391277322f21d53352017bd6a7) Thanks [@luismeyer](https://github.com/luismeyer)! - Add aggregated flag evaluation telemetry and a `clientName` option for the Vercel Flags client.
7
+ - [#427](https://github.com/vercel/flags/pull/427) [`50b1aa4`](https://github.com/vercel/flags/commit/50b1aa4f9614b8e78f76cc9e3ae539e58a46fa7e) Thanks [@dferber90](https://github.com/dferber90)! - Align rollout, split, and segment split user assignment onto a single hash bucketing scheme.
8
+
9
+ Previously splits and rollouts bucketed users with opposite conventions, so switching a flag between a split and a rollout (or locking in a rollout as a split) could reassign users even when the effective distribution was unchanged. All three now derive their cut points from one shared boundary function over the full hash space, so a rollout at a given percentage is identical to the equivalent split.
10
+
11
+ Split assignments are effectively unchanged. Rollouts and segment splits are re-bucketed once with this release; after that, converting a flag between outcome types never reassigns anyone.
12
+
13
+ ## 1.6.0
14
+
15
+ ### Minor Changes
16
+
17
+ - [#401](https://github.com/vercel/flags/pull/401) [`9dff590`](https://github.com/vercel/flags/commit/9dff590bd5628bd93098637c2e9b3d1a043e4d4b) Thanks [@luismeyer](https://github.com/luismeyer)! - Add aggregated flag evaluation telemetry and a `clientName` option for the Vercel Flags client.
18
+
19
+ ## 1.5.2
20
+
21
+ ### Patch Changes
22
+
23
+ - [#416](https://github.com/vercel/flags/pull/416) [`f60c99d`](https://github.com/vercel/flags/commit/f60c99d70741e5e8e5af0a069deaf34a3129a27e) Thanks [@dferber90](https://github.com/dferber90)! - Fix datafile serialization across the RSC server/client boundary.
24
+
25
+ Evaluation memoized scaled split weights and compiled regexes by attaching
26
+ symbol-keyed properties directly onto objects inside the datafile. While
27
+ symbols are invisible to `JSON.stringify`, React Server Components serialization
28
+ walks objects directly and chokes on these (notably the non-serializable
29
+ `RegExp`), so datafiles could no longer be passed from server to client
30
+ components. Memoization now uses module-level `WeakMap`s keyed by the
31
+ outcome/rhs objects, leaving datafile objects pristine while keeping identical
32
+ caching semantics and lifetime.
8
33
 
9
34
  ## 1.5.1
10
35
 
@@ -32,22 +32,31 @@ var Packed;
32
32
 
33
33
  // src/evaluate.ts
34
34
  var MAX_REGEX_INPUT_LENGTH = 1e4;
35
- var UINT32_MAX = 4294967295;
36
- var SCALED_WEIGHTS = /* @__PURE__ */ Symbol("@vercel/flags-core:scaledWeights");
37
- var COMPILED_REGEX = /* @__PURE__ */ Symbol("@vercel/flags-core:compiledRegex");
38
- function getScaledWeights(outcome) {
39
- const cached = outcome[SCALED_WEIGHTS];
35
+ var HASH_SPACE = 2 ** 32;
36
+ var PROMILLE_SCALE = 1e5;
37
+ function boundaryFor(numerator, denominator) {
38
+ return Math.floor(numerator / denominator * HASH_SPACE);
39
+ }
40
+ var splitBoundariesCache = /* @__PURE__ */ new WeakMap();
41
+ var compiledRegexCache = /* @__PURE__ */ new WeakMap();
42
+ function getSplitBoundaries(outcome) {
43
+ const cached = splitBoundariesCache.get(outcome);
40
44
  if (cached) return cached;
41
45
  const total = sum(outcome.weights);
42
- const scaled = outcome.weights.map((w) => w / total * UINT32_MAX);
43
- outcome[SCALED_WEIGHTS] = scaled;
44
- return scaled;
46
+ const boundaries = [];
47
+ let cumulative = 0;
48
+ for (const weight of outcome.weights) {
49
+ cumulative += weight;
50
+ boundaries.push(boundaryFor(cumulative, total));
51
+ }
52
+ splitBoundariesCache.set(outcome, boundaries);
53
+ return boundaries;
45
54
  }
46
55
  function getCompiledRegex(rhs) {
47
- const cached = rhs[COMPILED_REGEX];
56
+ const cached = compiledRegexCache.get(rhs);
48
57
  if (cached) return cached;
49
58
  const compiled = new RegExp(rhs.pattern, rhs.flags);
50
- rhs[COMPILED_REGEX] = compiled;
59
+ compiledRegexCache.set(rhs, compiled);
51
60
  return compiled;
52
61
  }
53
62
  function exhaustivenessCheck(_) {
@@ -276,11 +285,10 @@ function handleSegmentOutcome(params, outcome) {
276
285
  case "split": {
277
286
  const lhs = access(outcome.base, params);
278
287
  if (typeof lhs !== "string") return false;
279
- const maxValue = 1e5;
280
288
  if (outcome.passPromille <= 0) return false;
281
- if (outcome.passPromille >= maxValue) return true;
282
- const value = hashInput(lhs, params.definition.seed) % maxValue;
283
- return value < outcome.passPromille;
289
+ if (outcome.passPromille >= PROMILLE_SCALE) return true;
290
+ const bucket = hashInput(lhs, params.definition.seed);
291
+ return bucket < boundaryFor(outcome.passPromille, PROMILLE_SCALE);
284
292
  }
285
293
  default: {
286
294
  const { type } = outcome;
@@ -325,12 +333,18 @@ function handleOutcome(params, outcome) {
325
333
  outcomeType: "split" /* SPLIT */
326
334
  };
327
335
  }
328
- const value = hashInput(lhs, params.definition.seed);
329
- const scaledWeights = getScaledWeights(outcome);
330
- const variantIndex = findWeightedIndex(scaledWeights, value, UINT32_MAX);
331
- const variant = variantIndex === -1 ? defaultOutcome : getVariant(params.definition, variantIndex);
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
+ }
332
346
  return {
333
- ...variant,
347
+ ...defaultOutcome,
334
348
  outcomeType: "split" /* SPLIT */
335
349
  };
336
350
  }
@@ -366,7 +380,7 @@ function handleOutcome(params, outcome) {
366
380
  break;
367
381
  }
368
382
  }
369
- if (exhausted) currentPromille = 1e5;
383
+ if (exhausted) currentPromille = PROMILLE_SCALE;
370
384
  if (currentPromille <= 0) {
371
385
  return {
372
386
  ...rollFromVariant,
@@ -377,17 +391,26 @@ function handleOutcome(params, outcome) {
377
391
  params.definition,
378
392
  outcome.rollToVariant
379
393
  );
380
- if (currentPromille >= 1e5) {
394
+ if (currentPromille >= PROMILLE_SCALE) {
381
395
  return {
382
396
  ...rollToVariant,
383
397
  outcomeType: "rollout" /* ROLLOUT */
384
398
  };
385
399
  }
386
- const value = hashInput(lhs, params.definition.seed);
387
- const threshold = currentPromille / 1e5 * UINT32_MAX;
388
- const variant = value < threshold ? rollToVariant : rollFromVariant;
400
+ const bucket = hashInput(lhs, params.definition.seed);
401
+ if (outcome.rollToVariant < outcome.rollFromVariant) {
402
+ const rollToBoundary = boundaryFor(currentPromille, PROMILLE_SCALE);
403
+ return {
404
+ ...bucket < rollToBoundary ? rollToVariant : rollFromVariant,
405
+ outcomeType: "rollout" /* ROLLOUT */
406
+ };
407
+ }
408
+ const rollFromBoundary = boundaryFor(
409
+ PROMILLE_SCALE - currentPromille,
410
+ PROMILLE_SCALE
411
+ );
389
412
  return {
390
- ...variant,
413
+ ...bucket < rollFromBoundary ? rollFromVariant : rollToVariant,
391
414
  outcomeType: "rollout" /* ROLLOUT */
392
415
  };
393
416
  }
@@ -468,18 +491,9 @@ function bulkEvaluate(flags, shared) {
468
491
  }
469
492
  return results;
470
493
  }
471
- function findWeightedIndex(weights, value, maxValue) {
472
- if (value < 0 || value >= maxValue) return -1;
473
- let sum2 = 0;
474
- for (let i = 0; i < weights.length; i++) {
475
- sum2 += weights[i];
476
- if (value < sum2) return i;
477
- }
478
- return -1;
479
- }
480
494
 
481
495
  // package.json
482
- var version = "1.6.0-fa24822-20260629103652";
496
+ var version = "1.7.0";
483
497
 
484
498
  // src/lib/report-value.ts
485
499
  function internalReportValue(key, value, data) {
@@ -854,7 +868,9 @@ function getJitteredWaitMs(baseMs, ratio) {
854
868
 
855
869
  // src/utils/ingest.ts
856
870
  var MAX_RETRIES = 3;
871
+ var MAX_EVENTS_PER_REQUEST = 2e3;
857
872
  var EVALUATING_OIDC_TOKEN_HEADER = "X-Vercel-Flags-OIDC-Token";
873
+ var FLUSH_REASON_HEADER = "X-Vercel-Flags-Flush-Reason";
858
874
  var isDebugMode = process.env.DEBUG?.includes("@vercel/flags-core");
859
875
  var debugLog = (...args) => {
860
876
  if (!isDebugMode) return;
@@ -868,25 +884,36 @@ async function getEvaluatingOidcToken(auth) {
868
884
  return void 0;
869
885
  }
870
886
  }
871
- async function getIngestHeaders(options) {
887
+ async function getIngestHeaders(options, flushReason) {
872
888
  const token = await options.auth.resolveToken();
873
889
  const evaluatingOidcToken = await getEvaluatingOidcToken(options.auth);
874
890
  return {
875
891
  "Content-Type": "application/json",
876
892
  Authorization: `Bearer ${token}`,
877
893
  "User-Agent": `VercelFlagsCore/${version}`,
894
+ [FLUSH_REASON_HEADER]: flushReason,
878
895
  ...process.env.VERCEL_ENV ? { "X-Vercel-Env": process.env.VERCEL_ENV } : null,
879
896
  ...evaluatingOidcToken ? { [EVALUATING_OIDC_TOKEN_HEADER]: evaluatingOidcToken } : null,
880
897
  ...isDebugMode ? { "x-vercel-debug-ingest": "1" } : null
881
898
  };
882
899
  }
883
- async function sendIngestEvents(options, events, flushId) {
900
+ async function sendIngestEvents(options, events, flushId, flushReason) {
884
901
  const eventsToSend = events.map((event) => event.ingestEvent());
902
+ for (let i = 0; i < eventsToSend.length; i += MAX_EVENTS_PER_REQUEST) {
903
+ await sendIngestChunk(
904
+ options,
905
+ eventsToSend.slice(i, i + MAX_EVENTS_PER_REQUEST),
906
+ flushId,
907
+ flushReason
908
+ );
909
+ }
910
+ }
911
+ async function sendIngestChunk(options, eventsToSend, flushId, flushReason) {
885
912
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
886
913
  try {
887
914
  const response = await options.fetch(`${options.host}/v1/ingest`, {
888
915
  method: "POST",
889
- headers: await getIngestHeaders(options),
916
+ headers: await getIngestHeaders(options, flushReason),
890
917
  body: JSON.stringify(eventsToSend)
891
918
  });
892
919
  debugLog(
@@ -936,7 +963,6 @@ function getRequestContext() {
936
963
 
937
964
  // src/utils/scheduler.ts
938
965
  import { waitUntil } from "@vercel/functions";
939
- var MAX_COUNT = 50;
940
966
  var IDLE_FLUSH_WAIT_MS = 5e3;
941
967
  var IDLE_FLUSH_JITTER_RATIO = 0.2;
942
968
  var MAX_FLUSH_WAIT_MS = 6e4;
@@ -944,32 +970,25 @@ var Scheduler = class {
944
970
  constructor(onFlush) {
945
971
  this.onFlush = onFlush;
946
972
  }
947
- count = 0;
948
973
  resolveWait = null;
949
974
  pending = null;
950
975
  idleTimeout = null;
951
976
  maxTimeout = null;
952
- increment() {
953
- this.count += 1;
954
- if (this.count >= MAX_COUNT) {
955
- this.resolveScheduledFlush();
956
- }
957
- }
958
977
  scheduleFlush() {
959
978
  if (!this.pending) {
960
979
  this.pending = (async () => {
961
- await new Promise((res) => {
980
+ const reason = await new Promise((res) => {
962
981
  this.resolveWait = res;
963
982
  });
964
983
  this.reset();
965
- await this.onFlush();
984
+ await this.onFlush(reason);
966
985
  })();
967
986
  try {
968
987
  waitUntil(this.pending);
969
988
  } catch {
970
989
  }
971
990
  this.maxTimeout = setTimeout(
972
- () => this.resolveScheduledFlush(),
991
+ () => this.resolveScheduledFlush("max_timeout"),
973
992
  MAX_FLUSH_WAIT_MS
974
993
  );
975
994
  }
@@ -981,24 +1000,23 @@ var Scheduler = class {
981
1000
  async shutdown() {
982
1001
  this.clearTimeouts();
983
1002
  const pending = this.pending;
984
- this.resolveWait?.();
1003
+ this.resolveScheduledFlush("shutdown");
985
1004
  if (pending) await pending;
986
1005
  }
987
1006
  resetIdleTimeout() {
988
1007
  if (this.idleTimeout) clearTimeout(this.idleTimeout);
989
1008
  this.idleTimeout = setTimeout(
990
- () => this.resolveScheduledFlush(),
1009
+ () => this.resolveScheduledFlush("idle_timeout"),
991
1010
  getJitteredWaitMs(IDLE_FLUSH_WAIT_MS, IDLE_FLUSH_JITTER_RATIO)
992
1011
  );
993
1012
  }
994
- resolveScheduledFlush() {
1013
+ resolveScheduledFlush(reason) {
995
1014
  this.clearTimeouts();
996
- this.resolveWait?.();
1015
+ this.resolveWait?.(reason);
997
1016
  }
998
1017
  reset() {
999
1018
  this.pending = null;
1000
1019
  this.resolveWait = null;
1001
- this.count = 0;
1002
1020
  this.clearTimeouts();
1003
1021
  }
1004
1022
  clearTimeouts() {
@@ -1109,10 +1127,6 @@ var FlagsEvaluationEvent = class {
1109
1127
  };
1110
1128
 
1111
1129
  // src/utils/usage-tracker.ts
1112
- function evaluationsDisabled() {
1113
- const value = process.env.VERCEL_FLAGS_ENABLE_METRICS?.toLowerCase();
1114
- return value !== "true";
1115
- }
1116
1130
  var UsageTracker = class {
1117
1131
  flushCount = 0;
1118
1132
  options;
@@ -1122,7 +1136,7 @@ var UsageTracker = class {
1122
1136
  evaluationEvents = /* @__PURE__ */ new Map();
1123
1137
  constructor(options) {
1124
1138
  this.options = options;
1125
- this.scheduler = new Scheduler(() => this.flushEvents());
1139
+ this.scheduler = new Scheduler((reason) => this.flushEvents(reason));
1126
1140
  }
1127
1141
  /**
1128
1142
  * Triggers an immediate flush of any pending events.
@@ -1130,7 +1144,7 @@ var UsageTracker = class {
1130
1144
  */
1131
1145
  async shutdown() {
1132
1146
  await this.scheduler.shutdown();
1133
- await this.flushEvents();
1147
+ await this.flushEvents("shutdown");
1134
1148
  }
1135
1149
  /**
1136
1150
  * Tracks a config read event. Deduplicates by request context.
@@ -1143,7 +1157,6 @@ var UsageTracker = class {
1143
1157
  this.trackedRequests.add(ctx);
1144
1158
  this.readEvents.push(new FlagsConfigReadEvent(headers, options));
1145
1159
  this.scheduler.scheduleFlush();
1146
- this.scheduler.increment();
1147
1160
  } catch (error) {
1148
1161
  console.error("@vercel/flags-core: Failed to record event:", error);
1149
1162
  }
@@ -1153,9 +1166,6 @@ var UsageTracker = class {
1153
1166
  */
1154
1167
  trackEvaluation(options) {
1155
1168
  try {
1156
- if (evaluationsDisabled()) {
1157
- return;
1158
- }
1159
1169
  const bucketedOptions = {
1160
1170
  ...options,
1161
1171
  bucketTs: minuteBucketTs()
@@ -1169,7 +1179,6 @@ var UsageTracker = class {
1169
1179
  batchKey,
1170
1180
  new FlagsEvaluationEvent(bucketedOptions)
1171
1181
  );
1172
- this.scheduler.increment();
1173
1182
  }
1174
1183
  this.scheduler.scheduleFlush();
1175
1184
  } catch (error) {
@@ -1182,14 +1191,14 @@ var UsageTracker = class {
1182
1191
  /**
1183
1192
  * Send all events to the ingest service
1184
1193
  */
1185
- async flushEvents() {
1194
+ async flushEvents(flushReason) {
1186
1195
  const events = [...this.readEvents, ...this.evaluationEvents.values()];
1187
1196
  if (events.length === 0) return;
1188
1197
  this.flushCount += 1;
1189
1198
  const flushId = this.flushCount;
1190
1199
  this.readEvents = [];
1191
1200
  this.evaluationEvents.clear();
1192
- await sendIngestEvents(this.options, events, flushId);
1201
+ await sendIngestEvents(this.options, events, flushId, flushReason);
1193
1202
  }
1194
1203
  };
1195
1204
 
@@ -1347,7 +1356,8 @@ function normalizeOptions(options) {
1347
1356
  buildStep,
1348
1357
  fetch: options.fetch ?? globalThis.fetch,
1349
1358
  host: "https://flags.vercel.com",
1350
- clientName: options.clientName
1359
+ clientName: options.clientName,
1360
+ disableMetrics: options.disableMetrics ?? false
1351
1361
  };
1352
1362
  }
1353
1363
 
@@ -2318,7 +2328,7 @@ var Controller = class {
2318
2328
  * Tracks a flag evaluation for usage analytics.
2319
2329
  */
2320
2330
  trackEvaluation(options) {
2321
- if (this.unauthorized) return;
2331
+ if (this.unauthorized || this.options.disableMetrics) return;
2322
2332
  this.usageTracker.trackEvaluation({
2323
2333
  ...options,
2324
2334
  clientName: options.clientName ?? this.options.clientName
@@ -2494,4 +2504,4 @@ export {
2494
2504
  resetDefaultFlagsClient,
2495
2505
  createClient
2496
2506
  };
2497
- //# sourceMappingURL=chunk-ETOLBVKS.js.map
2507
+ //# sourceMappingURL=chunk-3QHXSGEW.js.map