From 1f8b03f2be1624391277322f21d53352017bd6a7 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Tue, 16 Jun 2026 15:38:28 +0200 Subject: [PATCH 1/5] Add FLAGS_EVALUATION metric --- .changeset/evaluation-metrics.md | 5 + packages/vercel-flags-core/CLAUDE.md | 3 +- .../vercel-flags-core/src/black-box.test.ts | 281 +++++++- .../vercel-flags-core/src/controller-fns.ts | 68 +- .../vercel-flags-core/src/controller/index.ts | 18 +- .../src/controller/normalized-options.ts | 7 + packages/vercel-flags-core/src/evaluate.ts | 125 ++-- .../vercel-flags-core/src/index.make.test.ts | 15 + .../vercel-flags-core/src/utils/ingest.ts | 98 +++ .../src/utils/request-context.ts | 29 + .../src/utils/scheduler.test.ts | 220 +++++++ .../vercel-flags-core/src/utils/scheduler.ts | 109 +++ .../src/utils/usage-tracker.test.ts | 619 ++++++++++++++++-- .../src/utils/usage-tracker.ts | 343 +++------- .../src/utils/usage/events.ts | 9 + .../src/utils/usage/flags-config-read.ts | 100 +++ .../src/utils/usage/flags-evaluation.ts | 70 ++ 17 files changed, 1726 insertions(+), 393 deletions(-) create mode 100644 .changeset/evaluation-metrics.md create mode 100644 packages/vercel-flags-core/src/utils/ingest.ts create mode 100644 packages/vercel-flags-core/src/utils/request-context.ts create mode 100644 packages/vercel-flags-core/src/utils/scheduler.test.ts create mode 100644 packages/vercel-flags-core/src/utils/scheduler.ts create mode 100644 packages/vercel-flags-core/src/utils/usage/events.ts create mode 100644 packages/vercel-flags-core/src/utils/usage/flags-config-read.ts create mode 100644 packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts diff --git a/.changeset/evaluation-metrics.md b/.changeset/evaluation-metrics.md new file mode 100644 index 00000000..7a79f8cb --- /dev/null +++ b/.changeset/evaluation-metrics.md @@ -0,0 +1,5 @@ +--- +'@vercel/flags-core': minor +--- + +Add aggregated flag evaluation telemetry and a `clientName` option for the Vercel Flags client. diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 0c3fc0f4..bd70d2dc 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -257,7 +257,8 @@ The Controller tags all data with its origin using `tagData(data, origin)` from ### Usage Tracking -- Batches flag read events (max 50 events, max 5s wait) +- Batches flag read/evaluation events; flushes on any of: 50 distinct events, a 5s idle window (reset on every event), or a 60s max window (starts with the batch, never reset) +- The scheduled flush awaits the full ingest send (incl. retries) before its `waitUntil` promise resolves; `shutdown()` drains any in-flight batch instead of orphaning it - Sends to `flags.vercel.com/v1/ingest` - At runtime: deduplicates by request context (per-instance WeakSet in UsageTracker) - During builds: deduplicates all reads to a single event (buildReadTracked flag in Controller), since there is no request context available diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 2c2bc18a..798c32b8 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -104,6 +104,54 @@ const originalEnv = { ...process.env }; describe('Controller (black-box)', () => { const date = new Date(); + function nextMinuteBucketTs(ts: number): number { + return Math.ceil(ts / 60_000) * 60_000; + } + + function expectEvaluationOnlyIngest( + evaluationCount = 1, + extraEvents: Array<{ + flagKey: string; + variant: string; + reason: string; + evaluationCount: number; + periodStartedAt?: number; + }> = [], + ) { + const periodStartedAt = nextMinuteBucketTs(date.getTime()); + + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + flagKey: 'flagA', + variant: '1', + reason: 'paused', + evaluationCount, + periodStartedAt, + }, + }, + ...extraEvents.map( + ({ periodStartedAt: eventPeriod, ...payload }) => ({ + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + ...payload, + periodStartedAt: eventPeriod ?? periodStartedAt, + }, + }), + ), + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, + ); + } + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(date); @@ -207,8 +255,9 @@ describe('Controller (black-box)', () => { expect(fetchMock).not.toHaveBeenCalled(); await client.shutdown(); - // No ingest call: trackRead skips when request context is unavailable (build step has no request context) - expect(fetchMock).not.toHaveBeenCalled(); + // Config reads skip without request context, but evaluations are still reported. + expect(fetchMock).toHaveBeenCalledTimes(1); + expectEvaluationOnlyIngest(); }); it('should detect build step when NEXT_PHASE=phase-production-build', async () => { @@ -227,8 +276,9 @@ describe('Controller (black-box)', () => { expect(fetchMock).not.toHaveBeenCalled(); await client.shutdown(); - // No ingest call: trackRead skips when request context is unavailable - expect(fetchMock).not.toHaveBeenCalled(); + // Config reads skip without request context, but evaluations are still reported. + expect(fetchMock).toHaveBeenCalledTimes(1); + expectEvaluationOnlyIngest(); }); it('should NOT detect build step when neither CI nor NEXT_PHASE is set', async () => { @@ -332,6 +382,17 @@ describe('Controller (black-box)', () => { environment: 'production', }, }, + { + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + flagKey: 'flagA', + variant: '1', + reason: 'paused', + evaluationCount: 1, + periodStartedAt: nextMinuteBucketTs(date.getTime()), + }, + }, ]), headers: ingestRequestHeaders, method: 'POST', @@ -388,8 +449,16 @@ describe('Controller (black-box)', () => { await client.shutdown(); - // No ingest call: trackRead skips when request context is unavailable (build step has no request context) - expect(fetchMock).toHaveBeenCalledTimes(1); + // Config reads skip without request context, but evaluations are still reported. + expect(fetchMock).toHaveBeenCalledTimes(2); + expectEvaluationOnlyIngest(1, [ + { + flagKey: 'flagB', + variant: 'unknown', + reason: 'error', + evaluationCount: 1, + }, + ]); }); it('should throw when bundled definitions missing and fetch fails during build (no defaultValue)', async () => { @@ -1171,6 +1240,17 @@ describe('Controller (black-box)', () => { environment: 'production', }, }, + { + type: 'FLAG_EVALUATION', + ts: after.getTime(), + payload: { + flagKey: 'flagA', + variant: '1', + reason: 'paused', + evaluationCount: 1, + periodStartedAt: nextMinuteBucketTs(after.getTime()), + }, + }, ]), headers: ingestRequestHeaders, method: 'POST', @@ -1249,6 +1329,17 @@ describe('Controller (black-box)', () => { environment: 'production', }, }, + { + type: 'FLAG_EVALUATION', + ts: after.getTime(), + payload: { + flagKey: 'flagA', + variant: '1', + reason: 'paused', + evaluationCount: 1, + periodStartedAt: nextMinuteBucketTs(after.getTime()), + }, + }, ]), headers: ingestRequestHeaders, method: 'POST', @@ -2116,6 +2207,17 @@ describe('Controller (black-box)', () => { environment: 'production', }, }, + { + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + flagKey: 'flagA', + variant: '1', + reason: 'paused', + evaluationCount: 1, + periodStartedAt: nextMinuteBucketTs(date.getTime()), + }, + }, ]), }, ], @@ -2494,6 +2596,17 @@ describe('Controller (black-box)', () => { environment: 'production', }, }, + { + type: 'FLAG_EVALUATION', + ts: date.getTime() + 60, + payload: { + flagKey: 'flagA', + variant: '1', + reason: 'paused', + evaluationCount: 1, + periodStartedAt: nextMinuteBucketTs(date.getTime() + 60), + }, + }, ]), }, ); @@ -3201,6 +3314,17 @@ describe('Controller (black-box)', () => { environment: 'production', }, }, + { + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + flagKey: 'flagA', + variant: '1', + reason: 'paused', + evaluationCount: 3, + periodStartedAt: nextMinuteBucketTs(date.getTime()), + }, + }, ]), headers: ingestRequestHeaders, method: 'POST', @@ -3247,8 +3371,9 @@ describe('Controller (black-box)', () => { stream.close(); await client.shutdown(); - // No ingest call: trackRead skips when request context is unavailable - expect(fetchMock).toHaveBeenCalledTimes(1); + // Config reads skip without request context, but evaluations are still reported. + expect(fetchMock).toHaveBeenCalledTimes(2); + expectEvaluationOnlyIngest(3); }); it('should start only one retry loop when concurrent evaluate() calls hit a failing stream', async () => { @@ -3403,6 +3528,17 @@ describe('Controller (black-box)', () => { environment: 'production', }, }, + { + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + flagKey: 'flagA', + variant: '1', + reason: 'paused', + evaluationCount: 1, + periodStartedAt: nextMinuteBucketTs(date.getTime()), + }, + }, ]), }, ); @@ -3553,6 +3689,109 @@ describe('Controller (black-box)', () => { // Usage tracking // --------------------------------------------------------------------------- describe('usage tracking', () => { + it('should report counted FLAG_EVALUATION events', async () => { + const cleanupCtx = setRequestContext({ host: 'example.com' }); + fetchMock.mockImplementation((input) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/v1/ingest')) { + return Promise.resolve(new Response(null, { status: 200 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const client = createClient(sdkKey, { + fetch: fetchMock, + stream: false, + polling: false, + clientName: 'checkout', + datafile: makeBundled({ + definitions: { + flagA: { + variantIds: ['off', 'on'], + environments: { production: 1 }, + variants: [false, true], + }, + flagB: { + environments: { production: { fallthrough: 0 } }, + variants: ['control', 'variant'], + }, + }, + }), + }); + + await client.evaluate('flagA'); + await client.evaluate('missing-flag', false); + await client.bulkEvaluate([{ key: 'flagA' }, { key: 'flagB' }]); + + await client.shutdown(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://flags.vercel.com/v1/ingest', + { + body: JSON.stringify([ + { + type: 'FLAGS_CONFIG_READ', + ts: date.getTime(), + payload: { + invocationHost: 'example.com', + configOrigin: 'in-memory', + cacheStatus: 'HIT', + cacheAction: 'NONE', + cacheIsFirstRead: true, + cacheIsBlocking: false, + duration: 0, + configUpdatedAt: 1, + mode: 'offline', + revision: '1', + environment: 'production', + }, + }, + { + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + flagKey: 'flagA', + variant: 'on', + reason: 'paused', + evaluationCount: 2, + periodStartedAt: nextMinuteBucketTs(date.getTime()), + clientName: 'checkout', + }, + }, + { + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + flagKey: 'missing-flag', + variant: 'unknown', + reason: 'error', + evaluationCount: 1, + periodStartedAt: nextMinuteBucketTs(date.getTime()), + clientName: 'checkout', + }, + }, + { + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + flagKey: 'flagB', + variant: '0', + reason: 'fallthrough', + evaluationCount: 1, + periodStartedAt: nextMinuteBucketTs(date.getTime()), + clientName: 'checkout', + }, + }, + ]), + headers: ingestRequestHeaders, + method: 'POST', + }, + ); + + cleanupCtx(); + }); + it('should report FLAGS_CONFIG_READ when using provided datafile in build step', async () => { const passedDatafile = makeBundled({ configUpdatedAt: 2, @@ -3605,8 +3844,9 @@ describe('Controller (black-box)', () => { await client.shutdown(); - // No ingest call: trackRead skips when request context is unavailable (build step has no request context) - expect(fetchMock).not.toHaveBeenCalled(); + // Config reads skip without request context, but evaluations are still reported. + expect(fetchMock).toHaveBeenCalledTimes(1); + expectEvaluationOnlyIngest(); }); it('should not track FLAGS_CONFIG_READ during build step (no request context)', async () => { @@ -3625,8 +3865,9 @@ describe('Controller (black-box)', () => { await client.evaluate('flagA'); await client.shutdown(); - // No ingest call: trackRead skips when request context is unavailable - expect(fetchMock).not.toHaveBeenCalled(); + // Config reads skip without request context, but evaluations are still reported. + expect(fetchMock).toHaveBeenCalledTimes(1); + expectEvaluationOnlyIngest(3); }); it('should report FLAGS_CONFIG_READ with FOLLOWING cacheAction when streaming', async () => { @@ -3690,6 +3931,17 @@ describe('Controller (black-box)', () => { environment: 'production', }, }, + { + type: 'FLAG_EVALUATION', + ts: date.getTime(), + payload: { + flagKey: 'flagA', + variant: '1', + reason: 'paused', + evaluationCount: 1, + periodStartedAt: nextMinuteBucketTs(date.getTime()), + }, + }, ]), headers: ingestRequestHeaders, method: 'POST', @@ -3729,8 +3981,9 @@ describe('Controller (black-box)', () => { await client.shutdown(); - // No ingest call: trackRead skips when request context is unavailable - expect(fetchMock).not.toHaveBeenCalled(); + // Config reads skip without request context, but evaluations are still reported. + expect(fetchMock).toHaveBeenCalledTimes(1); + expectEvaluationOnlyIngest(); }); }); }); diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index f6b779b9..9f5c6e75 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -2,6 +2,7 @@ import { type BulkEvaluationInput, bulkEvaluate as bulkEvalFlags, evaluate as evalFlag, + getEvaluationVariantIndex, } from './evaluate'; import { internalReportValue } from './lib/report-value'; import type { @@ -14,6 +15,7 @@ import type { Packed, } from './types'; import { ErrorCode, ResolutionReason } from './types'; +import type { TrackEvaluationOptions } from './utils/usage/flags-evaluation'; export type ControllerInstance = { controller: ControllerInterface; @@ -23,6 +25,10 @@ export type ControllerInstance = { export const controllerInstanceMap = new Map(); +type EvaluationTrackingController = ControllerInterface & { + trackEvaluation?: (options: TrackEvaluationOptions) => void; +}; + function getInstance(id: number): ControllerInstance { const instance = controllerInstanceMap.get(id); if (!instance) { @@ -51,13 +57,29 @@ export function getFallbackDatafile(id: number): Promise { throw new Error('flags: This data source does not support fallbacks'); } +function getVariantIdentifier( + definition: Packed.FlagDefinition, + result: EvaluationResult, +): string { + const variantIndex = getEvaluationVariantIndex(result); + if (variantIndex === undefined) return 'unknown'; + return definition.variantIds?.[variantIndex] ?? String(variantIndex); +} + +function trackEvaluation( + controller: EvaluationTrackingController, + options: TrackEvaluationOptions, +): void { + controller.trackEvaluation?.(options); +} + export async function evaluate>( id: number, flagKey: string, defaultValue?: T, entities?: E, ): Promise> { - const controller = getInstance(id).controller; + const controller = getInstance(id).controller as EvaluationTrackingController; let datafile: Datafile; try { @@ -65,12 +87,18 @@ export async function evaluate>( } catch (error) { // All data sources failed. Fall back to defaultValue if provided. if (defaultValue !== undefined) { - return { + const result: EvaluationResult = { value: defaultValue, reason: ResolutionReason.ERROR, errorMessage: error instanceof Error ? error.message : 'Failed to read datafile', }; + trackEvaluation(controller, { + flagKey, + variant: 'unknown', + reason: result.reason, + }); + return result; } throw error; } @@ -86,7 +114,7 @@ export async function evaluate>( }); } - return { + const result: EvaluationResult = { value: defaultValue, reason: ResolutionReason.ERROR, errorCode: ErrorCode.FLAG_NOT_FOUND, @@ -100,6 +128,12 @@ export async function evaluate>( mode: datafile.metrics.mode, }, }; + trackEvaluation(controller, { + flagKey, + variant: 'unknown', + reason: result.reason, + }); + return result; } const evalStartTime = Date.now(); @@ -123,6 +157,14 @@ export async function evaluate>( : undefined, }); } + trackEvaluation(controller, { + flagKey, + variant: getVariantIdentifier( + flagDefinition, + result as EvaluationResult, + ), + reason: result.reason, + }); return Object.assign(result, { metrics: { @@ -141,7 +183,7 @@ export async function bulkEvaluate>( flags: BulkEvaluateInput[], entities?: E, ): Promise>> { - const controller = getInstance(id).controller; + const controller = getInstance(id).controller as EvaluationTrackingController; let datafile: Datafile; try { @@ -157,6 +199,11 @@ export async function bulkEvaluate>( reason: ResolutionReason.ERROR, errorMessage, }; + trackEvaluation(controller, { + flagKey: flag.key, + variant: 'unknown', + reason: ResolutionReason.ERROR, + }); } return results; } @@ -192,6 +239,11 @@ export async function bulkEvaluate>( errorMessage: `@vercel/flags-core: Definition not found for flag "${key}"`, metrics: { evaluationMs: 0, ...baseMetrics }, }; + trackEvaluation(controller, { + flagKey: key, + variant: 'unknown', + reason: ResolutionReason.ERROR, + }); continue; } @@ -219,6 +271,14 @@ export async function bulkEvaluate>( : undefined, }); } + trackEvaluation(controller, { + flagKey: key, + variant: getVariantIdentifier( + toEvaluate[key]!.definition, + result as EvaluationResult, + ), + reason: result.reason, + }); results[key] = Object.assign(result, { metrics: { evaluationMs: evaluationDurationMs, ...baseMetrics }, }); diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index e841bfd9..fa4b0a00 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -6,7 +6,9 @@ import type { Metrics, } from '../types'; import { readBundledDefinitions } from '../utils/read-bundled-definitions'; -import { type TrackReadOptions, UsageTracker } from '../utils/usage-tracker'; +import type { TrackReadOptions } from '../utils/usage/flags-config-read'; +import type { TrackEvaluationOptions } from '../utils/usage/flags-evaluation'; +import { UsageTracker } from '../utils/usage-tracker'; import { BundledSource } from './bundled-source'; import { fetchDatafile } from './fetch-datafile'; import { @@ -346,7 +348,7 @@ export class Controller implements ControllerInterface { ? tagData(this.options.datafile, 'provided') : undefined; this.transition('shutdown'); - await this.usageTracker.flush(); + await this.usageTracker.shutdown(); } /** @@ -814,4 +816,16 @@ export class Controller implements ControllerInterface { } this.usageTracker.trackRead(trackOptions); } + + /** + * Tracks a flag evaluation for usage analytics. + */ + trackEvaluation(options: TrackEvaluationOptions): void { + if (this.unauthorized) return; + + this.usageTracker.trackEvaluation({ + ...options, + clientName: options.clientName ?? this.options.clientName, + }); + } } diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index f17b15a4..9eda555c 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -52,6 +52,11 @@ export type ControllerOptions = { * @default globalThis.fetch */ fetch?: typeof globalThis.fetch; + + /** + * Custom client name included in evaluation telemetry. + */ + clientName?: string; }; export type NormalizedOptions = { @@ -62,6 +67,7 @@ export type NormalizedOptions = { buildStep: boolean; fetch: typeof globalThis.fetch; host: string; + clientName: string | undefined; }; export function normalizeOptions( @@ -111,5 +117,6 @@ export function normalizeOptions( buildStep, fetch: options.fetch ?? globalThis.fetch, host: 'https://flags.vercel.com', + clientName: options.clientName, }; } diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index 3ce46f6f..b041f5d1 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -20,6 +20,36 @@ const UINT32_MAX = 4_294_967_295; // don't leak into serialized datafiles or surprise consumers. const SCALED_WEIGHTS = Symbol('@vercel/flags-core:scaledWeights'); const COMPILED_REGEX = Symbol('@vercel/flags-core:compiledRegex'); +const EVALUATION_VARIANT_INDEX = Symbol( + '@vercel/flags-core:evaluationVariantIndex', +); + +type EvaluationOutcome = { + value: T; + outcomeType: OutcomeType; + [EVALUATION_VARIANT_INDEX]?: number; +}; + +function withVariantIndex( + value: T, + outcomeType: OutcomeType, + variantIndex: number, +): EvaluationOutcome { + return Object.defineProperty( + { + value, + outcomeType, + }, + EVALUATION_VARIANT_INDEX, + { value: variantIndex }, + ); +} + +export function getEvaluationVariantIndex( + result: EvaluationResult, +): number | undefined { + return (result as EvaluationOutcome)[EVALUATION_VARIANT_INDEX]; +} function getScaledWeights(outcome: Packed.SplitOutcome): number[] { const cached = (outcome as unknown as Record)[ @@ -376,15 +406,13 @@ function getVariant(variants: unknown[], index: number): T { function handleOutcome( params: EvaluationParams, outcome: Packed.Outcome, -): { - value: T; - outcomeType: OutcomeType; -} { +): EvaluationOutcome { if (typeof outcome === 'number') { - return { - value: getVariant(params.definition.variants, outcome), - outcomeType: OutcomeType.VALUE, - }; + return withVariantIndex( + getVariant(params.definition.variants, outcome), + OutcomeType.VALUE, + outcome, + ); } switch (outcome.type) { case 'split': { @@ -396,7 +424,11 @@ function handleOutcome( // serve the default variant if the lhs is not a string if (typeof lhs !== 'string') { - return { value: defaultOutcome, outcomeType: OutcomeType.SPLIT }; + return withVariantIndex( + defaultOutcome, + OutcomeType.SPLIT, + outcome.defaultVariant, + ); } /** @@ -407,13 +439,15 @@ function handleOutcome( const value = hashInput(lhs, params.definition.seed); const scaledWeights = getScaledWeights(outcome); const variantIndex = findWeightedIndex(scaledWeights, value, UINT32_MAX); - return { - value: - variantIndex === -1 - ? defaultOutcome - : getVariant(params.definition.variants, variantIndex), - outcomeType: OutcomeType.SPLIT, - }; + const selectedVariantIndex = + variantIndex === -1 ? outcome.defaultVariant : variantIndex; + return withVariantIndex( + selectedVariantIndex === outcome.defaultVariant + ? defaultOutcome + : getVariant(params.definition.variants, selectedVariantIndex), + OutcomeType.SPLIT, + selectedVariantIndex, + ); } case 'rollout': { const lhs = access(outcome.base, params); @@ -424,7 +458,11 @@ function handleOutcome( // serve the default variant if the lhs is not a string if (typeof lhs !== 'string') { - return { value: defaultOutcome, outcomeType: OutcomeType.ROLLOUT }; + return withVariantIndex( + defaultOutcome, + OutcomeType.ROLLOUT, + outcome.defaultVariant, + ); } // Determine active slot based on elapsed time @@ -433,13 +471,11 @@ function handleOutcome( // Before rollout starts or no slots, serve rollFromVariant if (elapsed < 0 || outcome.slots.length === 0) { - return { - value: getVariant( - params.definition.variants, - outcome.rollFromVariant, - ), - outcomeType: OutcomeType.ROLLOUT, - }; + return withVariantIndex( + getVariant(params.definition.variants, outcome.rollFromVariant), + OutcomeType.ROLLOUT, + outcome.rollFromVariant, + ); } // Walk slots to find current promille. @@ -461,37 +497,30 @@ function handleOutcome( // short-circuit common edges if (currentPromille <= 0) { - return { - value: getVariant( - params.definition.variants, - outcome.rollFromVariant, - ), - outcomeType: OutcomeType.ROLLOUT, - }; + return withVariantIndex( + getVariant(params.definition.variants, outcome.rollFromVariant), + OutcomeType.ROLLOUT, + outcome.rollFromVariant, + ); } if (currentPromille >= 100_000) { - return { - value: getVariant( - params.definition.variants, - outcome.rollToVariant, - ), - outcomeType: OutcomeType.ROLLOUT, - }; + return withVariantIndex( + getVariant(params.definition.variants, outcome.rollToVariant), + OutcomeType.ROLLOUT, + outcome.rollToVariant, + ); } const value = hashInput(lhs, params.definition.seed); const threshold = (currentPromille / 100_000) * UINT32_MAX; + const selectedVariantIndex = + value < threshold ? outcome.rollToVariant : outcome.rollFromVariant; - return { - value: - value < threshold - ? getVariant(params.definition.variants, outcome.rollToVariant) - : getVariant( - params.definition.variants, - outcome.rollFromVariant, - ), - outcomeType: OutcomeType.ROLLOUT, - }; + return withVariantIndex( + getVariant(params.definition.variants, selectedVariantIndex), + OutcomeType.ROLLOUT, + selectedVariantIndex, + ); } default: { const { type } = outcome; diff --git a/packages/vercel-flags-core/src/index.make.test.ts b/packages/vercel-flags-core/src/index.make.test.ts index 52e533ac..ba7497d7 100644 --- a/packages/vercel-flags-core/src/index.make.test.ts +++ b/packages/vercel-flags-core/src/index.make.test.ts @@ -101,6 +101,21 @@ describe('make', () => { expect(client).toBeDefined(); }); + it('should pass clientName to the controller', () => { + const createRawClient = createMockCreateRawClient(); + const { createClient } = make(createRawClient); + + const client = createClient('vf_server_test_key', { + clientName: 'checkout', + }); + + expect(Controller).toHaveBeenCalledWith({ + auth: expect.objectContaining({ sdkKey: 'vf_server_test_key' }), + clientName: 'checkout', + }); + expect(client).toBeDefined(); + }); + it('should throw for empty SDK key', () => { const createRawClient = createMockCreateRawClient(); const { createClient } = make(createRawClient); diff --git a/packages/vercel-flags-core/src/utils/ingest.ts b/packages/vercel-flags-core/src/utils/ingest.ts new file mode 100644 index 00000000..8248c553 --- /dev/null +++ b/packages/vercel-flags-core/src/utils/ingest.ts @@ -0,0 +1,98 @@ +import { getVercelOidcToken } from '@vercel/oidc'; +import { version } from '../../package.json'; +import type { Auth } from '../controller/auth'; +import { getRetryDelayMs } from './backoff'; +import type { UsageEvent } from './usage/events'; + +const MAX_RETRIES = 3; + +export const EVALUATING_OIDC_TOKEN_HEADER = 'X-Vercel-Flags-OIDC-Token'; + +const isDebugMode = process.env.DEBUG?.includes('@vercel/flags-core'); + +const debugLog = (...args: any[]) => { + if (!isDebugMode) return; + console.log(...args); +}; + +export interface IngestOptions { + auth: Auth; + host: string; + fetch: typeof fetch; +} + +async function getEvaluatingOidcToken(auth: Auth): Promise { + if (!auth.sdkKey) return undefined; + + try { + return await getVercelOidcToken(); + } catch { + return undefined; + } +} + +async function getIngestHeaders( + options: IngestOptions, +): Promise> { + const token = await options.auth.resolveToken(); + const evaluatingOidcToken = await getEvaluatingOidcToken(options.auth); + + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'User-Agent': `VercelFlagsCore/${version}`, + ...(process.env.VERCEL_ENV + ? { 'X-Vercel-Env': process.env.VERCEL_ENV } + : null), + ...(evaluatingOidcToken + ? { [EVALUATING_OIDC_TOKEN_HEADER]: evaluatingOidcToken } + : null), + ...(isDebugMode ? { 'x-vercel-debug-ingest': '1' } : null), + }; +} + +export async function sendIngestEvents( + options: IngestOptions, + events: UsageEvent[], + flushId: number, +): Promise { + const eventsToSend = events.map((event) => event.ingestEvent()); + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const response = await options.fetch(`${options.host}/v1/ingest`, { + method: 'POST', + headers: await getIngestHeaders(options), + body: JSON.stringify(eventsToSend), + }); + + debugLog( + `@vercel/flags-core: Ingest response ${response.status} for ${eventsToSend.length} events on ${response.headers.get('x-vercel-id')}`, + ); + + if (response.ok) { + break; + } + + throw new Error( + `Ingest endpoint responded with status ${response.status} for ${eventsToSend.length} events on request ${response.headers.get('x-vercel-id')}.\n` + + `Response body: ${await response.text().catch(() => null)}`, + ); + } catch (error) { + console.error( + `@vercel/flags-core: Error sending events (attempt=${attempt}/${MAX_RETRIES} flushId=${flushId}):`, + error, + ); + if (attempt < MAX_RETRIES) { + const delayMs = getRetryDelayMs(attempt); + await new Promise((res) => setTimeout(res, delayMs)); + } else { + // All retries exhausted - surface a structured warning so consumers + // can alert on dropped batches. The events are not persisted anywhere. + console.error( + `@vercel/flags-core: Dropped ${eventsToSend.length} events after ${MAX_RETRIES} attempts (flushId=${flushId})`, + ); + } + } + } +} diff --git a/packages/vercel-flags-core/src/utils/request-context.ts b/packages/vercel-flags-core/src/utils/request-context.ts new file mode 100644 index 00000000..a8c748b6 --- /dev/null +++ b/packages/vercel-flags-core/src/utils/request-context.ts @@ -0,0 +1,29 @@ +interface RequestContext { + ctx: object | undefined; + headers: Record | undefined; +} + +const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context'); +const fromSymbol = globalThis as typeof globalThis & { + [key: symbol]: + | { get?: () => { headers?: Record } } + | undefined; +}; + +/** + * Gets the Vercel request context and headers from the global symbol. + */ +export function getRequestContext(): RequestContext { + try { + const ctx = fromSymbol[SYMBOL_FOR_REQ_CONTEXT]?.get?.(); + if (ctx && Object.hasOwn(ctx, 'headers')) { + return { + ctx, + headers: ctx.headers as Record, + }; + } + return { ctx, headers: undefined }; + } catch { + return { ctx: undefined, headers: undefined }; + } +} diff --git a/packages/vercel-flags-core/src/utils/scheduler.test.ts b/packages/vercel-flags-core/src/utils/scheduler.test.ts new file mode 100644 index 00000000..02f46c40 --- /dev/null +++ b/packages/vercel-flags-core/src/utils/scheduler.test.ts @@ -0,0 +1,220 @@ +import { waitUntil } from '@vercel/functions'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Scheduler } from './scheduler'; + +vi.mock('@vercel/functions', () => ({ + waitUntil: vi.fn(), +})); + +const waitUntilMock = vi.mocked(waitUntil); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +describe('Scheduler', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + waitUntilMock.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('flushes after the idle window', async () => { + const onFlush = vi.fn(); + const scheduler = new Scheduler(onFlush); + + scheduler.scheduleFlush(); + + await vi.advanceTimersByTimeAsync(4999); + expect(onFlush).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + + await vi.waitFor(() => { + expect(onFlush).toHaveBeenCalledTimes(1); + }); + }); + + it('applies jitter to the idle window', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const onFlush = vi.fn(); + const scheduler = new Scheduler(onFlush); + + scheduler.scheduleFlush(); + + await vi.advanceTimersByTimeAsync(3999); + expect(onFlush).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + + await vi.waitFor(() => { + expect(onFlush).toHaveBeenCalledTimes(1); + }); + }); + + it('resets the idle window when a new flush is scheduled', async () => { + const onFlush = vi.fn(); + const scheduler = new Scheduler(onFlush); + + scheduler.scheduleFlush(); + await vi.advanceTimersByTimeAsync(4999); + + scheduler.scheduleFlush(); + await vi.advanceTimersByTimeAsync(4999); + expect(onFlush).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + + await vi.waitFor(() => { + expect(onFlush).toHaveBeenCalledTimes(1); + }); + }); + + it('flushes when the count reaches the max batch size', async () => { + const onFlush = vi.fn(); + const scheduler = new Scheduler(onFlush); + + scheduler.scheduleFlush(); + for (let i = 0; i < 49; i++) { + scheduler.increment(); + } + expect(onFlush).not.toHaveBeenCalled(); + + scheduler.increment(); + + await vi.waitFor(() => { + expect(onFlush).toHaveBeenCalledTimes(1); + }); + }); + + it('registers the scheduled flush with waitUntil', () => { + const scheduler = new Scheduler(vi.fn()); + + scheduler.scheduleFlush(); + + expect(waitUntilMock).toHaveBeenCalledTimes(1); + expect(waitUntilMock).toHaveBeenCalledWith(expect.any(Promise)); + }); + + it('starts a fresh batch after a flush completes', async () => { + const onFlush = vi.fn(); + const scheduler = new Scheduler(onFlush); + + scheduler.scheduleFlush(); + for (let i = 0; i < 50; i++) { + scheduler.increment(); + } + + await vi.waitFor(() => { + expect(onFlush).toHaveBeenCalledTimes(1); + }); + + // A new batch should accumulate independently and flush again. + scheduler.scheduleFlush(); + for (let i = 0; i < 50; i++) { + scheduler.increment(); + } + + await vi.waitFor(() => { + expect(onFlush).toHaveBeenCalledTimes(2); + }); + }); + + it('does not resolve the waitUntil promise until the async flush completes', async () => { + const flushDeferred = deferred(); + const onFlush = vi.fn(() => flushDeferred.promise); + const scheduler = new Scheduler(onFlush); + + scheduler.scheduleFlush(); + + const pending = waitUntilMock.mock.calls[0]![0] as Promise; + let settled = false; + void pending.then(() => { + settled = true; + }); + + // Trigger the flush via the count threshold. + for (let i = 0; i < 50; i++) { + scheduler.increment(); + } + + // onFlush has been invoked, but the pending promise must not resolve yet. + await vi.advanceTimersByTimeAsync(0); + expect(onFlush).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + flushDeferred.resolve(); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(true); + }); + + it('flushes at the max window under continuous traffic', async () => { + const onFlush = vi.fn(); + const scheduler = new Scheduler(onFlush); + + // Keep scheduling every 4s so the 5s idle timer never fires. + scheduler.scheduleFlush(); + for (let elapsed = 0; elapsed < 56000; elapsed += 4000) { + await vi.advanceTimersByTimeAsync(4000); + expect(onFlush).not.toHaveBeenCalled(); + scheduler.scheduleFlush(); + } + + // At the 60s mark since the first event, the max window fires. + await vi.advanceTimersByTimeAsync(4000); + + await vi.waitFor(() => { + expect(onFlush).toHaveBeenCalledTimes(1); + }); + }); + + it('resolves a pending flush on shutdown', async () => { + const onFlush = vi.fn(); + const scheduler = new Scheduler(onFlush); + + scheduler.scheduleFlush(); + + await scheduler.shutdown(); + + expect(onFlush).toHaveBeenCalledTimes(1); + }); + + it('shutdown resolves without hanging when nothing is pending', async () => { + const onFlush = vi.fn(); + const scheduler = new Scheduler(onFlush); + + await scheduler.shutdown(); + + expect(onFlush).not.toHaveBeenCalled(); + }); + + it('shutdown awaits the in-flight ingest', async () => { + const flushDeferred = deferred(); + const onFlush = vi.fn(() => flushDeferred.promise); + const scheduler = new Scheduler(onFlush); + + scheduler.scheduleFlush(); + + let shutdownResolved = false; + const shutdownPromise = scheduler.shutdown().then(() => { + shutdownResolved = true; + }); + + await vi.advanceTimersByTimeAsync(0); + expect(onFlush).toHaveBeenCalledTimes(1); + expect(shutdownResolved).toBe(false); + + flushDeferred.resolve(); + await shutdownPromise; + expect(shutdownResolved).toBe(true); + }); +}); diff --git a/packages/vercel-flags-core/src/utils/scheduler.ts b/packages/vercel-flags-core/src/utils/scheduler.ts new file mode 100644 index 00000000..770e0e8e --- /dev/null +++ b/packages/vercel-flags-core/src/utils/scheduler.ts @@ -0,0 +1,109 @@ +import { waitUntil } from '@vercel/functions'; +import { getJitteredWaitMs } from './backoff'; + +const MAX_COUNT = 50; +const IDLE_FLUSH_WAIT_MS = 5000; +const IDLE_FLUSH_JITTER_RATIO = 0.2; +const MAX_FLUSH_WAIT_MS = 60000; + +/** + * Schedule helper that flushes when any of the following occur: + * - the batch size is reached ({@link MAX_COUNT} distinct events), + * - the idle window elapses ({@link IDLE_FLUSH_WAIT_MS}, reset on every event), or + * - the max window elapses ({@link MAX_FLUSH_WAIT_MS}, starts with the batch and is + * never reset, so a batch always flushes under continuous traffic). + * + * The scheduled flush awaits {@link onFlush} (including its ingest send + retries), + * so the promise handed to `waitUntil` does not resolve until ingest completes. + */ +export class Scheduler { + private count: number = 0; + private resolveWait: (() => void) | null = null; + private pending: null | Promise = null; + private idleTimeout: null | ReturnType = null; + private maxTimeout: null | ReturnType = null; + + constructor(private readonly onFlush: () => void | Promise) {} + + increment(): void { + this.count += 1; + + // immediately flush if we've reached the batch size + if (this.count >= MAX_COUNT) { + this.resolveScheduledFlush(); + } + } + + scheduleFlush(): void { + if (!this.pending) { + this.pending = (async () => { + // wait for a timeout or the event count to reach the batch size + await new Promise((res) => { + this.resolveWait = res; + }); + + // free state so new events start a fresh batch while ingest runs + this.reset(); + + // genuinely await ingest (incl. retries) so waitUntil covers the send + await this.onFlush(); + })(); + + try { + waitUntil(this.pending); + } catch { + // waitUntil is best-effort; falling through leaves a floating promise + } + + // max window: starts with the batch and is never reset + this.maxTimeout = setTimeout( + () => this.resolveScheduledFlush(), + MAX_FLUSH_WAIT_MS, + ); + } + + // idle window: reset on every event + this.resetIdleTimeout(); + } + + /** + * Resolves any in-flight scheduled flush and waits for its ingest to finish. + */ + async shutdown(): Promise { + this.clearTimeouts(); + const pending = this.pending; + this.resolveWait?.(); + if (pending) await pending; + } + + private resetIdleTimeout(): void { + if (this.idleTimeout) clearTimeout(this.idleTimeout); + this.idleTimeout = setTimeout( + () => this.resolveScheduledFlush(), + getJitteredWaitMs(IDLE_FLUSH_WAIT_MS, IDLE_FLUSH_JITTER_RATIO), + ); + } + + private resolveScheduledFlush(): void { + this.clearTimeouts(); + this.resolveWait?.(); + } + + private reset(): void { + this.pending = null; + this.resolveWait = null; + this.count = 0; + this.clearTimeouts(); + } + + private clearTimeouts(): void { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + if (this.maxTimeout) { + clearTimeout(this.maxTimeout); + this.maxTimeout = null; + } + } +} diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index 86895f60..91988840 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -1,15 +1,68 @@ +import { waitUntil } from '@vercel/functions'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Auth } from '../controller/auth'; import { setRequestContext } from '../test-utils'; -import { type FlagsConfigReadEvent, UsageTracker } from './usage-tracker'; +import { ResolutionReason } from '../types'; +import { EVALUATING_OIDC_TOKEN_HEADER, type IngestOptions } from './ingest'; +import { UsageTracker } from './usage-tracker'; + +type SerializedConfigReadEvent = { + type: 'FLAGS_CONFIG_READ'; + ts: number; + payload: { + deploymentId?: string; + region?: string; + invocationHost?: string; + vercelRequestId?: string; + cacheStatus?: string; + cacheAction?: string; + cacheIsBlocking?: boolean; + cacheIsFirstRead?: boolean; + duration?: number; + configUpdatedAt?: number; + configOrigin?: string; + mode?: string; + revision?: string; + environment?: string; + }; +}; + +type SerializedEvaluationEvent = { + type: 'FLAG_EVALUATION'; + ts: number; + payload: { + flagKey: string; + variant: string; + reason: ResolutionReason; + clientName?: string; + evaluationCount: number; + periodStartedAt: number; + }; +}; + +const getVercelOidcTokenMock = vi.hoisted(() => vi.fn()); + +vi.mock('@vercel/oidc', () => ({ + getVercelOidcToken: getVercelOidcTokenMock, +})); // Mock @vercel/functions vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn(), })); +const waitUntilMock = vi.mocked(waitUntil); + const fetchMock = vi.fn(); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + function jsonResponse( body: unknown, init?: { status?: number; headers?: Record }, @@ -30,13 +83,17 @@ let cleanupContext: (() => void) | undefined; beforeEach(() => { // Set up request context so trackRead doesn't skip (it's skipped when ctx is unavailable) cleanupContext = setRequestContext({ host: 'example.com' }); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + waitUntilMock.mockReset(); }); afterEach(() => { cleanupContext?.(); cleanupContext = undefined; fetchMock.mockReset(); + getVercelOidcTokenMock.mockReset(); vi.restoreAllMocks(); + vi.useRealTimers(); vi.unstubAllEnvs(); }); @@ -49,11 +106,12 @@ function createAuth(sdkKey = 'test-key'): Auth { }; } -function createTracker(sdkKey = 'test-key') { +function createTracker(sdkKey = 'test-key', options?: Partial) { return new UsageTracker({ auth: createAuth(sdkKey), host: 'https://example.com', fetch: fetchMock, + ...options, }); } @@ -85,7 +143,7 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -96,16 +154,32 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); expect(fetchMock).toHaveBeenCalledTimes(1); - const events = getBody() as FlagsConfigReadEvent[]; + const events = getBody() as SerializedConfigReadEvent[]; expect(events).toHaveLength(1); - const event = events[0] as FlagsConfigReadEvent; + const event = events[0] as SerializedConfigReadEvent; expect(event.type).toBe('FLAGS_CONFIG_READ'); expect(event.ts).toBeTypeOf('number'); }); + it('should timestamp config read events when they are tracked', async () => { + vi.useFakeTimers(); + const trackedAt = new Date('2026-01-01T00:00:00.000Z'); + vi.setSystemTime(trackedAt); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + + tracker.trackRead(); + vi.setSystemTime(new Date(trackedAt.getTime() + 10_000)); + await tracker.shutdown(); + + const events = getBody() as SerializedConfigReadEvent[]; + expect(events[0]?.ts).toBe(trackedAt.getTime()); + }); + it('should include deployment ID and region from environment', async () => { vi.stubEnv('VERCEL_DEPLOYMENT_ID', 'dpl_123'); vi.stubEnv('VERCEL_REGION', 'iad1'); @@ -115,10 +189,10 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); - const events = getBody() as FlagsConfigReadEvent[]; - const event = events[0] as FlagsConfigReadEvent; + const events = getBody() as SerializedConfigReadEvent[]; + const event = events[0] as SerializedConfigReadEvent; expect(event.payload.deploymentId).toBe('dpl_123'); expect(event.payload.region).toBe('iad1'); }); @@ -137,7 +211,7 @@ describe('UsageTracker', () => { }); tracker.trackRead(); } - await tracker.flush(); + await tracker.shutdown(); const events = getBody() as Array<{ type: string }>; expect(events).toHaveLength(3); @@ -153,9 +227,56 @@ describe('UsageTracker', () => { }); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); + + expect(getHeaders().Authorization).toBe('Bearer my-secret-key'); + }); + + it('should send evaluating OIDC header when SDK key auth is used', async () => { + getVercelOidcTokenMock.mockResolvedValue('oidc-token'); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker('my-secret-key'); + + tracker.trackRead(); + await tracker.shutdown(); + + expect(getHeaders().Authorization).toBe('Bearer my-secret-key'); + expect(getHeaders()[EVALUATING_OIDC_TOKEN_HEADER]).toBe('oidc-token'); + }); + + it('should omit evaluating OIDC header when OIDC is unavailable', async () => { + getVercelOidcTokenMock.mockRejectedValue(new Error('No OIDC')); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker('my-secret-key'); + + tracker.trackRead(); + await tracker.shutdown(); expect(getHeaders().Authorization).toBe('Bearer my-secret-key'); + expect(getHeaders()[EVALUATING_OIDC_TOKEN_HEADER]).toBeUndefined(); + }); + + it('should not send evaluating OIDC header when OIDC is primary auth', async () => { + getVercelOidcTokenMock.mockResolvedValue('evaluating-oidc-token'); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = new UsageTracker({ + auth: { + resolveToken: () => Promise.resolve('primary-oidc-token'), + resolveBundledDefinitionsLookup: () => + Promise.resolve({ type: 'project-id', projectId: 'prj_123' }), + }, + host: 'https://example.com', + fetch: fetchMock, + }); + + tracker.trackRead(); + await tracker.shutdown(); + + expect(getHeaders().Authorization).toBe('Bearer primary-oidc-token'); + expect(getHeaders()[EVALUATING_OIDC_TOKEN_HEADER]).toBeUndefined(); }); it('should send correct content-type header', async () => { @@ -164,7 +285,7 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); expect(getHeaders()['Content-Type']).toBe('application/json'); }); @@ -175,7 +296,7 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); expect(getHeaders()['User-Agent']).toMatch(/^VercelFlagsCore\//); }); @@ -185,12 +306,32 @@ describe('UsageTracker', () => { const tracker = createTracker(); - // Flush without tracking anything - await tracker.flush(); + // Shut down without tracking anything + await tracker.shutdown(); expect(fetchMock).not.toHaveBeenCalled(); }); + it('should not emit flag evaluation metrics when disabled by env var', async () => { + vi.stubEnv('VERCEL_FLAGS_DISABLE_FLAG_EVALUATIONS', '1'); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + + tracker.trackRead({ configOrigin: 'in-memory' }); + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: 'enabled', + reason: ResolutionReason.FALLTHROUGH, + }); + await tracker.shutdown(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getBody()).toEqual([ + expect.objectContaining({ type: 'FLAGS_CONFIG_READ' }), + ]); + }); + it('should handle fetch errors gracefully', async () => { const consoleSpy = vi .spyOn(console, 'error') @@ -201,7 +342,7 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); // Should not throw, errors are logged via console.error expect(consoleSpy).toHaveBeenCalled(); @@ -217,7 +358,7 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); // Should not throw, errors are logged via console.error expect(consoleSpy).toHaveBeenCalled(); @@ -245,7 +386,7 @@ describe('UsageTracker', () => { }); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); expect(getHeaders()['x-vercel-debug-ingest']).toBe('1'); expect(consoleSpy).toHaveBeenCalledWith( @@ -261,7 +402,7 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); expect(getHeaders()['x-vercel-debug-ingest']).toBeUndefined(); }); @@ -283,7 +424,7 @@ describe('UsageTracker', () => { }); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -293,7 +434,309 @@ describe('UsageTracker', () => { }); }); - describe('flush', () => { + describe('trackEvaluation', () => { + it('should aggregate matching evaluations into counted buckets', async () => { + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: 'enabled', + reason: ResolutionReason.FALLTHROUGH, + clientName: 'checkout', + }); + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: 'enabled', + reason: ResolutionReason.FALLTHROUGH, + clientName: 'checkout', + }); + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: 'disabled', + reason: ResolutionReason.FALLTHROUGH, + clientName: 'checkout', + }); + + await tracker.shutdown(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const events = getBody() as SerializedEvaluationEvent[]; + expect(events).toHaveLength(2); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'FLAG_EVALUATION', + payload: expect.objectContaining({ + flagKey: 'flag-a', + variant: 'enabled', + reason: ResolutionReason.FALLTHROUGH, + clientName: 'checkout', + evaluationCount: 2, + periodStartedAt: expect.any(Number), + }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + payload: expect.objectContaining({ + flagKey: 'flag-a', + variant: 'disabled', + reason: ResolutionReason.FALLTHROUGH, + clientName: 'checkout', + evaluationCount: 1, + periodStartedAt: expect.any(Number), + }), + }), + ); + }); + + it('should preserve evaluation timestamps and bucket periodStartedAt to the next minute', async () => { + vi.useFakeTimers(); + const trackedAt = new Date('2026-01-01T00:00:15.123Z'); + const bucketTs = new Date('2026-01-01T00:01:00.000Z').getTime(); + vi.setSystemTime(trackedAt); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: 'enabled', + reason: ResolutionReason.FALLTHROUGH, + }); + vi.setSystemTime(new Date(trackedAt.getTime() + 10_000)); + await tracker.shutdown(); + + const events = getBody() as SerializedEvaluationEvent[]; + expect(events[0]?.ts).toBe(trackedAt.getTime()); + expect(events[0]?.payload.periodStartedAt).toBe(bucketTs); + }); + + it('should keep exact minute boundaries in the same bucket', async () => { + vi.useFakeTimers(); + const trackedAt = new Date('2026-01-01T00:01:00.000Z'); + vi.setSystemTime(trackedAt); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: 'enabled', + reason: ResolutionReason.FALLTHROUGH, + }); + + await tracker.shutdown(); + + const events = getBody() as SerializedEvaluationEvent[]; + expect(events[0]?.ts).toBe(trackedAt.getTime()); + expect(events[0]?.payload.periodStartedAt).toBe(trackedAt.getTime()); + }); + + it('should aggregate matching evaluations in the same minute bucket', async () => { + vi.useFakeTimers(); + const bucketTs = new Date('2026-01-01T00:01:00.000Z').getTime(); + vi.setSystemTime(new Date('2026-01-01T00:00:15.000Z')); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: 'enabled', + reason: ResolutionReason.FALLTHROUGH, + }); + vi.setSystemTime(new Date('2026-01-01T00:00:59.999Z')); + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: 'enabled', + reason: ResolutionReason.FALLTHROUGH, + }); + vi.setSystemTime(new Date('2026-01-01T00:01:10.000Z')); + await tracker.shutdown(); + + const events = getBody() as SerializedEvaluationEvent[]; + expect(events).toHaveLength(1); + expect(events[0]?.ts).toBe( + new Date('2026-01-01T00:00:15.000Z').getTime(), + ); + expect(events[0]?.payload.periodStartedAt).toBe(bucketTs); + expect(events[0]?.payload.evaluationCount).toBe(2); + }); + + it('should keep matching evaluations in different minute buckets separate', async () => { + vi.useFakeTimers(); + const firstBucketTs = new Date('2026-01-01T00:01:00.000Z').getTime(); + const secondBucketTs = new Date('2026-01-01T00:02:00.000Z').getTime(); + vi.setSystemTime(new Date('2026-01-01T00:00:59.999Z')); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: 'enabled', + reason: ResolutionReason.FALLTHROUGH, + }); + vi.setSystemTime(new Date('2026-01-01T00:01:00.001Z')); + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: 'enabled', + reason: ResolutionReason.FALLTHROUGH, + }); + await tracker.shutdown(); + + const events = getBody() as SerializedEvaluationEvent[]; + expect(events).toHaveLength(2); + expect(events).toContainEqual({ + type: 'FLAG_EVALUATION', + ts: new Date('2026-01-01T00:00:59.999Z').getTime(), + payload: { + flagKey: 'flag-a', + variant: 'enabled', + reason: ResolutionReason.FALLTHROUGH, + evaluationCount: 1, + periodStartedAt: firstBucketTs, + }, + }); + expect(events).toContainEqual({ + type: 'FLAG_EVALUATION', + ts: new Date('2026-01-01T00:01:00.001Z').getTime(), + payload: { + flagKey: 'flag-a', + variant: 'enabled', + reason: ResolutionReason.FALLTHROUGH, + evaluationCount: 1, + periodStartedAt: secondBucketTs, + }, + }); + }); + + it('should send read and evaluation events in the same flush payload', async () => { + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + + tracker.trackRead({ configOrigin: 'in-memory' }); + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: '0', + reason: ResolutionReason.FALLTHROUGH, + }); + await tracker.shutdown(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getBody()).toEqual([ + expect.objectContaining({ type: 'FLAGS_CONFIG_READ' }), + { + type: 'FLAG_EVALUATION', + ts: expect.any(Number), + payload: { + flagKey: 'flag-a', + variant: '0', + reason: ResolutionReason.FALLTHROUGH, + evaluationCount: 1, + periodStartedAt: expect.any(Number), + }, + }, + ]); + }); + + it('should reset the idle flush timer when evaluations keep arriving', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:10.000Z')); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: '0', + reason: ResolutionReason.FALLTHROUGH, + }); + + await vi.advanceTimersByTimeAsync(4999); + expect(fetchMock).not.toHaveBeenCalled(); + + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: '0', + reason: ResolutionReason.FALLTHROUGH, + }); + + await vi.advanceTimersByTimeAsync(4999); + expect(fetchMock).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + const events = getBody() as SerializedEvaluationEvent[]; + expect(events[0]?.payload.evaluationCount).toBe(2); + }); + + it('should not count repeated evaluations of the same bucket toward the batch threshold', async () => { + vi.useFakeTimers(); + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + + // Track the same bucket well past the 50-event threshold. + for (let i = 0; i < 60; i++) { + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: '0', + reason: ResolutionReason.FALLTHROUGH, + }); + } + + // No count-based flush should fire (a single distinct bucket). + await vi.advanceTimersByTimeAsync(4999); + expect(fetchMock).not.toHaveBeenCalled(); + + // The aggregated event still carries the full repeated count. + await tracker.shutdown(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const events = getBody() as SerializedEvaluationEvent[]; + expect(events).toHaveLength(1); + expect(events[0]?.payload.evaluationCount).toBe(60); + }); + + it('should track when request context is unavailable', async () => { + cleanupContext?.(); + cleanupContext = undefined; + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: '0', + reason: ResolutionReason.FALLTHROUGH, + }); + await tracker.shutdown(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getBody()).toEqual([ + { + type: 'FLAG_EVALUATION', + ts: expect.any(Number), + payload: { + flagKey: 'flag-a', + variant: '0', + reason: ResolutionReason.FALLTHROUGH, + evaluationCount: 1, + periodStartedAt: expect.any(Number), + }, + }, + ]); + }); + }); + + describe('shutdown', () => { it('should trigger immediate flush of pending events', async () => { fetchMock.mockImplementation(() => jsonResponse({ ok: true })); @@ -301,21 +744,75 @@ describe('UsageTracker', () => { tracker.trackRead(); - // Flush immediately instead of waiting for timeout - await tracker.flush(); + // Shut down immediately instead of waiting for timeout + await tracker.shutdown(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should keep the waitUntil promise pending until the scheduled ingest completes', async () => { + vi.useFakeTimers(); + const fetchDeferred = deferred(); + fetchMock.mockImplementation(() => fetchDeferred.promise); + + const tracker = createTracker(); + tracker.trackRead(); + + // Trigger the scheduled flush via the idle window. + await vi.advanceTimersByTimeAsync(5000); + + expect(waitUntilMock).toHaveBeenCalledTimes(1); + const pending = waitUntilMock.mock.calls[0]![0] as Promise; + let settled = false; + void pending.then(() => { + settled = true; + }); + + // Ingest has started but not resolved; the waitUntil promise must wait. + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + fetchDeferred.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await pending; + expect(settled).toBe(true); + }); + + it('should drain a pending scheduled batch without double-sending', async () => { + fetchMock.mockImplementation(() => jsonResponse({ ok: true })); + + const tracker = createTracker(); + + // Both events sit in a pending scheduled batch (idle timer running). + tracker.trackRead(); + tracker.trackEvaluation({ + flagKey: 'flag-a', + variant: '0', + reason: ResolutionReason.FALLTHROUGH, + }); + + await tracker.shutdown(); + // Exactly one ingest batch carrying both events; the trailing + // safety-net flush is a no-op (maps already cleared). expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getBody()).toHaveLength(2); }); - it('should be safe to call flush multiple times', async () => { + it('should be safe to call shutdown multiple times', async () => { fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const tracker = createTracker(); tracker.trackRead(); - tracker.flush(); - tracker.flush(); - await tracker.flush(); + await tracker.shutdown(); + await tracker.shutdown(); + await tracker.shutdown(); expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -336,7 +833,7 @@ describe('UsageTracker', () => { tracker.trackRead(); tracker.trackRead(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); // Only one event should be recorded due to deduplication const events = getBody() as Array<{ type: string }>; @@ -356,10 +853,10 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); - const events = getBody() as FlagsConfigReadEvent[]; - const event = events[0] as FlagsConfigReadEvent; + const events = getBody() as SerializedConfigReadEvent[]; + const event = events[0] as SerializedConfigReadEvent; expect(event.payload.vercelRequestId).toBe('req_123'); expect(event.payload.invocationHost).toBe('myapp.vercel.app'); @@ -391,8 +888,8 @@ describe('UsageTracker', () => { // Both trackers track with the same request context tracker1.trackRead(); tracker2.trackRead(); - await tracker1.flush(); - await tracker2.flush(); + await tracker1.shutdown(); + await tracker2.shutdown(); // Each tracker should have sent its own event expect(fetchMock).toHaveBeenCalledTimes(2); @@ -420,7 +917,7 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); // 2 failed + 1 success = 3 total expect(requestCount).toBe(3); @@ -442,7 +939,7 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); // 2 failed + 1 success = 3 total expect(requestCount).toBe(3); @@ -458,7 +955,7 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); // All 3 attempts fail; SDK logs an extra "Dropped" line expect(fetchMock).toHaveBeenCalledTimes(3); @@ -488,7 +985,7 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead(); - await tracker.flush(); + await tracker.shutdown(); const droppedLogs = consoleSpy.mock.calls.filter( ([msg]) => typeof msg === 'string' && msg.includes('Dropped'), @@ -552,10 +1049,10 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead({ configOrigin: 'in-memory' }); - await tracker.flush(); + await tracker.shutdown(); - const events = getBody() as FlagsConfigReadEvent[]; - const event = events[0] as FlagsConfigReadEvent; + const events = getBody() as SerializedConfigReadEvent[]; + const event = events[0] as SerializedConfigReadEvent; expect(event.payload.configOrigin).toBe('in-memory'); }); @@ -565,10 +1062,10 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead({ configOrigin: 'in-memory', cacheStatus: 'HIT' }); - await tracker.flush(); + await tracker.shutdown(); - const events = getBody() as FlagsConfigReadEvent[]; - const event = events[0] as FlagsConfigReadEvent; + const events = getBody() as SerializedConfigReadEvent[]; + const event = events[0] as SerializedConfigReadEvent; expect(event.payload.cacheStatus).toBe('HIT'); }); @@ -578,10 +1075,10 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead({ configOrigin: 'in-memory', cacheIsFirstRead: true }); - await tracker.flush(); + await tracker.shutdown(); - const events = getBody() as FlagsConfigReadEvent[]; - const event = events[0] as FlagsConfigReadEvent; + const events = getBody() as SerializedConfigReadEvent[]; + const event = events[0] as SerializedConfigReadEvent; expect(event.payload.cacheIsFirstRead).toBe(true); }); @@ -591,10 +1088,10 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead({ configOrigin: 'in-memory', cacheIsBlocking: true }); - await tracker.flush(); + await tracker.shutdown(); - const events = getBody() as FlagsConfigReadEvent[]; - const event = events[0] as FlagsConfigReadEvent; + const events = getBody() as SerializedConfigReadEvent[]; + const event = events[0] as SerializedConfigReadEvent; expect(event.payload.cacheIsBlocking).toBe(true); }); @@ -604,10 +1101,10 @@ describe('UsageTracker', () => { const tracker = createTracker(); tracker.trackRead({ configOrigin: 'in-memory', duration: 150 }); - await tracker.flush(); + await tracker.shutdown(); - const events = getBody() as FlagsConfigReadEvent[]; - const event = events[0] as FlagsConfigReadEvent; + const events = getBody() as SerializedConfigReadEvent[]; + const event = events[0] as SerializedConfigReadEvent; expect(event.payload.duration).toBe(150); }); @@ -621,10 +1118,10 @@ describe('UsageTracker', () => { configOrigin: 'in-memory', configUpdatedAt: timestamp, }); - await tracker.flush(); + await tracker.shutdown(); - const events = getBody() as FlagsConfigReadEvent[]; - const event = events[0] as FlagsConfigReadEvent; + const events = getBody() as SerializedConfigReadEvent[]; + const event = events[0] as SerializedConfigReadEvent; expect(event.payload.configUpdatedAt).toBe(timestamp); }); @@ -642,10 +1139,10 @@ describe('UsageTracker', () => { duration: 200, configUpdatedAt: timestamp, }); - await tracker.flush(); + await tracker.shutdown(); - const events = getBody() as FlagsConfigReadEvent[]; - const event = events[0] as FlagsConfigReadEvent; + const events = getBody() as SerializedConfigReadEvent[]; + const event = events[0] as SerializedConfigReadEvent; expect(event.payload.configOrigin).toBe('in-memory'); expect(event.payload.cacheStatus).toBe('MISS'); expect(event.payload.cacheIsFirstRead).toBe(true); @@ -661,10 +1158,10 @@ describe('UsageTracker', () => { // Only pass configOrigin, omit others tracker.trackRead({ configOrigin: 'embedded' }); - await tracker.flush(); + await tracker.shutdown(); - const events = getBody() as FlagsConfigReadEvent[]; - const event = events[0] as FlagsConfigReadEvent; + const events = getBody() as SerializedConfigReadEvent[]; + const event = events[0] as SerializedConfigReadEvent; expect(event.payload.configOrigin).toBe('embedded'); expect(event.payload.cacheStatus).toBeUndefined(); expect(event.payload.cacheIsFirstRead).toBeUndefined(); diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index df251286..d1555cf9 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -1,146 +1,54 @@ -import { waitUntil } from '@vercel/functions'; -import { version } from '../../package.json'; -import type { Auth } from '../controller/auth'; -import { getJitteredWaitMs, getRetryDelayMs } from './backoff'; - -const RESOLVED_VOID: Promise = Promise.resolve(); - -const isDebugMode = process.env.DEBUG?.includes('@vercel/flags-core'); - -const debugLog = (...args: any[]) => { - if (!isDebugMode) return; - console.log(...args); -}; - -export interface FlagsConfigReadEvent { - type: 'FLAGS_CONFIG_READ'; - ts: number; - payload: { - deploymentId?: string; - region?: string; - invocationHost?: string; - vercelRequestId?: string; - cacheStatus?: 'HIT' | 'MISS' | 'BYPASS' | 'STALE'; - cacheAction?: 'REFRESHING' | 'FOLLOWING' | 'NONE'; - cacheIsBlocking?: boolean; - cacheIsFirstRead?: boolean; - duration?: number; - configUpdatedAt?: number; - configOrigin?: 'in-memory' | 'embedded' | 'poll' | 'stream' | 'constructor'; - mode?: 'poll' | 'stream' | 'build' | 'offline'; - revision?: string; - environment?: string; - }; -} - -interface EventBatcher { - events: FlagsConfigReadEvent[]; - /** Resolves the current wait period early (e.g., when batch size is reached) */ - resolveWait: (() => void) | null; - /** Promise for flush operation */ - pending: null | Promise; -} - -const MAX_RETRIES = 3; -const MAX_BATCH_SIZE = 50; -const MAX_BATCH_WAIT_MS = 5000; - -/** - * Symmetric jitter applied to MAX_BATCH_WAIT_MS so that independent processes - * that started at the same wall-clock time do not flush in lockstep. - */ -const BATCH_WAIT_JITTER_RATIO = 0.2; - -interface RequestContext { - ctx: object | undefined; - headers: Record | undefined; -} - -const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context'); -const fromSymbol = globalThis as typeof globalThis & { - [key: symbol]: - | { get?: () => { headers?: Record } } - | undefined; -}; - -/** - * Gets the Vercel request context and headers from the global symbol. - */ -function getRequestContext(): RequestContext { - try { - const ctx = fromSymbol[SYMBOL_FOR_REQ_CONTEXT]?.get?.(); - if (ctx && Object.hasOwn(ctx, 'headers')) { - return { - ctx, - headers: ctx.headers as Record, - }; - } - return { ctx, headers: undefined }; - } catch { - return { ctx: undefined, headers: undefined }; - } -} - -export interface UsageTrackerOptions { - auth: Auth; - host: string; - fetch: typeof fetch; -} - -export interface TrackReadOptions { - /** Whether the config was read from in-memory cache or embedded bundle */ - configOrigin: 'in-memory' | 'embedded'; - /** HIT when definitions exist in memory, MISS when not, BYPASS when using fallback as primary source */ - cacheStatus?: 'HIT' | 'MISS' | 'BYPASS'; - /** FOLLOWING when streaming, REFRESHING when polling, NONE otherwise */ - cacheAction?: 'REFRESHING' | 'FOLLOWING' | 'NONE'; - /** True for the very first getData call */ - cacheIsFirstRead?: boolean; - /** Whether the cache read was blocking */ - cacheIsBlocking?: boolean; - /** Duration in milliseconds from start of getData until trackRead */ - duration?: number; - /** Timestamp when the config was last updated */ - configUpdatedAt?: number; - /** The mode the SDK is operating in */ - mode?: 'poll' | 'stream' | 'build' | 'offline'; - /** Revision of the config */ - revision?: number; +import { type IngestOptions, sendIngestEvents } from './ingest'; +import { getRequestContext } from './request-context'; +import { Scheduler } from './scheduler'; +import { + FlagsConfigReadEvent, + type TrackReadOptions, +} from './usage/flags-config-read'; +import { + evaluationBatchKey, + FlagsEvaluationEvent, + nextMinuteBucketTs, + type TrackEvaluationOptions, +} from './usage/flags-evaluation'; + +const DISABLE_FLAG_EVALUATIONS_ENV = 'VERCEL_FLAGS_DISABLE_FLAG_EVALUATIONS'; + +function isFlagEvaluationTrackingDisabled(): boolean { + const value = process.env[DISABLE_FLAG_EVALUATIONS_ENV]?.toLowerCase(); + return value === '1' || value === 'true'; } /** * Tracks usage events and batches them for submission to the ingest endpoint. */ export class UsageTracker { - private flushCounter: number = 0; - private options: UsageTrackerOptions; + private flushCount: number = 0; + + private options: IngestOptions; + private scheduler: Scheduler; + private trackedRequests = new WeakSet(); - private batcher: EventBatcher = { - events: [], - resolveWait: null, - pending: null, - }; - constructor(options: UsageTrackerOptions) { + private readEvents: FlagsConfigReadEvent[] = []; + private evaluationEvents = new Map(); + + constructor(options: IngestOptions) { this.options = options; + this.scheduler = new Scheduler(() => this.flushEvents()); } /** * Triggers an immediate flush of any pending events. * Returns a promise that resolves when the flush completes. */ - flush(): Promise { - if (this.batcher.pending) { - this.batcher.resolveWait?.(); - return this.batcher.pending; - } - - // No scheduled flush yet — flush directly if there are queued events - if (this.batcher.events.length > 0) { - return this.flushEvents(); - } + async shutdown() { + // Drain any in-flight scheduled batch (incl. its ingest send). + await this.scheduler.shutdown(); - return RESOLVED_VOID; + // Safety net for events tracked after the drained batch reset; if the + // drained flush already sent everything this returns early (maps cleared). + await this.flushEvents(); } /** @@ -157,158 +65,67 @@ export class UsageTracker { if (this.trackedRequests.has(ctx)) return; this.trackedRequests.add(ctx); - const event: FlagsConfigReadEvent = { - type: 'FLAGS_CONFIG_READ', - ts: Date.now(), - payload: { - deploymentId: process.env.VERCEL_DEPLOYMENT_ID, - region: process.env.VERCEL_REGION, - }, - }; - - if (headers) { - event.payload.vercelRequestId = headers['x-vercel-id'] ?? undefined; - event.payload.invocationHost = headers.host ?? undefined; - } - - if (options) { - event.payload.configOrigin = options.configOrigin; - if (options.cacheStatus !== undefined) { - event.payload.cacheStatus = options.cacheStatus; - } - if (options.cacheAction !== undefined) { - event.payload.cacheAction = options.cacheAction; - } - if (options.cacheIsFirstRead !== undefined) { - event.payload.cacheIsFirstRead = options.cacheIsFirstRead; - } - if (options.cacheIsBlocking !== undefined) { - event.payload.cacheIsBlocking = options.cacheIsBlocking; - } - if (options.duration !== undefined) { - event.payload.duration = options.duration; - } - if (options.configUpdatedAt !== undefined) { - event.payload.configUpdatedAt = options.configUpdatedAt; - } - if (options.mode !== undefined) { - event.payload.mode = options.mode; - } - if (options.revision !== undefined) { - event.payload.revision = String(options.revision); - } - } - - const environment = - process.env.VERCEL_ENV || process.env.NODE_ENV || undefined; - if (environment) { - event.payload.environment = environment; - } + this.readEvents.push(new FlagsConfigReadEvent(headers, options)); - this.batcher.events.push(event); - this.scheduleFlush(); + // always schedule and increment since we are adding a new event here + this.scheduler.scheduleFlush(); + this.scheduler.increment(); } catch (error) { // trackRead should never throw, but log the error console.error('@vercel/flags-core: Failed to record event:', error); } } - private scheduleFlush(): void { - if (!this.batcher.pending) { - let timeout: null | ReturnType = null; - - const pending = (async () => { - await new Promise((res) => { - this.batcher.resolveWait = res; - timeout = setTimeout( - res, - getJitteredWaitMs(MAX_BATCH_WAIT_MS, BATCH_WAIT_JITTER_RATIO), - ); - }); - - this.batcher.pending = null; - this.batcher.resolveWait = null; - if (timeout) clearTimeout(timeout); + /** + * Tracks a flag evaluation event. + */ + trackEvaluation(options: TrackEvaluationOptions): void { + try { + if (isFlagEvaluationTrackingDisabled()) return; - await this.flushEvents(); - })(); + const bucketedOptions = { + ...options, + bucketTs: nextMinuteBucketTs(), + }; + const batchKey = evaluationBatchKey(bucketedOptions); + + const existingEvent = this.evaluationEvents.get(batchKey); + // increment if we already have an event for this batch key + if (existingEvent) { + existingEvent.increment(); + } else { + this.evaluationEvents.set( + batchKey, + new FlagsEvaluationEvent(bucketedOptions), + ); - // Use waitUntil to keep the function alive until flush completes - // If `waitUntil` is not available this will be a no-op and leave - // a floating promise that will be completed in the background - try { - waitUntil(pending); - } catch { - // waitUntil is best-effort; falling through leaves a floating promise + // only increment the scheduler if we are adding a new event + this.scheduler.increment(); } - this.batcher.pending = pending; - } - - // Trigger early flush if threshold was reached - if (this.batcher.events.length >= MAX_BATCH_SIZE) { - this.batcher.resolveWait?.(); + // always schedule to reset the timer + this.scheduler.scheduleFlush(); + } catch (error) { + console.error( + '@vercel/flags-core: Failed to record evaluation event:', + error, + ); } } - private async flushEvents(): Promise { - if (this.batcher.events.length === 0) return; - - // Take all events and clear the queue - const eventsToSend = this.batcher.events; - this.batcher.events = []; - - const flushId = ++this.flushCounter; - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - const token = await this.options.auth.resolveToken(); - - const response = await this.options.fetch( - `${this.options.host}/v1/ingest`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - 'User-Agent': `VercelFlagsCore/${version}`, - ...(process.env.VERCEL_ENV - ? { 'X-Vercel-Env': process.env.VERCEL_ENV } - : null), - ...(isDebugMode ? { 'x-vercel-debug-ingest': '1' } : null), - }, - body: JSON.stringify(eventsToSend), - }, - ); + /** + * Send all events to the ingest service + */ + private async flushEvents() { + const events = [...this.readEvents, ...this.evaluationEvents.values()]; + if (events.length === 0) return; - debugLog( - `@vercel/flags-core: Ingest response ${response.status} for ${eventsToSend.length} events on ${response.headers.get('x-vercel-id')}`, - ); + this.flushCount += 1; + const flushId = this.flushCount; - if (response.ok) { - break; // Break the loop if the request succeeded - } + this.readEvents = []; + this.evaluationEvents.clear(); - throw new Error( - `Ingest endpoint responded with status ${response.status} for ${eventsToSend.length} events on request ${response.headers.get('x-vercel-id')}.\n` + - `Response body: ${await response.text().catch(() => null)}`, - ); - } catch (error) { - console.error( - `@vercel/flags-core: Error sending events (attempt=${attempt}/${MAX_RETRIES} flushId=${flushId}):`, - error, - ); - if (attempt < MAX_RETRIES) { - const delayMs = getRetryDelayMs(attempt); - await new Promise((res) => setTimeout(res, delayMs)); - } else { - // All retries exhausted — surface a structured warning so consumers - // can alert on dropped batches. The events are not persisted anywhere. - console.error( - `@vercel/flags-core: Dropped ${eventsToSend.length} events after ${MAX_RETRIES} attempts (flushId=${flushId})`, - ); - } - } - } + await sendIngestEvents(this.options, events, flushId); } } diff --git a/packages/vercel-flags-core/src/utils/usage/events.ts b/packages/vercel-flags-core/src/utils/usage/events.ts new file mode 100644 index 00000000..62153bf1 --- /dev/null +++ b/packages/vercel-flags-core/src/utils/usage/events.ts @@ -0,0 +1,9 @@ +export interface IngestEvent { + type: string; + ts: number; + payload: object; +} + +export interface UsageEvent { + ingestEvent(): IngestEvent; +} diff --git a/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts b/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts new file mode 100644 index 00000000..c9657343 --- /dev/null +++ b/packages/vercel-flags-core/src/utils/usage/flags-config-read.ts @@ -0,0 +1,100 @@ +import type { UsageEvent } from './events'; + +export interface TrackReadOptions { + /** Whether the config was read from in-memory cache or embedded bundle */ + configOrigin: 'in-memory' | 'embedded'; + /** HIT when definitions exist in memory, MISS when not, BYPASS when using fallback as primary source */ + cacheStatus?: 'HIT' | 'MISS' | 'BYPASS'; + /** FOLLOWING when streaming, REFRESHING when polling, NONE otherwise */ + cacheAction?: 'REFRESHING' | 'FOLLOWING' | 'NONE'; + /** True for the very first getData call */ + cacheIsFirstRead?: boolean; + /** Whether the cache read was blocking */ + cacheIsBlocking?: boolean; + /** Duration in milliseconds from start of getData until trackRead */ + duration?: number; + /** Timestamp when the config was last updated */ + configUpdatedAt?: number; + /** The mode the SDK is operating in */ + mode?: 'poll' | 'stream' | 'build' | 'offline'; + /** Revision of the config */ + revision?: number; +} + +export class FlagsConfigReadEvent implements UsageEvent { + private readonly ts = Date.now(); + + private payload: { + deploymentId?: string; + region?: string; + invocationHost?: string; + vercelRequestId?: string; + cacheStatus?: 'HIT' | 'MISS' | 'BYPASS' | 'STALE'; + cacheAction?: 'REFRESHING' | 'FOLLOWING' | 'NONE'; + cacheIsBlocking?: boolean; + cacheIsFirstRead?: boolean; + duration?: number; + configUpdatedAt?: number; + configOrigin?: 'in-memory' | 'embedded' | 'poll' | 'stream' | 'constructor'; + mode?: 'poll' | 'stream' | 'build' | 'offline'; + revision?: string; + environment?: string; + }; + + constructor( + headers: Record | undefined, + options?: TrackReadOptions, + ) { + this.payload = { + deploymentId: process.env.VERCEL_DEPLOYMENT_ID, + region: process.env.VERCEL_REGION, + }; + + if (headers) { + this.payload.vercelRequestId = headers['x-vercel-id'] ?? undefined; + this.payload.invocationHost = headers.host ?? undefined; + } + + if (options) { + this.payload.configOrigin = options.configOrigin; + if (options.cacheStatus !== undefined) { + this.payload.cacheStatus = options.cacheStatus; + } + if (options.cacheAction !== undefined) { + this.payload.cacheAction = options.cacheAction; + } + if (options.cacheIsFirstRead !== undefined) { + this.payload.cacheIsFirstRead = options.cacheIsFirstRead; + } + if (options.cacheIsBlocking !== undefined) { + this.payload.cacheIsBlocking = options.cacheIsBlocking; + } + if (options.duration !== undefined) { + this.payload.duration = options.duration; + } + if (options.configUpdatedAt !== undefined) { + this.payload.configUpdatedAt = options.configUpdatedAt; + } + if (options.mode !== undefined) { + this.payload.mode = options.mode; + } + if (options.revision !== undefined) { + this.payload.revision = String(options.revision); + } + } + + const environment = + process.env.VERCEL_ENV || process.env.NODE_ENV || undefined; + if (environment) { + this.payload.environment = environment; + } + } + + ingestEvent() { + return { + type: 'FLAGS_CONFIG_READ' as const, + ts: this.ts, + payload: this.payload, + }; + } +} diff --git a/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts b/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts new file mode 100644 index 00000000..ff1ad21b --- /dev/null +++ b/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts @@ -0,0 +1,70 @@ +import type { ResolutionReason } from '../../types'; +import type { UsageEvent } from './events'; + +export interface TrackEvaluationOptions { + flagKey: string; + variant: string; + reason: ResolutionReason; + clientName?: string; +} + +const MINUTE_MS = 60_000; + +export function nextMinuteBucketTs(ts = Date.now()): number { + return Math.ceil(ts / MINUTE_MS) * MINUTE_MS; +} + +export type BucketedTrackEvaluationOptions = TrackEvaluationOptions & { + bucketTs: number; +}; + +export function evaluationBatchKey( + options: BucketedTrackEvaluationOptions, +): string { + return JSON.stringify([ + options.flagKey, + options.variant, + options.reason, + options.clientName ?? null, + options.bucketTs, + ]); +} + +export class FlagsEvaluationEvent implements UsageEvent { + private readonly ts = Date.now(); + + payload: { + flagKey: string; + variant: string; + reason: ResolutionReason; + clientName?: string; + evaluationCount: number; + periodStartedAt: number; + }; + + constructor(eventOptions: BucketedTrackEvaluationOptions) { + this.payload = { + flagKey: eventOptions.flagKey, + variant: eventOptions.variant, + reason: eventOptions.reason, + evaluationCount: 1, + periodStartedAt: eventOptions.bucketTs, + }; + + if (eventOptions.clientName) { + this.payload.clientName = eventOptions.clientName; + } + } + + increment(): void { + this.payload.evaluationCount += 1; + } + + ingestEvent() { + return { + type: 'FLAG_EVALUATION' as const, + ts: this.ts, + payload: this.payload, + }; + } +} From f3bc9e293d688be1d04aac4d52504aca59ed9598 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 18 Jun 2026 13:26:37 +0200 Subject: [PATCH 2/5] Track variant IDs in evaluation results --- .../vercel-flags-core/src/black-box.test.ts | 37 +++-- .../vercel-flags-core/src/controller-fns.ts | 38 ++--- .../vercel-flags-core/src/evaluate.test.ts | 38 +++++ packages/vercel-flags-core/src/evaluate.ts | 150 ++++++++---------- .../vercel-flags-core/src/integration.test.ts | 5 + packages/vercel-flags-core/src/types.ts | 8 + .../src/utils/usage-tracker.test.ts | 48 +++--- .../src/utils/usage/flags-evaluation.ts | 8 +- 8 files changed, 192 insertions(+), 140 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 798c32b8..c35bf8be 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -108,6 +108,14 @@ describe('Controller (black-box)', () => { return Math.ceil(ts / 60_000) * 60_000; } + function metricValue(value: unknown): string { + return JSON.stringify({ value }); + } + + function metricId(id: string): string { + return JSON.stringify({ id }); + } + function expectEvaluationOnlyIngest( evaluationCount = 1, extraEvents: Array<{ @@ -129,7 +137,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: '1', + variant: metricValue(true), reason: 'paused', evaluationCount, periodStartedAt, @@ -387,7 +395,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: '1', + variant: metricValue(true), reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -454,7 +462,7 @@ describe('Controller (black-box)', () => { expectEvaluationOnlyIngest(1, [ { flagKey: 'flagB', - variant: 'unknown', + variant: metricValue(undefined), reason: 'error', evaluationCount: 1, }, @@ -1245,7 +1253,7 @@ describe('Controller (black-box)', () => { ts: after.getTime(), payload: { flagKey: 'flagA', - variant: '1', + variant: metricValue(true), reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(after.getTime()), @@ -1334,7 +1342,7 @@ describe('Controller (black-box)', () => { ts: after.getTime(), payload: { flagKey: 'flagA', - variant: '1', + variant: metricValue(true), reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(after.getTime()), @@ -2212,7 +2220,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: '1', + variant: metricValue(true), reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -2601,7 +2609,7 @@ describe('Controller (black-box)', () => { ts: date.getTime() + 60, payload: { flagKey: 'flagA', - variant: '1', + variant: metricValue(true), reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime() + 60), @@ -3319,7 +3327,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: '1', + variant: metricValue(true), reason: 'paused', evaluationCount: 3, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -3533,7 +3541,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: '1', + variant: metricValue(true), reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -3639,6 +3647,7 @@ describe('Controller (black-box)', () => { expect(result).toEqual({ value: false, + variantId: null, reason: 'error', errorMessage: expect.stringContaining( '@vercel/flags-core: No flag definitions available', @@ -3752,7 +3761,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: 'on', + variant: metricId('on'), reason: 'paused', evaluationCount: 2, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -3764,7 +3773,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'missing-flag', - variant: 'unknown', + variant: metricValue(false), reason: 'error', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -3776,7 +3785,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagB', - variant: '0', + variant: metricValue('control'), reason: 'fallthrough', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -3838,6 +3847,7 @@ describe('Controller (black-box)', () => { outcomeType: 'value', reason: 'paused', value: true, + variantId: null, }); expect(fetchMock).not.toHaveBeenCalled(); @@ -3936,7 +3946,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: '1', + variant: metricValue(true), reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -3975,6 +3985,7 @@ describe('Controller (black-box)', () => { outcomeType: 'value', reason: 'paused', value: true, + variantId: null, }); expect(fetchMock).not.toHaveBeenCalled(); diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index 9f5c6e75..17080f29 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -2,7 +2,6 @@ import { type BulkEvaluationInput, bulkEvaluate as bulkEvalFlags, evaluate as evalFlag, - getEvaluationVariantIndex, } from './evaluate'; import { internalReportValue } from './lib/report-value'; import type { @@ -57,15 +56,6 @@ export function getFallbackDatafile(id: number): Promise { throw new Error('flags: This data source does not support fallbacks'); } -function getVariantIdentifier( - definition: Packed.FlagDefinition, - result: EvaluationResult, -): string { - const variantIndex = getEvaluationVariantIndex(result); - if (variantIndex === undefined) return 'unknown'; - return definition.variantIds?.[variantIndex] ?? String(variantIndex); -} - function trackEvaluation( controller: EvaluationTrackingController, options: TrackEvaluationOptions, @@ -89,13 +79,14 @@ export async function evaluate>( if (defaultValue !== undefined) { const result: EvaluationResult = { value: defaultValue, + variantId: null, reason: ResolutionReason.ERROR, errorMessage: error instanceof Error ? error.message : 'Failed to read datafile', }; trackEvaluation(controller, { flagKey, - variant: 'unknown', + variant: { value: defaultValue }, reason: result.reason, }); return result; @@ -116,6 +107,7 @@ export async function evaluate>( const result: EvaluationResult = { value: defaultValue, + variantId: null, reason: ResolutionReason.ERROR, errorCode: ErrorCode.FLAG_NOT_FOUND, errorMessage: `@vercel/flags-core: Definition not found for flag "${flagKey}"`, @@ -130,7 +122,7 @@ export async function evaluate>( }; trackEvaluation(controller, { flagKey, - variant: 'unknown', + variant: { value: defaultValue }, reason: result.reason, }); return result; @@ -157,12 +149,13 @@ export async function evaluate>( : undefined, }); } + const variant = result.variantId + ? { id: result.variantId } + : { value: result.value }; + trackEvaluation(controller, { flagKey, - variant: getVariantIdentifier( - flagDefinition, - result as EvaluationResult, - ), + variant, reason: result.reason, }); @@ -198,10 +191,11 @@ export async function bulkEvaluate>( value: flag.defaultValue, reason: ResolutionReason.ERROR, errorMessage, + variantId: null, }; trackEvaluation(controller, { flagKey: flag.key, - variant: 'unknown', + variant: { value: flag.defaultValue }, reason: ResolutionReason.ERROR, }); } @@ -238,10 +232,11 @@ export async function bulkEvaluate>( errorCode: ErrorCode.FLAG_NOT_FOUND, errorMessage: `@vercel/flags-core: Definition not found for flag "${key}"`, metrics: { evaluationMs: 0, ...baseMetrics }, + variantId: null, }; trackEvaluation(controller, { flagKey: key, - variant: 'unknown', + variant: { value: defaultValue }, reason: ResolutionReason.ERROR, }); continue; @@ -273,10 +268,9 @@ export async function bulkEvaluate>( } trackEvaluation(controller, { flagKey: key, - variant: getVariantIdentifier( - toEvaluate[key]!.definition, - result as EvaluationResult, - ), + variant: result.variantId + ? { id: result.variantId } + : { value: result.value }, reason: result.reason, }); results[key] = Object.assign(result, { diff --git a/packages/vercel-flags-core/src/evaluate.test.ts b/packages/vercel-flags-core/src/evaluate.test.ts index 4fdcafee..07fc77d0 100644 --- a/packages/vercel-flags-core/src/evaluate.test.ts +++ b/packages/vercel-flags-core/src/evaluate.test.ts @@ -23,6 +23,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); @@ -40,6 +41,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.PAUSED, outcomeType: OutcomeType.VALUE, }); @@ -59,6 +61,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.ERROR, errorMessage: 'Could not find envConfig for "this-env-does-not-exist-and-will-cause-an-error"', @@ -88,6 +91,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }); @@ -115,6 +119,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); @@ -140,6 +145,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); @@ -167,6 +173,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }); @@ -183,6 +190,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.PAUSED, outcomeType: OutcomeType.VALUE, }); @@ -199,6 +207,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.PAUSED, outcomeType: OutcomeType.VALUE, }); @@ -237,6 +246,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }); @@ -258,6 +268,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.PAUSED, outcomeType: OutcomeType.VALUE, }); @@ -281,6 +292,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.TARGET_MATCH, outcomeType: OutcomeType.VALUE, }); @@ -335,6 +347,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }); @@ -352,6 +365,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); @@ -376,6 +390,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }); @@ -398,6 +413,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); @@ -417,6 +433,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }); @@ -442,6 +459,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); @@ -464,6 +482,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); @@ -484,6 +503,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }); @@ -512,6 +532,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }); @@ -538,6 +559,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); @@ -1954,6 +1976,7 @@ describe('evaluate', () => { }), ).toEqual({ value: result, + variantId: null, reason: result ? ResolutionReason.RULE_MATCH : ResolutionReason.FALLTHROUGH, @@ -1992,6 +2015,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); @@ -2027,6 +2051,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); @@ -2062,6 +2087,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }); @@ -2168,6 +2194,7 @@ describe('evaluate', () => { }), ).toEqual({ value: result, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.SPLIT, }); @@ -2255,6 +2282,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.ROLLOUT, }); @@ -2274,6 +2302,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.ROLLOUT, }); @@ -2293,6 +2322,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.ROLLOUT, }); @@ -2312,6 +2342,7 @@ describe('evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.ROLLOUT, }); @@ -2437,6 +2468,7 @@ describe('evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.ROLLOUT, }); @@ -2484,16 +2516,19 @@ describe('bulkEvaluate', () => { ).toEqual({ active: { value: true, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }, paused: { value: false, + variantId: null, reason: ResolutionReason.PAUSED, outcomeType: OutcomeType.VALUE, }, ruled: { value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }, @@ -2516,11 +2551,13 @@ describe('bulkEvaluate', () => { expect(results.a).toEqual({ value: true, + variantId: null, reason: ResolutionReason.ERROR, errorMessage: 'Could not find envConfig for "this-env-does-not-exist"', }); expect(results.b).toEqual({ value: false, + variantId: null, reason: ResolutionReason.ERROR, errorMessage: 'Could not find envConfig for "this-env-does-not-exist"', }); @@ -2565,6 +2602,7 @@ describe('bulkEvaluate', () => { const expected: EvaluationResult = { value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }; diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index b041f5d1..48b43a7f 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -6,6 +6,7 @@ import { OutcomeType, Packed, ResolutionReason, + type VariantId, } from './types'; type PathArray = (string | number)[]; @@ -20,36 +21,6 @@ const UINT32_MAX = 4_294_967_295; // don't leak into serialized datafiles or surprise consumers. const SCALED_WEIGHTS = Symbol('@vercel/flags-core:scaledWeights'); const COMPILED_REGEX = Symbol('@vercel/flags-core:compiledRegex'); -const EVALUATION_VARIANT_INDEX = Symbol( - '@vercel/flags-core:evaluationVariantIndex', -); - -type EvaluationOutcome = { - value: T; - outcomeType: OutcomeType; - [EVALUATION_VARIANT_INDEX]?: number; -}; - -function withVariantIndex( - value: T, - outcomeType: OutcomeType, - variantIndex: number, -): EvaluationOutcome { - return Object.defineProperty( - { - value, - outcomeType, - }, - EVALUATION_VARIANT_INDEX, - { value: variantIndex }, - ); -} - -export function getEvaluationVariantIndex( - result: EvaluationResult, -): number | undefined { - return (result as EvaluationOutcome)[EVALUATION_VARIANT_INDEX]; -} function getScaledWeights(outcome: Packed.SplitOutcome): number[] { const cached = (outcome as unknown as Record)[ @@ -394,41 +365,58 @@ function handleSegmentOutcome( } } -function getVariant(variants: unknown[], index: number): T { +function getVariant( + definition: Packed.FlagDefinition, + index: number, +): { value: T; variantId: VariantId | null } { + const { variants, variantIds } = definition; + if (index < 0 || index >= variants.length) { throw new Error( `@vercel/flags-core: Invalid variant index ${index}, variants length is ${variants.length}`, ); } - return variants[index] as T; + + let variantId: VariantId | null = null; + if (variantIds && index < variantIds.length) { + variantId = variantIds[index] ?? null; + } + + return { + value: variants[index] as T, + variantId, + }; } function handleOutcome( params: EvaluationParams, outcome: Packed.Outcome, -): EvaluationOutcome { +): { + value: T; + outcomeType: OutcomeType; + variantId: VariantId | null; +} { if (typeof outcome === 'number') { - return withVariantIndex( - getVariant(params.definition.variants, outcome), - OutcomeType.VALUE, - outcome, - ); + const variant = getVariant(params.definition, outcome); + return { + ...variant, + outcomeType: OutcomeType.VALUE, + }; } switch (outcome.type) { case 'split': { const lhs = access(outcome.base, params); const defaultOutcome = getVariant( - params.definition.variants, + params.definition, outcome.defaultVariant, ); // serve the default variant if the lhs is not a string if (typeof lhs !== 'string') { - return withVariantIndex( - defaultOutcome, - OutcomeType.SPLIT, - outcome.defaultVariant, - ); + return { + ...defaultOutcome, + outcomeType: OutcomeType.SPLIT, + }; } /** @@ -439,43 +427,42 @@ function handleOutcome( const value = hashInput(lhs, params.definition.seed); const scaledWeights = getScaledWeights(outcome); const variantIndex = findWeightedIndex(scaledWeights, value, UINT32_MAX); - const selectedVariantIndex = - variantIndex === -1 ? outcome.defaultVariant : variantIndex; - return withVariantIndex( - selectedVariantIndex === outcome.defaultVariant + const variant = + variantIndex === -1 ? defaultOutcome - : getVariant(params.definition.variants, selectedVariantIndex), - OutcomeType.SPLIT, - selectedVariantIndex, - ); + : getVariant(params.definition, variantIndex); + return { + ...variant, + outcomeType: OutcomeType.SPLIT, + }; } case 'rollout': { const lhs = access(outcome.base, params); const defaultOutcome = getVariant( - params.definition.variants, + params.definition, outcome.defaultVariant, ); // serve the default variant if the lhs is not a string if (typeof lhs !== 'string') { - return withVariantIndex( - defaultOutcome, - OutcomeType.ROLLOUT, - outcome.defaultVariant, - ); + return { ...defaultOutcome, outcomeType: OutcomeType.ROLLOUT }; } // Determine active slot based on elapsed time const now = Date.now(); const elapsed = now - outcome.startTimestamp; + const rollFromVariant = getVariant( + params.definition, + outcome.rollFromVariant, + ); + // Before rollout starts or no slots, serve rollFromVariant if (elapsed < 0 || outcome.slots.length === 0) { - return withVariantIndex( - getVariant(params.definition.variants, outcome.rollFromVariant), - OutcomeType.ROLLOUT, - outcome.rollFromVariant, - ); + return { + ...rollFromVariant, + outcomeType: OutcomeType.ROLLOUT, + }; } // Walk slots to find current promille. @@ -497,30 +484,31 @@ function handleOutcome( // short-circuit common edges if (currentPromille <= 0) { - return withVariantIndex( - getVariant(params.definition.variants, outcome.rollFromVariant), - OutcomeType.ROLLOUT, - outcome.rollFromVariant, - ); + return { + ...rollFromVariant, + outcomeType: OutcomeType.ROLLOUT, + }; } + const rollToVariant = getVariant( + params.definition, + outcome.rollToVariant, + ); if (currentPromille >= 100_000) { - return withVariantIndex( - getVariant(params.definition.variants, outcome.rollToVariant), - OutcomeType.ROLLOUT, - outcome.rollToVariant, - ); + return { + ...rollToVariant, + outcomeType: OutcomeType.ROLLOUT, + }; } const value = hashInput(lhs, params.definition.seed); const threshold = (currentPromille / 100_000) * UINT32_MAX; - const selectedVariantIndex = - value < threshold ? outcome.rollToVariant : outcome.rollFromVariant; - return withVariantIndex( - getVariant(params.definition.variants, selectedVariantIndex), - OutcomeType.ROLLOUT, - selectedVariantIndex, - ); + const variant = value < threshold ? rollToVariant : rollFromVariant; + + return { + ...variant, + outcomeType: OutcomeType.ROLLOUT, + }; } default: { const { type } = outcome; @@ -560,6 +548,7 @@ export function evaluate( reason: ResolutionReason.ERROR, errorMessage: `Could not find envConfig for "${params.environment}"`, value: params.defaultValue, + variantId: null, }; } @@ -580,6 +569,7 @@ export function evaluate( reason: ResolutionReason.ERROR, errorMessage: `Circular environment reuse detected: "${envConfig.reuse}"`, value: params.defaultValue, + variantId: null, }; } visited.add(params.environment); diff --git a/packages/vercel-flags-core/src/integration.test.ts b/packages/vercel-flags-core/src/integration.test.ts index 5ac8e88b..a1d9e968 100644 --- a/packages/vercel-flags-core/src/integration.test.ts +++ b/packages/vercel-flags-core/src/integration.test.ts @@ -151,6 +151,7 @@ describe('integration evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); @@ -173,6 +174,7 @@ describe('integration evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); @@ -193,6 +195,7 @@ describe('integration evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }); @@ -221,6 +224,7 @@ describe('integration evaluate', () => { }), ).toEqual({ value: true, + variantId: null, reason: ResolutionReason.RULE_MATCH, outcomeType: OutcomeType.VALUE, }); @@ -247,6 +251,7 @@ describe('integration evaluate', () => { }), ).toEqual({ value: false, + variantId: null, reason: ResolutionReason.FALLTHROUGH, outcomeType: OutcomeType.VALUE, }); diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 77f0adf8..6e7fbe4d 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -246,6 +246,10 @@ export type EvaluationResult = * Indicates whether the outcome was a single variant or a split */ outcomeType?: OutcomeType; + /** + * The variant we want to report for o11y + */ + variantId: VariantId | null; /** * Indicates why the flag evaluated to a certain value */ @@ -260,6 +264,10 @@ export type EvaluationResult = errorMessage: string; errorCode?: ErrorCode; outcomeType?: never; + /** + * The variant we want to report for o11y + */ + variantId: VariantId | null; /** * In cases of errors this is the defaultValue if one was provided */ diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index 91988840..f6a856fa 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -125,6 +125,10 @@ function getHeaders(callIndex = 0): Record { return init!.headers as Record; } +function metricValue(value: unknown): string { + return JSON.stringify({ value }); +} + describe('UsageTracker', () => { describe('constructor', () => { it('should create an instance with sdkKey and host', () => { @@ -321,7 +325,7 @@ describe('UsageTracker', () => { tracker.trackRead({ configOrigin: 'in-memory' }); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: 'enabled', + variant: { value: 'enabled' }, reason: ResolutionReason.FALLTHROUGH, }); await tracker.shutdown(); @@ -442,19 +446,19 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: 'enabled', + variant: { value: 'enabled' }, reason: ResolutionReason.FALLTHROUGH, clientName: 'checkout', }); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: 'enabled', + variant: { value: 'enabled' }, reason: ResolutionReason.FALLTHROUGH, clientName: 'checkout', }); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: 'disabled', + variant: { value: 'disabled' }, reason: ResolutionReason.FALLTHROUGH, clientName: 'checkout', }); @@ -469,7 +473,7 @@ describe('UsageTracker', () => { type: 'FLAG_EVALUATION', payload: expect.objectContaining({ flagKey: 'flag-a', - variant: 'enabled', + variant: metricValue('enabled'), reason: ResolutionReason.FALLTHROUGH, clientName: 'checkout', evaluationCount: 2, @@ -481,7 +485,7 @@ describe('UsageTracker', () => { expect.objectContaining({ payload: expect.objectContaining({ flagKey: 'flag-a', - variant: 'disabled', + variant: metricValue('disabled'), reason: ResolutionReason.FALLTHROUGH, clientName: 'checkout', evaluationCount: 1, @@ -502,7 +506,7 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: 'enabled', + variant: { value: 'enabled' }, reason: ResolutionReason.FALLTHROUGH, }); vi.setSystemTime(new Date(trackedAt.getTime() + 10_000)); @@ -523,7 +527,7 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: 'enabled', + variant: { value: 'enabled' }, reason: ResolutionReason.FALLTHROUGH, }); @@ -544,13 +548,13 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: 'enabled', + variant: { value: 'enabled' }, reason: ResolutionReason.FALLTHROUGH, }); vi.setSystemTime(new Date('2026-01-01T00:00:59.999Z')); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: 'enabled', + variant: { value: 'enabled' }, reason: ResolutionReason.FALLTHROUGH, }); vi.setSystemTime(new Date('2026-01-01T00:01:10.000Z')); @@ -576,13 +580,13 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: 'enabled', + variant: { value: 'enabled' }, reason: ResolutionReason.FALLTHROUGH, }); vi.setSystemTime(new Date('2026-01-01T00:01:00.001Z')); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: 'enabled', + variant: { value: 'enabled' }, reason: ResolutionReason.FALLTHROUGH, }); await tracker.shutdown(); @@ -594,7 +598,7 @@ describe('UsageTracker', () => { ts: new Date('2026-01-01T00:00:59.999Z').getTime(), payload: { flagKey: 'flag-a', - variant: 'enabled', + variant: metricValue('enabled'), reason: ResolutionReason.FALLTHROUGH, evaluationCount: 1, periodStartedAt: firstBucketTs, @@ -605,7 +609,7 @@ describe('UsageTracker', () => { ts: new Date('2026-01-01T00:01:00.001Z').getTime(), payload: { flagKey: 'flag-a', - variant: 'enabled', + variant: metricValue('enabled'), reason: ResolutionReason.FALLTHROUGH, evaluationCount: 1, periodStartedAt: secondBucketTs, @@ -621,7 +625,7 @@ describe('UsageTracker', () => { tracker.trackRead({ configOrigin: 'in-memory' }); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: '0', + variant: { value: '0' }, reason: ResolutionReason.FALLTHROUGH, }); await tracker.shutdown(); @@ -634,7 +638,7 @@ describe('UsageTracker', () => { ts: expect.any(Number), payload: { flagKey: 'flag-a', - variant: '0', + variant: metricValue('0'), reason: ResolutionReason.FALLTHROUGH, evaluationCount: 1, periodStartedAt: expect.any(Number), @@ -652,7 +656,7 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: '0', + variant: { value: '0' }, reason: ResolutionReason.FALLTHROUGH, }); @@ -661,7 +665,7 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: '0', + variant: { value: '0' }, reason: ResolutionReason.FALLTHROUGH, }); @@ -687,7 +691,7 @@ describe('UsageTracker', () => { for (let i = 0; i < 60; i++) { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: '0', + variant: { value: '0' }, reason: ResolutionReason.FALLTHROUGH, }); } @@ -714,7 +718,7 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: '0', + variant: { value: '0' }, reason: ResolutionReason.FALLTHROUGH, }); await tracker.shutdown(); @@ -726,7 +730,7 @@ describe('UsageTracker', () => { ts: expect.any(Number), payload: { flagKey: 'flag-a', - variant: '0', + variant: metricValue('0'), reason: ResolutionReason.FALLTHROUGH, evaluationCount: 1, periodStartedAt: expect.any(Number), @@ -792,7 +796,7 @@ describe('UsageTracker', () => { tracker.trackRead(); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: '0', + variant: { value: '0' }, reason: ResolutionReason.FALLTHROUGH, }); diff --git a/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts b/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts index ff1ad21b..02c6d306 100644 --- a/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts +++ b/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts @@ -1,9 +1,11 @@ -import type { ResolutionReason } from '../../types'; +import type { ResolutionReason, VariantId } from '../../types'; import type { UsageEvent } from './events'; +type MetricVariant = { id: VariantId } | { value: unknown }; + export interface TrackEvaluationOptions { flagKey: string; - variant: string; + variant: MetricVariant; reason: ResolutionReason; clientName?: string; } @@ -45,7 +47,7 @@ export class FlagsEvaluationEvent implements UsageEvent { constructor(eventOptions: BucketedTrackEvaluationOptions) { this.payload = { flagKey: eventOptions.flagKey, - variant: eventOptions.variant, + variant: JSON.stringify(eventOptions.variant), reason: eventOptions.reason, evaluationCount: 1, periodStartedAt: eventOptions.bucketTs, From 8cd01409f4c730f53bc2b78b01dcb2e7d1c14eba Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Thu, 18 Jun 2026 17:21:51 +0200 Subject: [PATCH 3/5] report null instead of fallback --- .../vercel-flags-core/src/black-box.test.ts | 38 ++++++--------- .../vercel-flags-core/src/controller-fns.ts | 17 +++---- .../src/utils/usage-tracker.test.ts | 48 +++++++++---------- .../src/utils/usage/flags-evaluation.ts | 8 ++-- 4 files changed, 46 insertions(+), 65 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index c35bf8be..309d1fab 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -108,19 +108,11 @@ describe('Controller (black-box)', () => { return Math.ceil(ts / 60_000) * 60_000; } - function metricValue(value: unknown): string { - return JSON.stringify({ value }); - } - - function metricId(id: string): string { - return JSON.stringify({ id }); - } - function expectEvaluationOnlyIngest( evaluationCount = 1, extraEvents: Array<{ flagKey: string; - variant: string; + variant: string | null; reason: string; evaluationCount: number; periodStartedAt?: number; @@ -137,7 +129,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: metricValue(true), + variant: null, reason: 'paused', evaluationCount, periodStartedAt, @@ -395,7 +387,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: metricValue(true), + variant: null, reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -462,7 +454,7 @@ describe('Controller (black-box)', () => { expectEvaluationOnlyIngest(1, [ { flagKey: 'flagB', - variant: metricValue(undefined), + variant: null, reason: 'error', evaluationCount: 1, }, @@ -1253,7 +1245,7 @@ describe('Controller (black-box)', () => { ts: after.getTime(), payload: { flagKey: 'flagA', - variant: metricValue(true), + variant: null, reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(after.getTime()), @@ -1342,7 +1334,7 @@ describe('Controller (black-box)', () => { ts: after.getTime(), payload: { flagKey: 'flagA', - variant: metricValue(true), + variant: null, reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(after.getTime()), @@ -2220,7 +2212,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: metricValue(true), + variant: null, reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -2609,7 +2601,7 @@ describe('Controller (black-box)', () => { ts: date.getTime() + 60, payload: { flagKey: 'flagA', - variant: metricValue(true), + variant: null, reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime() + 60), @@ -3327,7 +3319,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: metricValue(true), + variant: null, reason: 'paused', evaluationCount: 3, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -3541,7 +3533,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: metricValue(true), + variant: null, reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -3716,7 +3708,7 @@ describe('Controller (black-box)', () => { datafile: makeBundled({ definitions: { flagA: { - variantIds: ['off', 'on'], + variantIds: ['var_off', 'var_on'], environments: { production: 1 }, variants: [false, true], }, @@ -3761,7 +3753,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: metricId('on'), + variant: 'var_on', reason: 'paused', evaluationCount: 2, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -3773,7 +3765,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'missing-flag', - variant: metricValue(false), + variant: null, reason: 'error', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -3785,7 +3777,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagB', - variant: metricValue('control'), + variant: null, reason: 'fallthrough', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime()), @@ -3946,7 +3938,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: metricValue(true), + variant: null, reason: 'paused', evaluationCount: 1, periodStartedAt: nextMinuteBucketTs(date.getTime()), diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index 17080f29..26b2e29a 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -86,7 +86,7 @@ export async function evaluate>( }; trackEvaluation(controller, { flagKey, - variant: { value: defaultValue }, + variant: null, reason: result.reason, }); return result; @@ -122,7 +122,7 @@ export async function evaluate>( }; trackEvaluation(controller, { flagKey, - variant: { value: defaultValue }, + variant: null, reason: result.reason, }); return result; @@ -149,13 +149,10 @@ export async function evaluate>( : undefined, }); } - const variant = result.variantId - ? { id: result.variantId } - : { value: result.value }; trackEvaluation(controller, { flagKey, - variant, + variant: result.variantId, reason: result.reason, }); @@ -195,7 +192,7 @@ export async function bulkEvaluate>( }; trackEvaluation(controller, { flagKey: flag.key, - variant: { value: flag.defaultValue }, + variant: null, reason: ResolutionReason.ERROR, }); } @@ -236,7 +233,7 @@ export async function bulkEvaluate>( }; trackEvaluation(controller, { flagKey: key, - variant: { value: defaultValue }, + variant: null, reason: ResolutionReason.ERROR, }); continue; @@ -268,9 +265,7 @@ export async function bulkEvaluate>( } trackEvaluation(controller, { flagKey: key, - variant: result.variantId - ? { id: result.variantId } - : { value: result.value }, + variant: result.variantId, reason: result.reason, }); results[key] = Object.assign(result, { diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index f6a856fa..ace3a68a 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -125,10 +125,6 @@ function getHeaders(callIndex = 0): Record { return init!.headers as Record; } -function metricValue(value: unknown): string { - return JSON.stringify({ value }); -} - describe('UsageTracker', () => { describe('constructor', () => { it('should create an instance with sdkKey and host', () => { @@ -325,7 +321,7 @@ describe('UsageTracker', () => { tracker.trackRead({ configOrigin: 'in-memory' }); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: 'enabled' }, + variant: 'var_enabled', reason: ResolutionReason.FALLTHROUGH, }); await tracker.shutdown(); @@ -446,19 +442,19 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: 'enabled' }, + variant: 'var_enabled', reason: ResolutionReason.FALLTHROUGH, clientName: 'checkout', }); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: 'enabled' }, + variant: 'var_enabled', reason: ResolutionReason.FALLTHROUGH, clientName: 'checkout', }); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: 'disabled' }, + variant: 'var_disabled', reason: ResolutionReason.FALLTHROUGH, clientName: 'checkout', }); @@ -473,7 +469,7 @@ describe('UsageTracker', () => { type: 'FLAG_EVALUATION', payload: expect.objectContaining({ flagKey: 'flag-a', - variant: metricValue('enabled'), + variant: 'var_enabled', reason: ResolutionReason.FALLTHROUGH, clientName: 'checkout', evaluationCount: 2, @@ -485,7 +481,7 @@ describe('UsageTracker', () => { expect.objectContaining({ payload: expect.objectContaining({ flagKey: 'flag-a', - variant: metricValue('disabled'), + variant: 'var_disabled', reason: ResolutionReason.FALLTHROUGH, clientName: 'checkout', evaluationCount: 1, @@ -506,7 +502,7 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: 'enabled' }, + variant: 'var_enabled', reason: ResolutionReason.FALLTHROUGH, }); vi.setSystemTime(new Date(trackedAt.getTime() + 10_000)); @@ -527,7 +523,7 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: 'enabled' }, + variant: 'var_enabled', reason: ResolutionReason.FALLTHROUGH, }); @@ -548,13 +544,13 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: 'enabled' }, + variant: 'var_enabled', reason: ResolutionReason.FALLTHROUGH, }); vi.setSystemTime(new Date('2026-01-01T00:00:59.999Z')); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: 'enabled' }, + variant: 'var_enabled', reason: ResolutionReason.FALLTHROUGH, }); vi.setSystemTime(new Date('2026-01-01T00:01:10.000Z')); @@ -580,13 +576,13 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: 'enabled' }, + variant: 'var_enabled', reason: ResolutionReason.FALLTHROUGH, }); vi.setSystemTime(new Date('2026-01-01T00:01:00.001Z')); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: 'enabled' }, + variant: 'var_enabled', reason: ResolutionReason.FALLTHROUGH, }); await tracker.shutdown(); @@ -598,7 +594,7 @@ describe('UsageTracker', () => { ts: new Date('2026-01-01T00:00:59.999Z').getTime(), payload: { flagKey: 'flag-a', - variant: metricValue('enabled'), + variant: 'var_enabled', reason: ResolutionReason.FALLTHROUGH, evaluationCount: 1, periodStartedAt: firstBucketTs, @@ -609,7 +605,7 @@ describe('UsageTracker', () => { ts: new Date('2026-01-01T00:01:00.001Z').getTime(), payload: { flagKey: 'flag-a', - variant: metricValue('enabled'), + variant: 'var_enabled', reason: ResolutionReason.FALLTHROUGH, evaluationCount: 1, periodStartedAt: secondBucketTs, @@ -625,7 +621,7 @@ describe('UsageTracker', () => { tracker.trackRead({ configOrigin: 'in-memory' }); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: '0' }, + variant: 'var_0', reason: ResolutionReason.FALLTHROUGH, }); await tracker.shutdown(); @@ -638,7 +634,7 @@ describe('UsageTracker', () => { ts: expect.any(Number), payload: { flagKey: 'flag-a', - variant: metricValue('0'), + variant: 'var_0', reason: ResolutionReason.FALLTHROUGH, evaluationCount: 1, periodStartedAt: expect.any(Number), @@ -656,7 +652,7 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: '0' }, + variant: 'var_0', reason: ResolutionReason.FALLTHROUGH, }); @@ -665,7 +661,7 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: '0' }, + variant: 'var_0', reason: ResolutionReason.FALLTHROUGH, }); @@ -691,7 +687,7 @@ describe('UsageTracker', () => { for (let i = 0; i < 60; i++) { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: '0' }, + variant: 'var_0', reason: ResolutionReason.FALLTHROUGH, }); } @@ -718,7 +714,7 @@ describe('UsageTracker', () => { tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: '0' }, + variant: 'var_0', reason: ResolutionReason.FALLTHROUGH, }); await tracker.shutdown(); @@ -730,7 +726,7 @@ describe('UsageTracker', () => { ts: expect.any(Number), payload: { flagKey: 'flag-a', - variant: metricValue('0'), + variant: 'var_0', reason: ResolutionReason.FALLTHROUGH, evaluationCount: 1, periodStartedAt: expect.any(Number), @@ -796,7 +792,7 @@ describe('UsageTracker', () => { tracker.trackRead(); tracker.trackEvaluation({ flagKey: 'flag-a', - variant: { value: '0' }, + variant: 'var_0', reason: ResolutionReason.FALLTHROUGH, }); diff --git a/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts b/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts index 02c6d306..47d5739d 100644 --- a/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts +++ b/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts @@ -1,11 +1,9 @@ import type { ResolutionReason, VariantId } from '../../types'; import type { UsageEvent } from './events'; -type MetricVariant = { id: VariantId } | { value: unknown }; - export interface TrackEvaluationOptions { flagKey: string; - variant: MetricVariant; + variant: VariantId | null; reason: ResolutionReason; clientName?: string; } @@ -37,7 +35,7 @@ export class FlagsEvaluationEvent implements UsageEvent { payload: { flagKey: string; - variant: string; + variant: string | null; reason: ResolutionReason; clientName?: string; evaluationCount: number; @@ -47,7 +45,7 @@ export class FlagsEvaluationEvent implements UsageEvent { constructor(eventOptions: BucketedTrackEvaluationOptions) { this.payload = { flagKey: eventOptions.flagKey, - variant: JSON.stringify(eventOptions.variant), + variant: eventOptions.variant, reason: eventOptions.reason, evaluationCount: 1, periodStartedAt: eventOptions.bucketTs, From ef71528ad98c82442c9c48f36954fa7599892447 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Fri, 19 Jun 2026 16:15:18 +0200 Subject: [PATCH 4/5] bucket to the previous minute --- .../vercel-flags-core/src/black-box.test.ts | 28 +++++++++---------- .../src/utils/usage-tracker.test.ts | 10 +++---- .../src/utils/usage-tracker.ts | 4 +-- .../src/utils/usage/flags-evaluation.ts | 4 +-- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 309d1fab..268e765f 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -104,8 +104,8 @@ const originalEnv = { ...process.env }; describe('Controller (black-box)', () => { const date = new Date(); - function nextMinuteBucketTs(ts: number): number { - return Math.ceil(ts / 60_000) * 60_000; + function minuteBucketTs(ts: number): number { + return Math.floor(ts / 60_000) * 60_000; } function expectEvaluationOnlyIngest( @@ -118,7 +118,7 @@ describe('Controller (black-box)', () => { periodStartedAt?: number; }> = [], ) { - const periodStartedAt = nextMinuteBucketTs(date.getTime()); + const periodStartedAt = minuteBucketTs(date.getTime()); expect(fetchMock).toHaveBeenLastCalledWith( 'https://flags.vercel.com/v1/ingest', @@ -390,7 +390,7 @@ describe('Controller (black-box)', () => { variant: null, reason: 'paused', evaluationCount: 1, - periodStartedAt: nextMinuteBucketTs(date.getTime()), + periodStartedAt: minuteBucketTs(date.getTime()), }, }, ]), @@ -1248,7 +1248,7 @@ describe('Controller (black-box)', () => { variant: null, reason: 'paused', evaluationCount: 1, - periodStartedAt: nextMinuteBucketTs(after.getTime()), + periodStartedAt: minuteBucketTs(after.getTime()), }, }, ]), @@ -1337,7 +1337,7 @@ describe('Controller (black-box)', () => { variant: null, reason: 'paused', evaluationCount: 1, - periodStartedAt: nextMinuteBucketTs(after.getTime()), + periodStartedAt: minuteBucketTs(after.getTime()), }, }, ]), @@ -2215,7 +2215,7 @@ describe('Controller (black-box)', () => { variant: null, reason: 'paused', evaluationCount: 1, - periodStartedAt: nextMinuteBucketTs(date.getTime()), + periodStartedAt: minuteBucketTs(date.getTime()), }, }, ]), @@ -2604,7 +2604,7 @@ describe('Controller (black-box)', () => { variant: null, reason: 'paused', evaluationCount: 1, - periodStartedAt: nextMinuteBucketTs(date.getTime() + 60), + periodStartedAt: minuteBucketTs(date.getTime() + 60), }, }, ]), @@ -3322,7 +3322,7 @@ describe('Controller (black-box)', () => { variant: null, reason: 'paused', evaluationCount: 3, - periodStartedAt: nextMinuteBucketTs(date.getTime()), + periodStartedAt: minuteBucketTs(date.getTime()), }, }, ]), @@ -3536,7 +3536,7 @@ describe('Controller (black-box)', () => { variant: null, reason: 'paused', evaluationCount: 1, - periodStartedAt: nextMinuteBucketTs(date.getTime()), + periodStartedAt: minuteBucketTs(date.getTime()), }, }, ]), @@ -3756,7 +3756,7 @@ describe('Controller (black-box)', () => { variant: 'var_on', reason: 'paused', evaluationCount: 2, - periodStartedAt: nextMinuteBucketTs(date.getTime()), + periodStartedAt: minuteBucketTs(date.getTime()), clientName: 'checkout', }, }, @@ -3768,7 +3768,7 @@ describe('Controller (black-box)', () => { variant: null, reason: 'error', evaluationCount: 1, - periodStartedAt: nextMinuteBucketTs(date.getTime()), + periodStartedAt: minuteBucketTs(date.getTime()), clientName: 'checkout', }, }, @@ -3780,7 +3780,7 @@ describe('Controller (black-box)', () => { variant: null, reason: 'fallthrough', evaluationCount: 1, - periodStartedAt: nextMinuteBucketTs(date.getTime()), + periodStartedAt: minuteBucketTs(date.getTime()), clientName: 'checkout', }, }, @@ -3941,7 +3941,7 @@ describe('Controller (black-box)', () => { variant: null, reason: 'paused', evaluationCount: 1, - periodStartedAt: nextMinuteBucketTs(date.getTime()), + periodStartedAt: minuteBucketTs(date.getTime()), }, }, ]), diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index ace3a68a..a0418d61 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -491,10 +491,10 @@ describe('UsageTracker', () => { ); }); - it('should preserve evaluation timestamps and bucket periodStartedAt to the next minute', async () => { + it('should preserve evaluation timestamps and bucket periodStartedAt to the current minute', async () => { vi.useFakeTimers(); const trackedAt = new Date('2026-01-01T00:00:15.123Z'); - const bucketTs = new Date('2026-01-01T00:01:00.000Z').getTime(); + const bucketTs = new Date('2026-01-01T00:00:00.000Z').getTime(); vi.setSystemTime(trackedAt); fetchMock.mockImplementation(() => jsonResponse({ ok: true })); @@ -536,7 +536,7 @@ describe('UsageTracker', () => { it('should aggregate matching evaluations in the same minute bucket', async () => { vi.useFakeTimers(); - const bucketTs = new Date('2026-01-01T00:01:00.000Z').getTime(); + const bucketTs = new Date('2026-01-01T00:00:00.000Z').getTime(); vi.setSystemTime(new Date('2026-01-01T00:00:15.000Z')); fetchMock.mockImplementation(() => jsonResponse({ ok: true })); @@ -567,8 +567,8 @@ describe('UsageTracker', () => { it('should keep matching evaluations in different minute buckets separate', async () => { vi.useFakeTimers(); - const firstBucketTs = new Date('2026-01-01T00:01:00.000Z').getTime(); - const secondBucketTs = new Date('2026-01-01T00:02:00.000Z').getTime(); + const firstBucketTs = new Date('2026-01-01T00:00:00.000Z').getTime(); + const secondBucketTs = new Date('2026-01-01T00:01:00.000Z').getTime(); vi.setSystemTime(new Date('2026-01-01T00:00:59.999Z')); fetchMock.mockImplementation(() => jsonResponse({ ok: true })); diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index d1555cf9..3557aab6 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -8,7 +8,7 @@ import { import { evaluationBatchKey, FlagsEvaluationEvent, - nextMinuteBucketTs, + minuteBucketTs, type TrackEvaluationOptions, } from './usage/flags-evaluation'; @@ -85,7 +85,7 @@ export class UsageTracker { const bucketedOptions = { ...options, - bucketTs: nextMinuteBucketTs(), + bucketTs: minuteBucketTs(), }; const batchKey = evaluationBatchKey(bucketedOptions); diff --git a/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts b/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts index 47d5739d..332c6b45 100644 --- a/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts +++ b/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts @@ -10,8 +10,8 @@ export interface TrackEvaluationOptions { const MINUTE_MS = 60_000; -export function nextMinuteBucketTs(ts = Date.now()): number { - return Math.ceil(ts / MINUTE_MS) * MINUTE_MS; +export function minuteBucketTs(ts = Date.now()): number { + return Math.floor(ts / MINUTE_MS) * MINUTE_MS; } export type BucketedTrackEvaluationOptions = TrackEvaluationOptions & { From 29fb9c7b121222fed35f5568c146426e7b499589 Mon Sep 17 00:00:00 2001 From: Luis Meyer Date: Mon, 22 Jun 2026 11:02:22 +0200 Subject: [PATCH 5/5] replace variant null with undefined --- .../vercel-flags-core/src/black-box.test.ts | 26 +++++++++---------- .../src/utils/usage/flags-evaluation.ts | 4 +-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/vercel-flags-core/src/black-box.test.ts b/packages/vercel-flags-core/src/black-box.test.ts index 268e765f..6414c9ef 100644 --- a/packages/vercel-flags-core/src/black-box.test.ts +++ b/packages/vercel-flags-core/src/black-box.test.ts @@ -112,7 +112,7 @@ describe('Controller (black-box)', () => { evaluationCount = 1, extraEvents: Array<{ flagKey: string; - variant: string | null; + variant: string | undefined; reason: string; evaluationCount: number; periodStartedAt?: number; @@ -129,7 +129,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: null, + variant: undefined, reason: 'paused', evaluationCount, periodStartedAt, @@ -387,7 +387,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: null, + variant: undefined, reason: 'paused', evaluationCount: 1, periodStartedAt: minuteBucketTs(date.getTime()), @@ -454,7 +454,7 @@ describe('Controller (black-box)', () => { expectEvaluationOnlyIngest(1, [ { flagKey: 'flagB', - variant: null, + variant: undefined, reason: 'error', evaluationCount: 1, }, @@ -1245,7 +1245,7 @@ describe('Controller (black-box)', () => { ts: after.getTime(), payload: { flagKey: 'flagA', - variant: null, + variant: undefined, reason: 'paused', evaluationCount: 1, periodStartedAt: minuteBucketTs(after.getTime()), @@ -1334,7 +1334,7 @@ describe('Controller (black-box)', () => { ts: after.getTime(), payload: { flagKey: 'flagA', - variant: null, + variant: undefined, reason: 'paused', evaluationCount: 1, periodStartedAt: minuteBucketTs(after.getTime()), @@ -2212,7 +2212,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: null, + variant: undefined, reason: 'paused', evaluationCount: 1, periodStartedAt: minuteBucketTs(date.getTime()), @@ -2601,7 +2601,7 @@ describe('Controller (black-box)', () => { ts: date.getTime() + 60, payload: { flagKey: 'flagA', - variant: null, + variant: undefined, reason: 'paused', evaluationCount: 1, periodStartedAt: minuteBucketTs(date.getTime() + 60), @@ -3319,7 +3319,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: null, + variant: undefined, reason: 'paused', evaluationCount: 3, periodStartedAt: minuteBucketTs(date.getTime()), @@ -3533,7 +3533,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: null, + variant: undefined, reason: 'paused', evaluationCount: 1, periodStartedAt: minuteBucketTs(date.getTime()), @@ -3765,7 +3765,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'missing-flag', - variant: null, + variant: undefined, reason: 'error', evaluationCount: 1, periodStartedAt: minuteBucketTs(date.getTime()), @@ -3777,7 +3777,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagB', - variant: null, + variant: undefined, reason: 'fallthrough', evaluationCount: 1, periodStartedAt: minuteBucketTs(date.getTime()), @@ -3938,7 +3938,7 @@ describe('Controller (black-box)', () => { ts: date.getTime(), payload: { flagKey: 'flagA', - variant: null, + variant: undefined, reason: 'paused', evaluationCount: 1, periodStartedAt: minuteBucketTs(date.getTime()), diff --git a/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts b/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts index 332c6b45..c0498b45 100644 --- a/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts +++ b/packages/vercel-flags-core/src/utils/usage/flags-evaluation.ts @@ -35,7 +35,7 @@ export class FlagsEvaluationEvent implements UsageEvent { payload: { flagKey: string; - variant: string | null; + variant?: string; reason: ResolutionReason; clientName?: string; evaluationCount: number; @@ -45,7 +45,7 @@ export class FlagsEvaluationEvent implements UsageEvent { constructor(eventOptions: BucketedTrackEvaluationOptions) { this.payload = { flagKey: eventOptions.flagKey, - variant: eventOptions.variant, + variant: eventOptions.variant ?? undefined, reason: eventOptions.reason, evaluationCount: 1, periodStartedAt: eventOptions.bucketTs,