diff --git a/src/feature-flags/analytics/flag-analytics.service.ts b/src/feature-flags/analytics/flag-analytics.service.ts new file mode 100644 index 0000000..19d6079 --- /dev/null +++ b/src/feature-flags/analytics/flag-analytics.service.ts @@ -0,0 +1,229 @@ +import { Injectable } from '@nestjs/common'; +import { + EvaluationReason, + ExperimentStats, + ExperimentVariantStats, + FlagAnalyticsEvent, + FlagEvaluationStats, + FlagSummary, + FlagValueType, +} from '../interfaces'; + +type TrackEvaluationInput = Omit; + +@Injectable() +export class FlagAnalyticsService { + /** flagKey → events */ + private readonly flagEvents = new Map(); + /** flagKey → Set of unique userIds */ + private readonly flagUsers = new Map>(); + /** experimentId → variantKey → impression count */ + private readonly experimentImpressions = new Map>(); + /** experimentId → variantKey → conversion count */ + private readonly experimentConversions = new Map>(); + + /** + * Records a flag evaluation event. + */ + trackEvaluation(input: TrackEvaluationInput): void { + const event: FlagAnalyticsEvent = { + ...input, + eventId: this.generateEventId(), + timestamp: new Date(), + }; + + if (event.flagKey) { + if (!this.flagEvents.has(event.flagKey)) { + this.flagEvents.set(event.flagKey, []); + } + this.flagEvents.get(event.flagKey)!.push(event); + + if (event.userId) { + if (!this.flagUsers.has(event.flagKey)) { + this.flagUsers.set(event.flagKey, new Set()); + } + this.flagUsers.get(event.flagKey)!.add(event.userId); + } + } + } + + /** + * Records an experiment impression (user saw a variant). + */ + trackImpression( + experimentId: string, + variantKey: string, + userId?: string, + flagKey?: string, + ): void { + this.incrementExperimentCounter(this.experimentImpressions, experimentId, variantKey); + + this.trackEvaluation({ + eventType: 'impression', + flagKey, + userId, + experimentId, + experimentVariantKey: variantKey, + }); + } + + /** + * Records an experiment conversion event. + */ + trackConversion( + experimentId: string, + variantKey: string, + userId?: string, + metadata?: Record, + ): void { + this.incrementExperimentCounter(this.experimentConversions, experimentId, variantKey); + + this.trackEvaluation({ + eventType: 'conversion', + userId, + experimentId, + experimentVariantKey: variantKey, + metadata, + }); + } + + /** + * Returns evaluation statistics for a flag. + * Optionally filters to events within the last `sinceHours` hours. + */ + getEvaluationStats(flagKey: string, sinceHours?: number): FlagEvaluationStats { + const allEvents = this.flagEvents.get(flagKey) ?? []; + + const events = sinceHours + ? allEvents.filter((e) => { + const cutoff = new Date(Date.now() - sinceHours * 3_600_000); + return e.timestamp >= cutoff; + }) + : allEvents; + + const evaluationsByVariation: Record = {}; + const evaluationsByReason: Record = {}; + let errorCount = 0; + let evaluationCount = 0; + + for (const event of events) { + if (event.eventType !== 'evaluation') continue; + evaluationCount++; + + if (event.variationKey) { + evaluationsByVariation[event.variationKey] = + (evaluationsByVariation[event.variationKey] ?? 0) + 1; + } + + if (event.reason) { + evaluationsByReason[event.reason] = (evaluationsByReason[event.reason] ?? 0) + 1; + if (event.reason === 'ERROR') errorCount++; + } + } + + return { + flagKey, + totalEvaluations: evaluationCount, + evaluationsByVariation, + evaluationsByReason, + uniqueUsers: this.flagUsers.get(flagKey)?.size ?? 0, + errorRate: evaluationCount > 0 ? errorCount / evaluationCount : 0, + }; + } + + /** + * Returns impression and conversion stats for all variants in an experiment. + */ + getExperimentStats( + experimentId: string, + controlVariantKey?: string, + ): ExperimentStats { + const impressions = this.experimentImpressions.get(experimentId) ?? new Map(); + const conversions = this.experimentConversions.get(experimentId) ?? new Map(); + + const allVariantKeys = new Set([...impressions.keys(), ...conversions.keys()]); + + let totalImpressions = 0; + const variants: Record = {}; + + for (const variantKey of allVariantKeys) { + const imp = impressions.get(variantKey) ?? 0; + const conv = conversions.get(variantKey) ?? 0; + totalImpressions += imp; + + variants[variantKey] = { + variantKey, + impressions: imp, + conversions: conv, + conversionRate: imp > 0 ? conv / imp : 0, + isControl: variantKey === controlVariantKey, + }; + } + + return { experimentId, totalImpressions, variants }; + } + + /** + * Returns the most evaluated flags, sorted by evaluation count descending. + */ + getTopFlags(limit: number = 10): FlagSummary[] { + const summaries: FlagSummary[] = []; + + for (const [flagKey, events] of this.flagEvents.entries()) { + const evaluations = events.filter((e) => e.eventType === 'evaluation'); + summaries.push({ + flagKey, + totalEvaluations: evaluations.length, + lastEvaluatedAt: events[events.length - 1]?.timestamp, + }); + } + + return summaries + .sort((a, b) => b.totalEvaluations - a.totalEvaluations) + .slice(0, limit); + } + + /** + * Returns the most recent evaluation events for a flag in reverse-chronological order. + */ + getFlagEvaluationHistory(flagKey: string, limit: number = 100): FlagAnalyticsEvent[] { + const events = this.flagEvents.get(flagKey) ?? []; + return events + .filter((e) => e.eventType === 'evaluation') + .slice(-limit) + .reverse(); + } + + /** + * Clears stored analytics. Pass a flagKey to clear only that flag's data, + * or call without arguments to wipe all analytics. + */ + clearAnalytics(flagKey?: string): void { + if (flagKey) { + this.flagEvents.delete(flagKey); + this.flagUsers.delete(flagKey); + return; + } + + this.flagEvents.clear(); + this.flagUsers.clear(); + this.experimentImpressions.clear(); + this.experimentConversions.clear(); + } + + private incrementExperimentCounter( + store: Map>, + experimentId: string, + variantKey: string, + ): void { + if (!store.has(experimentId)) { + store.set(experimentId, new Map()); + } + const inner = store.get(experimentId)!; + inner.set(variantKey, (inner.get(variantKey) ?? 0) + 1); + } + + private generateEventId(): string { + return `evt_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } +} diff --git a/src/feature-flags/evaluation/flag-evaluation.service.ts b/src/feature-flags/evaluation/flag-evaluation.service.ts new file mode 100644 index 0000000..a593d03 --- /dev/null +++ b/src/feature-flags/evaluation/flag-evaluation.service.ts @@ -0,0 +1,260 @@ +import { Injectable } from '@nestjs/common'; +import { + EvaluationReason, + FeatureFlag, + FlagEvaluationResult, + FlagValueType, + UserContext, +} from '../interfaces'; +import { FlagAnalyticsService } from '../analytics/flag-analytics.service'; +import { ExperimentationService } from '../experimentation/experimentation.service'; +import { RolloutService } from '../rollout/rollout.service'; +import { TargetingService } from '../targeting/targeting.service'; + +@Injectable() +export class FlagEvaluationService { + private readonly flags = new Map(); + + constructor( + private readonly targetingService: TargetingService, + private readonly rolloutService: RolloutService, + private readonly experimentationService: ExperimentationService, + private readonly analyticsService: FlagAnalyticsService, + ) {} + + /** + * Evaluates a single feature flag for the given user context. + * + * Evaluation order: + * 1. Flag disabled / archived → off variation + * 2. Prerequisites → off variation if unmet + * 3. Targeting rules → matched variation + * 4. A/B experiment → assigned variant + * 5. Gradual rollout → default variation + * 6. Default → default variation + */ + evaluate(flagKey: string, userContext: UserContext): FlagEvaluationResult { + try { + const flag = this.flags.get(flagKey); + + if (!flag) { + const result = this.errorResult(flagKey); + this.recordEvaluation(result, userContext); + return result; + } + + if (flag.archived || !flag.enabled) { + const result = this.buildResult(flag, flag.offVariationKey, 'FLAG_DISABLED'); + this.recordEvaluation(result, userContext); + return result; + } + + // Prerequisites + if (flag.prerequisites?.length) { + for (const prereq of flag.prerequisites) { + const prereqResult = this.evaluate(prereq.flagKey, userContext); + if (prereqResult.variationKey !== prereq.requiredVariationKey) { + const result = this.buildResult(flag, flag.offVariationKey, 'PREREQUISITE_FAILED'); + this.recordEvaluation(result, userContext); + return result; + } + } + } + + // Targeting rules + if (flag.targeting) { + const matchedVariationKey = this.targetingService.evaluateTargeting( + flag.targeting, + userContext, + ); + if (matchedVariationKey !== null) { + const result = this.buildResult(flag, matchedVariationKey, 'TARGETING_MATCH'); + this.recordEvaluation(result, userContext); + return result; + } + } + + // A/B experiment + if (flag.experiment) { + const experimentResult = this.experimentationService.assignVariant( + flag.experiment, + flagKey, + userContext, + ); + if (experimentResult) { + const result: FlagEvaluationResult = { + flagKey, + value: experimentResult.value, + variationKey: this.variationKeyForValue(flag, experimentResult.value), + reason: 'EXPERIMENT', + experimentId: experimentResult.experimentId, + experimentVariantKey: experimentResult.variantKey, + timestamp: new Date(), + }; + + this.recordEvaluation(result, userContext); + this.analyticsService.trackImpression( + experimentResult.experimentId, + experimentResult.variantKey, + userContext.userId, + flagKey, + ); + + return result; + } + } + + // Gradual rollout + if (flag.rollout) { + const inRollout = this.rolloutService.isUserInRollout(flag.rollout, flagKey, userContext); + if (inRollout) { + const result = this.buildResult(flag, flag.defaultVariationKey, 'ROLLOUT'); + this.recordEvaluation(result, userContext); + return result; + } else { + // User is outside the rollout window → serve off variation + const result = this.buildResult(flag, flag.offVariationKey, 'DEFAULT'); + this.recordEvaluation(result, userContext); + return result; + } + } + + // Default + const result = this.buildResult(flag, flag.defaultVariationKey, 'DEFAULT'); + this.recordEvaluation(result, userContext); + return result; + } catch { + const flag = this.flags.get(flagKey); + const result = flag + ? this.buildResult(flag, flag.offVariationKey, 'ERROR') + : this.errorResult(flagKey); + this.recordEvaluation(result, userContext); + return result; + } + } + + /** + * Evaluates all registered flags for the given user context. + */ + evaluateAll(userContext: UserContext): Record { + const results: Record = {}; + for (const flagKey of this.flags.keys()) { + results[flagKey] = this.evaluate(flagKey, userContext); + } + return results; + } + + /** + * Convenience method — returns the boolean value of a flag. + */ + evaluateBoolean(flagKey: string, userContext: UserContext, defaultValue = false): boolean { + const result = this.evaluate(flagKey, userContext); + return result.reason === 'ERROR' ? defaultValue : Boolean(result.value); + } + + /** + * Convenience method — returns the string value of a flag. + */ + evaluateString(flagKey: string, userContext: UserContext, defaultValue = ''): string { + const result = this.evaluate(flagKey, userContext); + return result.reason === 'ERROR' ? defaultValue : String(result.value); + } + + /** + * Convenience method — returns the numeric value of a flag. + */ + evaluateNumber(flagKey: string, userContext: UserContext, defaultValue = 0): number { + const result = this.evaluate(flagKey, userContext); + return result.reason === 'ERROR' ? defaultValue : Number(result.value); + } + + // --------------------------------------------------------------------------- + // Flag management + // --------------------------------------------------------------------------- + + setFlag(flag: FeatureFlag): void { + this.flags.set(flag.key, { ...flag, updatedAt: new Date() }); + } + + setFlags(flags: FeatureFlag[]): void { + for (const flag of flags) { + this.setFlag(flag); + } + } + + updateFlag( + flagKey: string, + updates: Partial>, + ): FeatureFlag | null { + const existing = this.flags.get(flagKey); + if (!existing) return null; + + const updated: FeatureFlag = { + ...existing, + ...updates, + key: existing.key, + id: existing.id, + version: existing.version + 1, + updatedAt: new Date(), + }; + + this.flags.set(flagKey, updated); + return updated; + } + + removeFlag(flagKey: string): boolean { + return this.flags.delete(flagKey); + } + + getFlag(flagKey: string): FeatureFlag | undefined { + return this.flags.get(flagKey); + } + + getAllFlags(): FeatureFlag[] { + return Array.from(this.flags.values()); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private buildResult( + flag: FeatureFlag, + variationKey: string, + reason: EvaluationReason, + ruleId?: string, + ): FlagEvaluationResult { + const variation = flag.variations.find((v) => v.key === variationKey); + const value: FlagValueType = variation?.value ?? flag.variations[0]?.value ?? false; + + return { flagKey: flag.key, value, variationKey, reason, ruleId, timestamp: new Date() }; + } + + private errorResult(flagKey: string): FlagEvaluationResult { + return { + flagKey, + value: false, + variationKey: 'off', + reason: 'ERROR', + timestamp: new Date(), + }; + } + + private variationKeyForValue(flag: FeatureFlag, value: FlagValueType): string { + return flag.variations.find((v) => v.value === value)?.key ?? flag.defaultVariationKey; + } + + private recordEvaluation(result: FlagEvaluationResult, userContext: UserContext): void { + this.analyticsService.trackEvaluation({ + eventType: 'evaluation', + flagKey: result.flagKey, + userId: userContext.userId, + sessionId: userContext.sessionId, + variationKey: result.variationKey, + experimentId: result.experimentId, + experimentVariantKey: result.experimentVariantKey, + reason: result.reason, + value: result.value, + }); + } +} diff --git a/src/feature-flags/experimentation/experimentation.service.ts b/src/feature-flags/experimentation/experimentation.service.ts new file mode 100644 index 0000000..811b612 --- /dev/null +++ b/src/feature-flags/experimentation/experimentation.service.ts @@ -0,0 +1,146 @@ +import { Injectable } from '@nestjs/common'; +import { ExperimentConfig, ExperimentResult, ExperimentVariant, UserContext } from '../interfaces'; +import { RolloutService } from '../rollout/rollout.service'; + +interface ConversionRecord { + eventName: string; + metadata?: Record; + timestamp: Date; +} + +@Injectable() +export class ExperimentationService { + /** variantKey → userId → ConversionRecord[] */ + private readonly conversions = new Map>(); + + constructor(private readonly rolloutService: RolloutService) {} + + /** + * Assigns a user to an experiment variant using consistent hashing. + * Returns null if the experiment is inactive or the user is outside traffic allocation. + */ + assignVariant( + config: ExperimentConfig, + flagKey: string, + userContext: UserContext, + ): ExperimentResult | null { + if (config.status !== 'running') return null; + + const now = new Date(); + if (config.startDate && now < config.startDate) return null; + if (config.endDate && now > config.endDate) return null; + + if (!this.isInExperimentTraffic(config, flagKey, userContext)) return null; + + const variant = this.selectVariant(config, flagKey, userContext); + if (!variant) return null; + + return { + experimentId: config.experimentId, + variantKey: variant.key, + value: variant.value, + isControl: variant.isControl ?? false, + }; + } + + /** + * Records a conversion event for a user in an experiment. + */ + trackConversion( + experimentId: string, + userId: string, + eventName: string, + metadata?: Record, + ): void { + if (!this.conversions.has(experimentId)) { + this.conversions.set(experimentId, new Map()); + } + + const expConversions = this.conversions.get(experimentId)!; + + if (!expConversions.has(userId)) { + expConversions.set(userId, []); + } + + expConversions.get(userId)!.push({ eventName, metadata, timestamp: new Date() }); + } + + /** + * Returns all recorded conversion records for an experiment. + */ + getConversions(experimentId: string): Map { + return this.conversions.get(experimentId) ?? new Map(); + } + + /** + * Returns the list of experiment IDs that currently have conversion data. + */ + getActiveExperimentIds(): string[] { + return Array.from(this.conversions.keys()); + } + + /** + * Checks whether a user falls within the experiment's traffic allocation percentage. + * Uses a separate hash seed from variant assignment to avoid correlation. + */ + private isInExperimentTraffic( + config: ExperimentConfig, + flagKey: string, + userContext: UserContext, + ): boolean { + const bucketValue = this.resolveBucketAttributeValue( + config.bucketByAttribute ?? 'userId', + userContext, + ); + const trafficBucketKey = `${flagKey}:${config.experimentId}:traffic:${bucketValue}`; + const bucket = this.rolloutService.computeBucketValue(trafficBucketKey); + return bucket < config.trafficAllocation; + } + + /** + * Selects a variant for the user using weighted bucket assignment. + * The same user always receives the same variant for the same experiment. + */ + private selectVariant( + config: ExperimentConfig, + flagKey: string, + userContext: UserContext, + ): ExperimentVariant | null { + if (!config.variants || config.variants.length === 0) return null; + + const bucketValue = this.resolveBucketAttributeValue( + config.bucketByAttribute ?? 'userId', + userContext, + ); + const variantBucketKey = `${flagKey}:${config.experimentId}:variant:${bucketValue}`; + const bucket = this.rolloutService.computeBucketValue(variantBucketKey); + + const totalWeight = config.variants.reduce((sum, v) => sum + v.weight, 0); + if (totalWeight === 0) return null; + + const normalizedBucket = (bucket / 100) * totalWeight; + let cumulative = 0; + + for (const variant of config.variants) { + cumulative += variant.weight; + if (normalizedBucket < cumulative) { + return variant; + } + } + + return config.variants[config.variants.length - 1]; + } + + private resolveBucketAttributeValue(attribute: string, userContext: UserContext): string { + switch (attribute) { + case 'userId': + return userContext.userId; + case 'sessionId': + return userContext.sessionId ?? userContext.userId; + case 'email': + return userContext.email ?? userContext.userId; + default: + return userContext.attributes?.[attribute]?.toString() ?? userContext.userId; + } + } +} diff --git a/src/feature-flags/feature-flags.module.ts b/src/feature-flags/feature-flags.module.ts new file mode 100644 index 0000000..06e39cb --- /dev/null +++ b/src/feature-flags/feature-flags.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { FlagEvaluationService } from './evaluation/flag-evaluation.service'; +import { TargetingService } from './targeting/targeting.service'; +import { RolloutService } from './rollout/rollout.service'; +import { ExperimentationService } from './experimentation/experimentation.service'; +import { FlagAnalyticsService } from './analytics/flag-analytics.service'; + +@Module({ + providers: [ + FlagAnalyticsService, + TargetingService, + RolloutService, + ExperimentationService, + FlagEvaluationService, + ], + exports: [ + FlagEvaluationService, + TargetingService, + RolloutService, + ExperimentationService, + FlagAnalyticsService, + ], +}) +export class FeatureFlagsModule {} diff --git a/src/feature-flags/interfaces/index.ts b/src/feature-flags/interfaces/index.ts new file mode 100644 index 0000000..279de7e --- /dev/null +++ b/src/feature-flags/interfaces/index.ts @@ -0,0 +1,199 @@ +export type FlagValueType = boolean | string | number; + +export type EvaluationReason = + | 'FLAG_DISABLED' + | 'PREREQUISITE_FAILED' + | 'TARGETING_MATCH' + | 'ROLLOUT' + | 'EXPERIMENT' + | 'DEFAULT' + | 'ERROR'; + +export type ConditionOperator = + | 'equals' + | 'notEquals' + | 'contains' + | 'notContains' + | 'startsWith' + | 'endsWith' + | 'greaterThan' + | 'greaterThanOrEqual' + | 'lessThan' + | 'lessThanOrEqual' + | 'in' + | 'notIn' + | 'regex' + | 'exists' + | 'notExists'; + +export type FlagType = 'boolean' | 'string' | 'number'; + +export type ExperimentStatus = 'draft' | 'running' | 'paused' | 'completed'; + +export interface UserContext { + userId: string; + email?: string; + country?: string; + plan?: string; + roles?: string[]; + groups?: string[]; + attributes?: Record; + sessionId?: string; + ipAddress?: string; +} + +export interface TargetingCondition { + attribute: string; + operator: ConditionOperator; + value?: FlagValueType | FlagValueType[]; +} + +export interface TargetingRule { + id: string; + name?: string; + conditions: TargetingCondition[]; + conditionsOperator: 'AND' | 'OR'; + serveVariationKey: string; + priority: number; +} + +export interface TargetingConfig { + rules: TargetingRule[]; + defaultServeVariationKey: string; +} + +export interface RampStep { + at: Date; + percentage: number; +} + +export interface RolloutConfig { + percentage: number; + bucketByAttribute?: string; + sticky?: boolean; + startDate?: Date; + endDate?: Date; + rampSchedule?: RampStep[]; +} + +export interface ExperimentVariant { + key: string; + name: string; + weight: number; + value: FlagValueType; + isControl?: boolean; + description?: string; +} + +export interface ExperimentConfig { + experimentId: string; + name: string; + hypothesis?: string; + variants: ExperimentVariant[]; + trafficAllocation: number; + bucketByAttribute?: string; + startDate?: Date; + endDate?: Date; + status: ExperimentStatus; + winnerVariantKey?: string; + metrics?: string[]; +} + +export interface FlagVariation { + key: string; + name: string; + value: FlagValueType; + description?: string; +} + +export interface FlagPrerequisite { + flagKey: string; + requiredVariationKey: string; +} + +export interface FeatureFlag { + id: string; + key: string; + name: string; + description?: string; + type: FlagType; + enabled: boolean; + defaultVariationKey: string; + offVariationKey: string; + variations: FlagVariation[]; + targeting?: TargetingConfig; + rollout?: RolloutConfig; + experiment?: ExperimentConfig; + prerequisites?: FlagPrerequisite[]; + tags?: string[]; + projectId?: string; + environmentId?: string; + version: number; + archived: boolean; + createdAt: Date; + updatedAt: Date; + createdBy?: string; + updatedBy?: string; +} + +export interface FlagEvaluationResult { + flagKey: string; + value: FlagValueType; + variationKey: string; + reason: EvaluationReason; + ruleId?: string; + experimentId?: string; + experimentVariantKey?: string; + timestamp: Date; +} + +export interface ExperimentResult { + experimentId: string; + variantKey: string; + value: FlagValueType; + isControl: boolean; +} + +export interface FlagAnalyticsEvent { + eventId: string; + eventType: 'evaluation' | 'impression' | 'conversion' | 'error'; + flagKey?: string; + userId?: string; + sessionId?: string; + variationKey?: string; + experimentId?: string; + experimentVariantKey?: string; + reason?: EvaluationReason; + value?: FlagValueType; + metadata?: Record; + timestamp: Date; +} + +export interface FlagEvaluationStats { + flagKey: string; + totalEvaluations: number; + evaluationsByVariation: Record; + evaluationsByReason: Record; + uniqueUsers: number; + errorRate: number; +} + +export interface ExperimentVariantStats { + variantKey: string; + impressions: number; + conversions: number; + conversionRate: number; + isControl: boolean; +} + +export interface ExperimentStats { + experimentId: string; + totalImpressions: number; + variants: Record; +} + +export interface FlagSummary { + flagKey: string; + totalEvaluations: number; + lastEvaluatedAt?: Date; +} diff --git a/src/feature-flags/rollout/rollout.service.ts b/src/feature-flags/rollout/rollout.service.ts new file mode 100644 index 0000000..20f5c2f --- /dev/null +++ b/src/feature-flags/rollout/rollout.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { RolloutConfig, UserContext } from '../interfaces'; + +@Injectable() +export class RolloutService { + /** + * Determines whether a user falls within the configured rollout percentage. + * Uses consistent hashing so the same user always gets the same result. + */ + isUserInRollout(config: RolloutConfig, flagKey: string, userContext: UserContext): boolean { + const now = new Date(); + + if (config.startDate && now < config.startDate) return false; + if (config.endDate && now > config.endDate) return false; + + const currentPercentage = this.getCurrentPercentage(config); + if (currentPercentage <= 0) return false; + if (currentPercentage >= 100) return true; + + const bucketKey = this.resolveBucketKey(config.bucketByAttribute ?? 'userId', userContext); + const bucketValue = this.computeBucketValue(`${flagKey}:${bucketKey}`); + + return bucketValue < currentPercentage; + } + + /** + * Returns the effective rollout percentage at the current time, + * accounting for any ramp schedule defined on the config. + */ + getCurrentPercentage(config: RolloutConfig): number { + if (!config.rampSchedule || config.rampSchedule.length === 0) { + return config.percentage; + } + + const now = new Date(); + const sortedSteps = [...config.rampSchedule].sort( + (a, b) => a.at.getTime() - b.at.getTime(), + ); + + let effective = 0; + for (const step of sortedSteps) { + if (now >= step.at) { + effective = step.percentage; + } else { + break; + } + } + + return Math.min(effective, config.percentage); + } + + /** + * DJB2 hash — fast, deterministic, and well-distributed. + * Returns a value in the range [0, 99]. + */ + computeBucketValue(key: string): number { + let hash = 5381; + for (let i = 0; i < key.length; i++) { + hash = ((hash << 5) + hash + key.charCodeAt(i)) >>> 0; + } + return hash % 100; + } + + private resolveBucketKey(attribute: string, userContext: UserContext): string { + switch (attribute) { + case 'userId': + return userContext.userId; + case 'sessionId': + return userContext.sessionId ?? userContext.userId; + case 'email': + return userContext.email ?? userContext.userId; + default: + return userContext.attributes?.[attribute]?.toString() ?? userContext.userId; + } + } +} diff --git a/src/feature-flags/targeting/targeting.service.ts b/src/feature-flags/targeting/targeting.service.ts new file mode 100644 index 0000000..3fdc609 --- /dev/null +++ b/src/feature-flags/targeting/targeting.service.ts @@ -0,0 +1,158 @@ +import { Injectable } from '@nestjs/common'; +import { + ConditionOperator, + FlagValueType, + TargetingCondition, + TargetingConfig, + TargetingRule, + UserContext, +} from '../interfaces'; + +@Injectable() +export class TargetingService { + /** + * Evaluates targeting rules against a user context. + * Returns the matched variation key, or null if no rule matches. + */ + evaluateTargeting(config: TargetingConfig, userContext: UserContext): string | null { + const sortedRules = [...config.rules].sort((a, b) => a.priority - b.priority); + + for (const rule of sortedRules) { + if (this.evaluateRule(rule, userContext)) { + return rule.serveVariationKey; + } + } + + return null; + } + + private evaluateRule(rule: TargetingRule, userContext: UserContext): boolean { + if (!rule.conditions || rule.conditions.length === 0) return false; + + if (rule.conditionsOperator === 'OR') { + return rule.conditions.some((c) => this.evaluateCondition(c, userContext)); + } + + return rule.conditions.every((c) => this.evaluateCondition(c, userContext)); + } + + private evaluateCondition(condition: TargetingCondition, userContext: UserContext): boolean { + const attributeValue = this.resolveAttribute(condition.attribute, userContext); + + return this.applyOperator(condition.operator, attributeValue, condition.value); + } + + private applyOperator( + operator: ConditionOperator, + attributeValue: unknown, + conditionValue?: FlagValueType | FlagValueType[], + ): boolean { + switch (operator) { + case 'exists': + return attributeValue !== null && attributeValue !== undefined; + + case 'notExists': + return attributeValue === null || attributeValue === undefined; + + case 'equals': + return String(attributeValue) === String(conditionValue); + + case 'notEquals': + return String(attributeValue) !== String(conditionValue); + + case 'contains': + return ( + typeof attributeValue === 'string' && + typeof conditionValue === 'string' && + attributeValue.toLowerCase().includes(conditionValue.toLowerCase()) + ); + + case 'notContains': + return ( + typeof attributeValue === 'string' && + typeof conditionValue === 'string' && + !attributeValue.toLowerCase().includes(conditionValue.toLowerCase()) + ); + + case 'startsWith': + return ( + typeof attributeValue === 'string' && + typeof conditionValue === 'string' && + attributeValue.toLowerCase().startsWith(conditionValue.toLowerCase()) + ); + + case 'endsWith': + return ( + typeof attributeValue === 'string' && + typeof conditionValue === 'string' && + attributeValue.toLowerCase().endsWith(conditionValue.toLowerCase()) + ); + + case 'greaterThan': + return Number(attributeValue) > Number(conditionValue); + + case 'greaterThanOrEqual': + return Number(attributeValue) >= Number(conditionValue); + + case 'lessThan': + return Number(attributeValue) < Number(conditionValue); + + case 'lessThanOrEqual': + return Number(attributeValue) <= Number(conditionValue); + + case 'in': + if (!Array.isArray(conditionValue)) return false; + return conditionValue.map(String).includes(String(attributeValue)); + + case 'notIn': + if (!Array.isArray(conditionValue)) return false; + return !conditionValue.map(String).includes(String(attributeValue)); + + case 'regex': + if (typeof conditionValue !== 'string' || typeof attributeValue !== 'string') { + return false; + } + try { + return new RegExp(conditionValue).test(attributeValue); + } catch { + return false; + } + + default: + return false; + } + } + + /** + * Resolves an attribute name from the user context. + * Checks top-level properties first, then custom attributes map. + */ + private resolveAttribute(attribute: string, userContext: UserContext): unknown { + const topLevel: Record = { + userId: userContext.userId, + email: userContext.email, + country: userContext.country, + plan: userContext.plan, + sessionId: userContext.sessionId, + ipAddress: userContext.ipAddress, + }; + + if (attribute in topLevel) { + return topLevel[attribute]; + } + + if (attribute === 'roles') { + return userContext.roles?.join(',') ?? null; + } + + if (attribute === 'groups') { + return userContext.groups?.join(',') ?? null; + } + + if (userContext.attributes && attribute in userContext.attributes) { + return userContext.attributes[attribute]; + } + + return null; + } +}