diff --git a/src/ConfigSpec.ts b/src/ConfigSpec.ts index 90bbc87..75ed5a2 100644 --- a/src/ConfigSpec.ts +++ b/src/ConfigSpec.ts @@ -82,6 +82,10 @@ export class ConfigRule { }); return conditions; } + + isTargetingRule(): boolean { + return this.id === 'inlineTargetingRules' || this.id === 'targetingGate'; + } } export class ConfigCondition { diff --git a/src/Evaluator.ts b/src/Evaluator.ts index d2b4265..0cbb30c 100644 --- a/src/Evaluator.ts +++ b/src/Evaluator.ts @@ -672,7 +672,8 @@ export default class Evaluator { } _evalConfig(ctx: EvaluationContext): ConfigEvaluation { - const { user, spec, userPersistedValues } = ctx; + const { user, spec, userPersistedValues, persistentAssignmentOptions } = + ctx; if (userPersistedValues == null || !spec.isActive) { this.persistentStore.delete(user, spec.idType, spec.name); return this._evalSpec(ctx); @@ -687,7 +688,14 @@ export default class Evaluator { : null; if (stickyEvaluation) { - return stickyEvaluation; + if (persistentAssignmentOptions?.enforceTargeting) { + const passesTargeting = this._evalTargeting(ctx); + if (passesTargeting) { + return stickyEvaluation; + } + } else { + return stickyEvaluation; + } } const evaluation = this._evalSpec(ctx); @@ -700,7 +708,8 @@ export default class Evaluator { } _evalLayer(ctx: EvaluationContext): ConfigEvaluation { - const { user, spec, userPersistedValues } = ctx; + const { user, spec, userPersistedValues, persistentAssignmentOptions } = + ctx; if (!userPersistedValues) { this.persistentStore.delete(user, spec.idType, spec.name); return this._evalSpec(ctx); @@ -714,31 +723,49 @@ export default class Evaluator { ) : null; - const isAllocatedExperimentActive = (evaluation: ConfigEvaluation) => - this._isExperimentActive( - evaluation.config_delegate - ? this.store.getConfig(evaluation.config_delegate) - : null, - ); - if (stickyEvaluation) { - if (isAllocatedExperimentActive(stickyEvaluation)) { - return stickyEvaluation; + const delegateSpec = stickyEvaluation.config_delegate + ? this.store.getConfig(stickyEvaluation.config_delegate) + : null; + if (delegateSpec && delegateSpec.isActive) { + if (persistentAssignmentOptions?.enforceTargeting) { + const passesTargeting = this._evalTargeting(ctx, delegateSpec); + if (passesTargeting) { + return stickyEvaluation; + } + } else { + return stickyEvaluation; + } } else { this.persistentStore.delete(user, spec.idType, spec.name); return this._evalSpec(ctx); } - } else { - const evaluation = this._evalSpec(ctx); - if (isAllocatedExperimentActive(evaluation)) { - if (evaluation.is_experiment_group) { - this.persistentStore.save(user, spec.idType, spec.name, evaluation); - } - } else { - this.persistentStore.delete(user, spec.idType, spec.name); + } + + const evaluation = this._evalSpec(ctx); + const delegateSpec = evaluation.config_delegate + ? this.store.getConfig(evaluation.config_delegate) + : null; + if (delegateSpec && delegateSpec.isActive) { + if (evaluation.is_experiment_group) { + this.persistentStore.save(user, spec.idType, spec.name, evaluation); } - return evaluation; + } else { + this.persistentStore.delete(user, spec.idType, spec.name); } + return evaluation; + } + + _evalTargeting(ctx: EvaluationContext, delegateSpec?: ConfigSpec): boolean { + return ( + this._evalSpec( + EvaluationContext.get(ctx.getRequestContext(), { + user: ctx.user, + spec: delegateSpec ?? ctx.spec, + onlyEvaluateTargeting: true, + }), + ).value === false + ); // Fail evaluation means to pass targeting (fall through logic) } _evalSpec(ctx: EvaluationContext): ConfigEvaluation { @@ -769,9 +796,16 @@ export default class Evaluator { ); } + let rules = config.rules; + if (ctx.onlyEvaluateTargeting) { + rules = config.rules.filter((rule) => rule.isTargetingRule()); + if (rules.length === 0) { + return new ConfigEvaluation(true); + } + } + let secondary_exposures: SecondaryExposure[] = []; - for (let i = 0; i < config.rules.length; i++) { - const rule = config.rules[i]; + for (const rule of rules) { const ruleResult = this._evalRule(user, rule, ctx); if (ruleResult.unsupported) { return ConfigEvaluation.unsupported( diff --git a/src/StatsigOptions.ts b/src/StatsigOptions.ts index 0040592..6fffbc2 100644 --- a/src/StatsigOptions.ts +++ b/src/StatsigOptions.ts @@ -237,10 +237,21 @@ function normalizeUrl(url: string | null): string | null { return url && url.endsWith('/') ? url.slice(0, -1) : url; } +export type PersistentAssignmentOptions = { + /* Whether or not to enforce targeting rules before assigning persisted values */ + enforceTargeting?: boolean; +}; + export type GetExperimentOptions = { + /* Persisted values to use for experiment assignment */ userPersistedValues?: UserPersistedValues | null; + /* Additional options for using persistent assignment */ + persistentAssignmentOptions?: PersistentAssignmentOptions; }; export type GetLayerOptions = { + /* Persisted values to use for layer assignment */ userPersistedValues?: UserPersistedValues | null; + /* Additional options for using persistent assignment */ + persistentAssignmentOptions?: PersistentAssignmentOptions; }; diff --git a/src/StatsigServer.ts b/src/StatsigServer.ts index 3d9339c..3ada941 100644 --- a/src/StatsigServer.ts +++ b/src/StatsigServer.ts @@ -325,6 +325,7 @@ export default class StatsigServer { caller: 'getExperiment', configName: experimentName, userPersistedValues: options?.userPersistedValues, + persistentAssignmentOptions: options?.persistentAssignmentOptions, }), ); } @@ -342,6 +343,7 @@ export default class StatsigServer { caller: 'getExperimentWithExposureLoggingDisabled', configName: experimentName, userPersistedValues: options?.userPersistedValues, + persistentAssignmentOptions: options?.persistentAssignmentOptions, }), ); } @@ -402,6 +404,7 @@ export default class StatsigServer { caller: 'getLayer', configName: layerName, userPersistedValues: options?.userPersistedValues, + persistentAssignmentOptions: options?.persistentAssignmentOptions, }), ); } @@ -419,6 +422,7 @@ export default class StatsigServer { caller: 'getLayerWithExposureLoggingDisabled', configName: layerName, userPersistedValues: options?.userPersistedValues, + persistentAssignmentOptions: options?.persistentAssignmentOptions, }), ); } diff --git a/src/utils/StatsigContext.ts b/src/utils/StatsigContext.ts index 6fc6158..0faa097 100644 --- a/src/utils/StatsigContext.ts +++ b/src/utils/StatsigContext.ts @@ -4,6 +4,7 @@ import { InitializationSource, } from '../InitializationDetails'; import { UserPersistedValues } from '../interfaces/IUserPersistentStorage'; +import { PersistentAssignmentOptions } from '../StatsigOptions'; import { StatsigUser } from '../StatsigUser'; type RequestContext = { @@ -17,6 +18,7 @@ type RequestContext = { user?: StatsigUser; spec?: ConfigSpec; userPersistedValues?: UserPersistedValues | null; + persistentAssignmentOptions?: PersistentAssignmentOptions; }; export class StatsigContext { @@ -28,6 +30,7 @@ export class StatsigContext { readonly hash?: string; readonly bypassDedupe?: boolean; readonly userPersistedValues?: UserPersistedValues | null; + readonly persistentAssignmentOptions?: PersistentAssignmentOptions; protected constructor(protected ctx: RequestContext) { this.startTime = Date.now(); @@ -38,6 +41,7 @@ export class StatsigContext { this.hash = ctx.clientKey; this.bypassDedupe = ctx.bypassDedupe; this.userPersistedValues = ctx.userPersistedValues; + this.persistentAssignmentOptions = ctx.persistentAssignmentOptions; } // Create a new context to avoid modifying context up the stack @@ -64,17 +68,20 @@ export class EvaluationContext extends StatsigContext { readonly user: StatsigUser; readonly spec: ConfigSpec; readonly targetAppID?: string; + readonly onlyEvaluateTargeting?: boolean; protected constructor( ctx: RequestContext, user: StatsigUser, spec: ConfigSpec, targetAppID?: string, + onlyEvaluateTargeting?: boolean, ) { super(ctx); this.user = user; this.spec = spec; this.targetAppID = targetAppID; + this.onlyEvaluateTargeting = onlyEvaluateTargeting; } public static new( @@ -90,6 +97,7 @@ export class EvaluationContext extends StatsigContext { user: StatsigUser; spec: ConfigSpec; targetAppID?: string; + onlyEvaluateTargeting?: boolean; }, ): EvaluationContext { return new EvaluationContext( @@ -97,15 +105,7 @@ export class EvaluationContext extends StatsigContext { evalCtx.user, evalCtx.spec, evalCtx.targetAppID, - ); - } - - public withTargetAppID(targetAppID: string): EvaluationContext { - return new EvaluationContext( - this.getRequestContext(), - this.user, - this.spec, - targetAppID, + evalCtx.onlyEvaluateTargeting, ); } }