Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/vercel-flags-core/src/controller-fns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ export async function evaluate<T, E = Record<string, unknown>>(
});
}

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,
Expand Down
9 changes: 9 additions & 0 deletions packages/vercel-flags-core/src/controller/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 25 additions & 10 deletions packages/vercel-flags-core/src/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,30 +345,45 @@ function getVariant<T>(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<T>(
params: EvaluationParams<T>,
outcome: Packed.Outcome,
): {
value: T;
outcomeType: OutcomeType;
variantId: string | undefined;
} {
if (typeof outcome === 'number') {
return {
value: getVariant<T>(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<T>(
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<T>(
params.definition.variants,
outcome.defaultVariant,
),
outcomeType: OutcomeType.SPLIT,
variantId: resolveVariantId(
params.definition,
outcome.defaultVariant,
),
};
}

/** 2^32-1 */
Expand All @@ -383,13 +398,13 @@ function handleOutcome<T>(
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<T>(params.definition.variants, variantIndex),
value: getVariant<T>(params.definition.variants, variantIndex),
outcomeType: OutcomeType.SPLIT,
variantId: resolveVariantId(params.definition, variantIndex),
};
}
default: {
Expand Down
4 changes: 4 additions & 0 deletions packages/vercel-flags-core/src/openfeature.make.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function make(
return {
value: result.value,
reason: mapReason(result.reason),
variant: result.variantId,
};
}

Expand Down Expand Up @@ -136,6 +137,7 @@ export function make(
return {
value: result.value,
reason: mapReason(result.reason),
variant: result.variantId,
errorMessage: result.errorMessage,
};
}
Expand Down Expand Up @@ -172,6 +174,7 @@ export function make(
return {
value: result.value,
reason: mapReason(result.reason),
variant: result.variantId,
errorMessage: result.errorMessage,
};
}
Expand Down Expand Up @@ -199,6 +202,7 @@ export function make(
return {
value: result.value,
reason: mapReason(result.reason),
variant: result.variantId,
errorMessage: result.errorMessage,
};
}
Expand Down
1 change: 1 addition & 0 deletions packages/vercel-flags-core/src/openfeature.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function createStaticController(opts: {
read: () => Promise.resolve(datafile),
getDatafile: () => Promise.resolve(datafile),
shutdown: () => {},
trackEvaluation: () => {},
};
}

Expand Down
19 changes: 16 additions & 3 deletions packages/vercel-flags-core/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { ControllerInstance } from './controller-fns';

/**
* Options for stream connection behavior
*/
Expand Down Expand Up @@ -108,6 +106,15 @@ export interface ControllerInterface {
* Throws FallbackEntryNotFoundError if the file exists but has no entry for the SDK key.
*/
getFallbackDatafile?(): Promise<BundledDefinitions>;

/**
* Tracks a flag evaluation event for reporting to the ingest endpoint.
*/
trackEvaluation(options: {
flagId: string;
variantId: string;
reason: string;
}): void;
}

export type Source = {
Expand Down Expand Up @@ -223,6 +230,10 @@ export type EvaluationResult<T> =
* 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
*/
Expand Down Expand Up @@ -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[];
Expand Down
49 changes: 48 additions & 1 deletion packages/vercel-flags-core/src/utils/usage-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<typeof setTimeout> = null;
Expand Down
Loading