diff --git a/packages/vercel-flags-core/src/controller-fns.ts b/packages/vercel-flags-core/src/controller-fns.ts index 6a3cb8db..59ecf9ee 100644 --- a/packages/vercel-flags-core/src/controller-fns.ts +++ b/packages/vercel-flags-core/src/controller-fns.ts @@ -118,6 +118,18 @@ export async function evaluate>( }); } + if ( + result.reason !== ResolutionReason.ERROR && + flagDefinition.id && + result.variantId + ) { + controller.trackEvaluation({ + flagId: flagDefinition.id, + variantId: result.variantId, + reason: result.reason, + }); + } + return Object.assign(result, { metrics: { evaluationMs: evaluationDurationMs, diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index ee8bc35c..34240369 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -348,6 +348,15 @@ export class Controller implements ControllerInterface { await this.usageTracker.flush(); } + trackEvaluation(options: { + flagId: string; + variantId: string; + reason: string; + }): void { + if (this.unauthorized) return; + this.usageTracker.trackEvaluation(options); + } + /** * Returns the datafile with metrics. * Uses in-memory data if available, otherwise falls back to bundled, diff --git a/packages/vercel-flags-core/src/evaluate.ts b/packages/vercel-flags-core/src/evaluate.ts index 470ab8a7..4215f35a 100644 --- a/packages/vercel-flags-core/src/evaluate.ts +++ b/packages/vercel-flags-core/src/evaluate.ts @@ -345,30 +345,45 @@ function getVariant(variants: unknown[], index: number): T { return variants[index] as T; } +function resolveVariantId( + definition: Packed.FlagDefinition, + index: number, +): string | undefined { + return definition.variantIds?.[index]; +} + function handleOutcome( params: EvaluationParams, outcome: Packed.Outcome, ): { value: T; outcomeType: OutcomeType; + variantId: string | undefined; } { if (typeof outcome === 'number') { return { value: getVariant(params.definition.variants, outcome), outcomeType: OutcomeType.VALUE, + variantId: resolveVariantId(params.definition, outcome), }; } switch (outcome.type) { case 'split': { const lhs = access(outcome.base, params); - const defaultOutcome = getVariant( - params.definition.variants, - outcome.defaultVariant, - ); // serve the default variant if the lhs is not a string if (typeof lhs !== 'string') { - return { value: defaultOutcome, outcomeType: OutcomeType.SPLIT }; + return { + value: getVariant( + params.definition.variants, + outcome.defaultVariant, + ), + outcomeType: OutcomeType.SPLIT, + variantId: resolveVariantId( + params.definition, + outcome.defaultVariant, + ), + }; } /** 2^32-1 */ @@ -383,13 +398,13 @@ function handleOutcome( const scaledWeights = outcome.weights.map( (weight) => (weight / sumOfWeights) * maxValue, ); - const variantIndex = findWeightedIndex(scaledWeights, value, maxValue); + const resolvedIndex = findWeightedIndex(scaledWeights, value, maxValue); + const variantIndex = + resolvedIndex === -1 ? outcome.defaultVariant : resolvedIndex; return { - value: - variantIndex === -1 - ? defaultOutcome - : getVariant(params.definition.variants, variantIndex), + value: getVariant(params.definition.variants, variantIndex), outcomeType: OutcomeType.SPLIT, + variantId: resolveVariantId(params.definition, variantIndex), }; } default: { diff --git a/packages/vercel-flags-core/src/openfeature.make.ts b/packages/vercel-flags-core/src/openfeature.make.ts index 90170d41..ce84954c 100644 --- a/packages/vercel-flags-core/src/openfeature.make.ts +++ b/packages/vercel-flags-core/src/openfeature.make.ts @@ -101,6 +101,7 @@ export function make( return { value: result.value, reason: mapReason(result.reason), + variant: result.variantId, }; } @@ -136,6 +137,7 @@ export function make( return { value: result.value, reason: mapReason(result.reason), + variant: result.variantId, errorMessage: result.errorMessage, }; } @@ -172,6 +174,7 @@ export function make( return { value: result.value, reason: mapReason(result.reason), + variant: result.variantId, errorMessage: result.errorMessage, }; } @@ -199,6 +202,7 @@ export function make( return { value: result.value, reason: mapReason(result.reason), + variant: result.variantId, errorMessage: result.errorMessage, }; } diff --git a/packages/vercel-flags-core/src/openfeature.test.ts b/packages/vercel-flags-core/src/openfeature.test.ts index 20fd020b..827b8a2d 100644 --- a/packages/vercel-flags-core/src/openfeature.test.ts +++ b/packages/vercel-flags-core/src/openfeature.test.ts @@ -27,6 +27,7 @@ function createStaticController(opts: { read: () => Promise.resolve(datafile), getDatafile: () => Promise.resolve(datafile), shutdown: () => {}, + trackEvaluation: () => {}, }; } diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index 2d25fb68..e2c61114 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -1,5 +1,3 @@ -import type { ControllerInstance } from './controller-fns'; - /** * Options for stream connection behavior */ @@ -108,6 +106,15 @@ export interface ControllerInterface { * Throws FallbackEntryNotFoundError if the file exists but has no entry for the SDK key. */ getFallbackDatafile?(): Promise; + + /** + * Tracks a flag evaluation event for reporting to the ingest endpoint. + */ + trackEvaluation(options: { + flagId: string; + variantId: string; + reason: string; + }): void; } export type Source = { @@ -223,6 +230,10 @@ export type EvaluationResult = * Indicates whether the outcome was a single variant or a split */ outcomeType?: OutcomeType; + /** + * The ID of the resolved variant, if available + */ + variantId?: string; /** * Indicates why the flag evaluated to a certain value */ @@ -776,7 +787,9 @@ export namespace Packed { }; export type FlagDefinition = { - /** for backwards compatibility with HappyKit */ + /** Stable identifier for this flag */ + id?: string; + /** IDs corresponding to each entry in the variants array */ variantIds?: string[]; /** variants, packed down to just their values */ variants: Value[]; diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index 2be7411f..b722746e 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -31,8 +31,22 @@ export interface FlagsConfigReadEvent { }; } +export interface FlagEvaluationEvent { + type: 'FLAG_EVALUATION'; + ts: number; + payload: { + flagId: string; + variantId: string; + reason: string; + deploymentId?: string; + region?: string; + }; +} + +export type IngestEvent = FlagsConfigReadEvent | FlagEvaluationEvent; + interface EventBatcher { - events: FlagsConfigReadEvent[]; + events: IngestEvent[]; /** Resolves the current wait period early (e.g., when batch size is reached) */ resolveWait: (() => void) | null; /** Promise for flush operation */ @@ -100,6 +114,12 @@ export interface TrackReadOptions { revision?: number; } +export interface TrackEvaluationOptions { + flagId: string; + variantId: string; + reason: string; +} + /** * Tracks usage events and batches them for submission to the ingest endpoint. */ @@ -205,6 +225,33 @@ export class UsageTracker { } } + /** + * Tracks a flag evaluation event. + */ + trackEvaluation(options: TrackEvaluationOptions): void { + try { + const event: FlagEvaluationEvent = { + type: 'FLAG_EVALUATION', + ts: Date.now(), + payload: { + flagId: options.flagId, + variantId: options.variantId, + reason: options.reason, + deploymentId: process.env.VERCEL_DEPLOYMENT_ID, + region: process.env.VERCEL_REGION, + }, + }; + + this.batcher.events.push(event); + this.scheduleFlush(); + } catch (error) { + console.error( + '@vercel/flags-core: Failed to record evaluation event:', + error, + ); + } + } + private scheduleFlush(): void { if (!this.batcher.pending) { let timeout: null | ReturnType = null;