From a593b43aa985dbe255e30dea75ab36c9bf2c01f0 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Fri, 6 Feb 2026 23:04:27 +0600 Subject: [PATCH] [FSSDK-12275] Add experiment type filtering to skip unsupported types during flag decision - Add optional 'type' field to Experiment interface - Define SUPPORTED_EXPERIMENT_TYPES constant (a/b, mab, cmab, feature_rollouts) - Update traverseFeatureExperimentList to skip experiments with unsupported types - Skip evaluation only if type is defined but not in supported list - Evaluate normally if type is undefined/null or is supported - Add comprehensive unit tests covering all scenarios Co-Authored-By: Claude Sonnet 4.5 --- lib/core/decision_service/index.spec.ts | 369 ++++++++++++++++++++++++ lib/core/decision_service/index.ts | 11 + lib/shared_types.ts | 1 + 3 files changed, 381 insertions(+) diff --git a/lib/core/decision_service/index.spec.ts b/lib/core/decision_service/index.spec.ts index af76674b6..9ec53c445 100644 --- a/lib/core/decision_service/index.spec.ts +++ b/lib/core/decision_service/index.spec.ts @@ -2963,4 +2963,373 @@ describe('DecisionService', () => { expect(variation).toBe(null); }); }); + + describe('experiment type filtering', () => { + beforeEach(() => { + mockBucket.mockReset(); + }); + + it('should skip experiment with unsupported type and continue to next experiment', () => { + const { decisionService, logger } = getDecisionService({ logger: true }); + + // Create a datafile with experiments having different types + const datafile = getDecisionTestDatafile(); + + // Modify the datafile to add type field to experiments + const modifiedDatafile = JSON.parse(JSON.stringify(datafile)); + modifiedDatafile.experiments[0].type = 'unsupported_type'; + modifiedDatafile.experiments[1].type = 'a/b'; + + const config = createProjectConfig(modifiedDatafile); + + mockBucket.mockReturnValue({ + result: '5002', + reasons: [], + }); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 40, + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const decision = decisionService.getVariationForFeature(config, feature, user); + + // Should skip exp_1 (unsupported type) and evaluate exp_2 (a/b type) + expect(decision.result.experiment?.key).toBe('exp_2'); + expect(decision.result.variation?.key).toBe('variation_2'); + + // Should have logged the skip message + expect(logger?.debug).toHaveBeenCalledWith( + expect.stringContaining('unsupported type'), + 'exp_1', + 'unsupported_type' + ); + }); + + it('should evaluate experiment with supported type a/b', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockImplementation(( + op, + config, + experiment: any, + user, + decideOptions, + userProfileTracker: any, + ) => { + if (experiment.key === 'exp_1') { + return Value.of('sync', { + result: { variationKey: 'variation_1' }, + reasons: [], + }); + } + return Value.of('sync', { + result: {}, + reasons: [], + }); + }); + + const datafile = getDecisionTestDatafile(); + const modifiedDatafile = JSON.parse(JSON.stringify(datafile)); + modifiedDatafile.experiments[0].type = 'a/b'; + + const config = createProjectConfig(modifiedDatafile); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 40, + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const decision = decisionService.getVariationForFeature(config, feature, user); + + // Should evaluate exp_1 with a/b type + expect(decision.result.experiment?.key).toBe('exp_1'); + expect(decision.result.variation?.key).toBe('variation_1'); + expect(resolveVariationSpy).toHaveBeenCalledWith( + 'sync', config, config.experimentKeyMap['exp_1'], user, expect.anything(), expect.anything()); + }); + + it('should evaluate experiment with supported type mab', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockImplementation(( + op, + config, + experiment: any, + user, + decideOptions, + userProfileTracker: any, + ) => { + if (experiment.key === 'exp_1') { + return Value.of('sync', { + result: { variationKey: 'variation_1' }, + reasons: [], + }); + } + return Value.of('sync', { + result: {}, + reasons: [], + }); + }); + + const datafile = getDecisionTestDatafile(); + const modifiedDatafile = JSON.parse(JSON.stringify(datafile)); + modifiedDatafile.experiments[0].type = 'mab'; + + const config = createProjectConfig(modifiedDatafile); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 40, + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const decision = decisionService.getVariationForFeature(config, feature, user); + + expect(decision.result.experiment?.key).toBe('exp_1'); + expect(decision.result.variation?.key).toBe('variation_1'); + expect(resolveVariationSpy).toHaveBeenCalledWith( + 'sync', config, config.experimentKeyMap['exp_1'], user, expect.anything(), expect.anything()); + }); + + it('should evaluate experiment with supported type cmab', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockImplementation(( + op, + config, + experiment: any, + user, + decideOptions, + userProfileTracker: any, + ) => { + if (experiment.key === 'exp_1') { + return Value.of('sync', { + result: { variationKey: 'variation_1' }, + reasons: [], + }); + } + return Value.of('sync', { + result: {}, + reasons: [], + }); + }); + + const datafile = getDecisionTestDatafile(); + const modifiedDatafile = JSON.parse(JSON.stringify(datafile)); + modifiedDatafile.experiments[0].type = 'cmab'; + + const config = createProjectConfig(modifiedDatafile); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 40, + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const decision = decisionService.getVariationForFeature(config, feature, user); + + expect(decision.result.experiment?.key).toBe('exp_1'); + expect(decision.result.variation?.key).toBe('variation_1'); + expect(resolveVariationSpy).toHaveBeenCalledWith( + 'sync', config, config.experimentKeyMap['exp_1'], user, expect.anything(), expect.anything()); + }); + + it('should evaluate experiment with supported type feature_rollouts', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockImplementation(( + op, + config, + experiment: any, + user, + decideOptions, + userProfileTracker: any, + ) => { + if (experiment.key === 'exp_1') { + return Value.of('sync', { + result: { variationKey: 'variation_1' }, + reasons: [], + }); + } + return Value.of('sync', { + result: {}, + reasons: [], + }); + }); + + const datafile = getDecisionTestDatafile(); + const modifiedDatafile = JSON.parse(JSON.stringify(datafile)); + modifiedDatafile.experiments[0].type = 'feature_rollouts'; + + const config = createProjectConfig(modifiedDatafile); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 40, + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const decision = decisionService.getVariationForFeature(config, feature, user); + + expect(decision.result.experiment?.key).toBe('exp_1'); + expect(decision.result.variation?.key).toBe('variation_1'); + expect(resolveVariationSpy).toHaveBeenCalledWith( + 'sync', config, config.experimentKeyMap['exp_1'], user, expect.anything(), expect.anything()); + }); + + it('should evaluate experiment when type field is undefined', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockImplementation(( + op, + config, + experiment: any, + user, + decideOptions, + userProfileTracker: any, + ) => { + if (experiment.key === 'exp_1') { + return Value.of('sync', { + result: { variationKey: 'variation_1' }, + reasons: [], + }); + } + return Value.of('sync', { + result: {}, + reasons: [], + }); + }); + + const datafile = getDecisionTestDatafile(); + // Don't add type field, leaving it undefined + const config = createProjectConfig(datafile); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 40, + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const decision = decisionService.getVariationForFeature(config, feature, user); + + // Should evaluate exp_1 normally when type is undefined + expect(decision.result.experiment?.key).toBe('exp_1'); + expect(decision.result.variation?.key).toBe('variation_1'); + expect(resolveVariationSpy).toHaveBeenCalledWith( + 'sync', config, config.experimentKeyMap['exp_1'], user, expect.anything(), expect.anything()); + }); + + it('should evaluate experiment when type field is null', () => { + const { decisionService } = getDecisionService(); + + const resolveVariationSpy = vi.spyOn(decisionService as any, 'resolveVariation') + .mockImplementation(( + op, + config, + experiment: any, + user, + decideOptions, + userProfileTracker: any, + ) => { + if (experiment.key === 'exp_1') { + return Value.of('sync', { + result: { variationKey: 'variation_1' }, + reasons: [], + }); + } + return Value.of('sync', { + result: {}, + reasons: [], + }); + }); + + const datafile = getDecisionTestDatafile(); + const modifiedDatafile = JSON.parse(JSON.stringify(datafile)); + modifiedDatafile.experiments[0].type = null; + + const config = createProjectConfig(modifiedDatafile); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 40, + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const decision = decisionService.getVariationForFeature(config, feature, user); + + // Should evaluate exp_1 normally when type is null + expect(decision.result.experiment?.key).toBe('exp_1'); + expect(decision.result.variation?.key).toBe('variation_1'); + expect(resolveVariationSpy).toHaveBeenCalledWith( + 'sync', config, config.experimentKeyMap['exp_1'], user, expect.anything(), expect.anything()); + }); + + it('should skip all experiments with unsupported types and return null when no rollout exists', () => { + const { decisionService, logger } = getDecisionService({ logger: true }); + + const datafile = getDecisionTestDatafile(); + const modifiedDatafile = JSON.parse(JSON.stringify(datafile)); + modifiedDatafile.experiments[0].type = 'unsupported_1'; + modifiedDatafile.experiments[1].type = 'unsupported_2'; + + const config = createProjectConfig(modifiedDatafile); + + mockBucket.mockReturnValue({ + result: null, + reasons: [], + }); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 50, // This ensures the user won't match any rollout rules + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const decision = decisionService.getVariationForFeature(config, feature, user); + + // Should have logged skip messages for both experiments + expect(logger?.debug).toHaveBeenCalledWith( + expect.stringContaining('unsupported type'), + 'exp_1', + 'unsupported_1' + ); + expect(logger?.debug).toHaveBeenCalledWith( + expect.stringContaining('unsupported type'), + 'exp_2', + 'unsupported_2' + ); + }); + }); }); diff --git a/lib/core/decision_service/index.ts b/lib/core/decision_service/index.ts index 217550f17..87c756590 100644 --- a/lib/core/decision_service/index.ts +++ b/lib/core/decision_service/index.ts @@ -118,6 +118,7 @@ export const USER_MEETS_CONDITIONS_FOR_HOLDOUT = 'User %s meets conditions for h export const USER_DOESNT_MEET_CONDITIONS_FOR_HOLDOUT = 'User %s does not meet conditions for holdout %s.'; export const USER_BUCKETED_INTO_HOLDOUT_VARIATION = 'User %s is in variation %s of holdout %s.'; export const USER_NOT_BUCKETED_INTO_HOLDOUT_VARIATION = 'User %s is in no holdout variation.'; +export const EXPERIMENT_TYPE_NOT_SUPPORTED = 'Experiment %s has unsupported type %s. Skipping to next experiment.'; export interface DecisionObj { experiment: Experiment | Holdout | null; @@ -158,6 +159,8 @@ export type DecideOptionsMap = Partial>; export const CMAB_DUMMY_ENTITY_ID= '$' +export const SUPPORTED_EXPERIMENT_TYPES = ['a/b', 'mab', 'cmab', 'feature_rollouts']; + export const LOGGER_NAME = 'DecisionService'; /** @@ -1071,6 +1074,14 @@ export class DecisionService { op, configObj, feature, fromIndex + 1, user, decideReasons, decideOptions, userProfileTracker); } + // Check if experiment type is supported + if (experiment.type !== undefined && experiment.type !== null && !SUPPORTED_EXPERIMENT_TYPES.includes(experiment.type)) { + this.logger?.debug(EXPERIMENT_TYPE_NOT_SUPPORTED, experiment.key, experiment.type); + decideReasons.push([EXPERIMENT_TYPE_NOT_SUPPORTED, experiment.key, experiment.type]); + return this.traverseFeatureExperimentList( + op, configObj, feature, fromIndex + 1, user, decideReasons, decideOptions, userProfileTracker); + } + const decisionVariationValue = this.getVariationFromExperimentRule( op, configObj, feature.key, experiment, user, decideOptions, userProfileTracker, ); diff --git a/lib/shared_types.ts b/lib/shared_types.ts index c46b38da6..4d39a317d 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -163,6 +163,7 @@ export interface Experiment extends ExperimentCore { status: string; forcedVariations?: { [key: string]: string }; isRollout?: boolean; + type?: string; cmab?: { trafficAllocation: number; attributeIds: string[];