diff --git a/lib/core/bucketer/index.ts b/lib/core/bucketer/index.ts index b5a5e58c6..e31c8df4b 100644 --- a/lib/core/bucketer/index.ts +++ b/lib/core/bucketer/index.ts @@ -56,7 +56,8 @@ export const bucket = function(bucketerParams: BucketerParams): DecisionResponse const decideReasons: DecisionReason[] = []; // Check if user is in a random group; if so, check if user is bucketed into a specific experiment const experiment = bucketerParams.experimentIdMap[bucketerParams.experimentId]; - const groupId = experiment['groupId']; + // Optional chaining skips groupId check for holdout experiments; Holdout experimentId is not in experimentIdMap + const groupId = experiment?.['groupId']; if (groupId) { const group = bucketerParams.groupIdMap[groupId]; if (!group) { diff --git a/lib/core/decision_service/index.spec.ts b/lib/core/decision_service/index.spec.ts index 975653611..e410e5d5f 100644 --- a/lib/core/decision_service/index.spec.ts +++ b/lib/core/decision_service/index.spec.ts @@ -13,25 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { describe, it, expect, vi, MockInstance, beforeEach } from 'vitest'; +import { describe, it, expect, vi, MockInstance, beforeEach, afterEach } from 'vitest'; import { CMAB_DUMMY_ENTITY_ID, CMAB_FETCH_FAILED, DecisionService } from '.'; import { getMockLogger } from '../../tests/mock/mock_logger'; import OptimizelyUserContext from '../../optimizely_user_context'; import { bucket } from '../bucketer'; import { getTestProjectConfig, getTestProjectConfigWithFeatures } from '../../tests/test_data'; import { createProjectConfig, ProjectConfig } from '../../project_config/project_config'; -import { BucketerParams, Experiment, OptimizelyDecideOption, UserAttributes, UserProfile } from '../../shared_types'; +import { BucketerParams, Experiment, Holdout, OptimizelyDecideOption, UserAttributes, UserProfile } from '../../shared_types'; import { CONTROL_ATTRIBUTES, DECISION_SOURCES } from '../../utils/enums'; import { getDecisionTestDatafile } from '../../tests/decision_test_datafile'; import { Value } from '../../utils/promise/operation_value'; - import { USER_HAS_NO_FORCED_VARIATION, VALID_BUCKETING_ID, SAVED_USER_VARIATION, SAVED_VARIATION_NOT_FOUND, } from 'log_message'; - import { EXPERIMENT_NOT_RUNNING, RETURNING_STORED_VARIATION, @@ -48,7 +46,6 @@ import { NO_ROLLOUT_EXISTS, USER_MEETS_CONDITIONS_FOR_TARGETING_RULE, } from '../decision_service/index'; - import { BUCKETING_ID_NOT_STRING, USER_PROFILE_LOOKUP_ERROR, USER_PROFILE_SAVE_ERROR } from 'error_message'; type MockLogger = ReturnType; @@ -117,11 +114,74 @@ vi.mock('../bucketer', () => ({ bucket: mockBucket, })); +// Mock the feature toggle for holdout tests +const mockHoldoutToggle = vi.hoisted(() => vi.fn()); + +vi.mock('../../feature_toggle', () => ({ + holdout: mockHoldoutToggle, +})); + + const cloneDeep = (d: any) => JSON.parse(JSON.stringify(d)); const testData = getTestProjectConfig(); const testDataWithFeatures = getTestProjectConfigWithFeatures(); +// Utility function to create test datafile with holdout configurations +const getHoldoutTestDatafile = () => { + const datafile = getDecisionTestDatafile(); + + // Add holdouts to the datafile + datafile.holdouts = [ + { + id: 'holdout_running_id', + key: 'holdout_running', + status: 'Running', + includeFlags: [], + excludeFlags: [], + audienceIds: ['4001'], // age_22 audience + audienceConditions: ['or', '4001'], + variations: [ + { + id: 'holdout_variation_running_id', + key: 'holdout_variation_running', + variables: [] + } + ], + trafficAllocation: [ + { + entityId: 'holdout_variation_running_id', + endOfRange: 5000 + } + ] + }, + { + id: "holdout_not_bucketed_id", + key: "holdout_not_bucketed", + status: "Running", + includeFlags: [], + excludeFlags: [], + audienceIds: ['4002'], + audienceConditions: ['or', '4002'], + variations: [ + { + id: 'holdout_not_bucketed_variation_id', + key: 'holdout_not_bucketed_variation', + variables: [] + } + ], + trafficAllocation: [ + { + entityId: 'holdout_not_bucketed_variation_id', + endOfRange: 0, + } + ] + }, + ]; + + return datafile; +}; + const verifyBucketCall = ( call: number, projectConfig: ProjectConfig, @@ -1841,6 +1901,359 @@ describe('DecisionService', () => { expect(userProfileServiceAsync?.lookup).not.toHaveBeenCalled(); expect(userProfileServiceAsync?.save).not.toHaveBeenCalled(); }); + + describe('holdout', () => { + beforeEach(async() => { + mockHoldoutToggle.mockReturnValue(true); + const actualBucketModule = (await vi.importActual('../bucketer')) as { bucket: typeof bucket }; + mockBucket.mockImplementation(actualBucketModule.bucket); + }); + + it('should return holdout variation when user is bucketed into running holdout', async () => { + const { decisionService } = getDecisionService(); + const config = createProjectConfig(getHoldoutTestDatafile()); + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 20, + }, + }); + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + experiment: config.holdoutIdMap && config.holdoutIdMap['holdout_running_id'], + variation: config.variationIdMap['holdout_variation_running_id'], + decisionSource: DECISION_SOURCES.HOLDOUT, + }); + }); + + it("should consider global holdout even if local holdout is present", async () => { + const { decisionService } = getDecisionService(); + const datafile = getHoldoutTestDatafile(); + const newEntry = { + id: 'holdout_included_id', + key: 'holdout_included', + status: 'Running', + includeFlags: ['flag_1'], + excludeFlags: [], + audienceIds: ['4002'], // age_40 audience + audienceConditions: ['or', '4002'], + variations: [ + { + id: 'holdout_variation_included_id', + key: 'holdout_variation_included', + variables: [], + }, + ], + trafficAllocation: [ + { + entityId: 'holdout_variation_included_id', + endOfRange: 5000, + }, + ], + }; + datafile.holdouts = [newEntry, ...datafile.holdouts]; + const config = createProjectConfig(datafile); + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 20, // satisfies both global holdout (age_22) and included holdout (age_40) audiences + }, + }); + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + experiment: config.holdoutIdMap && config.holdoutIdMap['holdout_running_id'], + variation: config.variationIdMap['holdout_variation_running_id'], + decisionSource: DECISION_SOURCES.HOLDOUT, + }); + }); + + it("should consider local holdout if misses global holdout", async () => { + const { decisionService } = getDecisionService(); + const datafile = getHoldoutTestDatafile(); + + datafile.holdouts.push({ + id: 'holdout_included_specific_id', + key: 'holdout_included_specific', + status: 'Running', + includeFlags: ['flag_1'], + excludeFlags: [], + audienceIds: ['4002'], // age_60 audience (age <= 60) + audienceConditions: ['or', '4002'], + variations: [ + { + id: 'holdout_variation_included_specific_id', + key: 'holdout_variation_included_specific', + variables: [] + } + ], + trafficAllocation: [ + { + entityId: 'holdout_variation_included_specific_id', + endOfRange: 5000 + } + ] + }); + const config = createProjectConfig(datafile); + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'test_holdout_user', + attributes: { + age: 50, // Does not satisfy global holdout (age_22, age <= 22) but satisfies included holdout (age_60, age <= 60) + }, + }); + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + experiment: config.holdoutIdMap && config.holdoutIdMap['holdout_included_specific_id'], + variation: config.variationIdMap['holdout_variation_included_specific_id'], + decisionSource: DECISION_SOURCES.HOLDOUT, + }); + }); + + it('should fallback to experiment when holdout status is not running', async () => { + const { decisionService } = getDecisionService(); + const datafile = getHoldoutTestDatafile(); + + datafile.holdouts = datafile.holdouts.map((holdout: Holdout) => { + if(holdout.id === 'holdout_running_id') { + return { + ...holdout, + status: "Draft" + } + } + return holdout; + }); + + const config = createProjectConfig(datafile); + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 15, + }, + }); + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['exp_1'], + variation: config.variationIdMap['5001'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + }); + + it('should fallback to experiment when user does not meet holdout audience conditions', async () => { + const { decisionService } = getDecisionService(); + const config = createProjectConfig(getHoldoutTestDatafile()); + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 30, // does not satisfy age_22 audience condition for holdout_running + }, + }); + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['exp_2'], + variation: config.variationIdMap['5002'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + }); + + it('should fallback to experiment when user is not bucketed into holdout traffic', async () => { + const { decisionService } = getDecisionService(); + const config = createProjectConfig(getHoldoutTestDatafile()); + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 50, + }, + }); + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['exp_2'], + variation: config.variationIdMap['5002'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + }); + + it('should fallback to rollout when no holdout or experiment matches', async () => { + const { decisionService } = getDecisionService(); + const datafile = getHoldoutTestDatafile(); + // Modify the datafile to create proper audience conditions for this test + // Make exp_1 and exp_2 use age conditions that won't match our test user + datafile.audiences = datafile.audiences.map((audience: any) => { + if (audience.id === '4001') { // age_22 + return { + ...audience, + conditions: JSON.stringify(["or", {"match": "exact", "name": "age", "type": "custom_attribute", "value": 22}]) + }; + } + if (audience.id === '4002') { // age_60 + return { + ...audience, + conditions: JSON.stringify(["or", {"match": "exact", "name": "age", "type": "custom_attribute", "value": 60}]) + }; + } + return audience; + }); + + // Make exp_2 use a different audience so it won't conflict with delivery_2 + datafile.experiments = datafile.experiments.map((experiment: any) => { + if (experiment.key === 'exp_2') { + return { + ...experiment, + audienceIds: ['4001'], // Change from 4002 to 4001 (age_22) + audienceConditions: ['or', '4001'] + }; + } + return experiment; + }); + + const config = createProjectConfig(datafile); + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 60, // matches audience 4002 (age_60) used by delivery_2, but not experiments + }, + }); + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['delivery_2'], + variation: config.variationIdMap['5005'], + decisionSource: DECISION_SOURCES.ROLLOUT, + }); + }); + + it('should skip holdouts excluded for specific flags', async () => { + const { decisionService } = getDecisionService(); + const datafile = getHoldoutTestDatafile(); + + datafile.holdouts = datafile.holdouts.map((holdout: any) => { + if(holdout.id === 'holdout_running_id') { + return { + ...holdout, + excludeFlags: ['flag_1'] + } + } + return holdout; + }); + + const config = createProjectConfig(datafile); + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 15, // satisfies age_22 audience condition (age <= 22) for global holdout, but holdout excludes flag_1 + }, + }); + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['exp_1'], + variation: config.variationIdMap['5001'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + }); + + it('should handle multiple holdouts and use first matching one', async () => { + const { decisionService } = getDecisionService(); + const datafile = getHoldoutTestDatafile(); + + datafile.holdouts.push({ + id: 'holdout_second_id', + key: 'holdout_second', + status: 'Running', + includeFlags: [], + excludeFlags: [], + audienceIds: [], // no audience requirements + audienceConditions: [], + variations: [ + { + id: 'holdout_variation_second_id', + key: 'holdout_variation_second', + variables: [] + } + ], + trafficAllocation: [ + { + entityId: 'holdout_variation_second_id', + endOfRange: 5000 + } + ] + }); + + const config = createProjectConfig(datafile); + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 20, // satisfies audience for holdout_running + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + experiment: config.holdoutIdMap && config.holdoutIdMap['holdout_running_id'], + variation: config.variationIdMap['holdout_variation_running_id'], + decisionSource: DECISION_SOURCES.HOLDOUT, + }); + }); + }); }); describe('resolveVariationForFeatureList - sync', () => { diff --git a/lib/core/decision_service/index.ts b/lib/core/decision_service/index.ts index 70673d68e..057a0e129 100644 --- a/lib/core/decision_service/index.ts +++ b/lib/core/decision_service/index.ts @@ -23,7 +23,6 @@ import { } from '../../utils/enums'; import { getAudiencesById, - getExperimentAudienceConditions, getExperimentFromId, getExperimentFromKey, getFlagVariationByKey, @@ -32,7 +31,7 @@ import { getVariationKeyFromId, isActive, ProjectConfig, - getTrafficAllocation, + getHoldoutsForFlag, } from '../../project_config/project_config'; import { AudienceEvaluator, createAudienceEvaluator } from '../audience_evaluator'; import * as stringValidator from '../../utils/string_value_validator'; @@ -41,10 +40,11 @@ import { DecisionResponse, Experiment, ExperimentBucketMap, + ExperimentCore, FeatureFlag, + Holdout, OptimizelyDecideOption, OptimizelyUserContext, - TrafficAllocation, UserAttributes, UserProfile, UserProfileService, @@ -75,6 +75,7 @@ import { OptimizelyError } from '../../error/optimizly_error'; import { CmabService } from './cmab/cmab_service'; import { Maybe, OpType, OpValue } from '../../utils/type'; import { Value } from '../../utils/promise/operation_value'; +import * as featureToggle from '../../feature_toggle'; export const EXPERIMENT_NOT_RUNNING = 'Experiment %s is not running.'; export const RETURNING_STORED_VARIATION = @@ -112,9 +113,14 @@ export const USER_HAS_FORCED_DECISION_WITH_NO_RULE_SPECIFIED_BUT_INVALID = export const CMAB_NOT_SUPPORTED_IN_SYNC = 'CMAB is not supported in sync mode.'; export const CMAB_FETCH_FAILED = 'Failed to fetch CMAB data for experiment %s.'; export const CMAB_FETCHED_VARIATION_INVALID = 'Fetched variation %s for cmab experiment %s is invalid.'; +export const HOLDOUT_NOT_RUNNING = 'Holdout %s is not running.'; +export const USER_MEETS_CONDITIONS_FOR_HOLDOUT = 'User %s meets conditions for holdout %s.'; +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 interface DecisionObj { - experiment: Experiment | null; + experiment: Experiment | Holdout | null; variation: Variation | null; decisionSource: DecisionSource; cmabUuid?: string; @@ -540,14 +546,15 @@ export class DecisionService { */ private checkIfUserIsInAudience( configObj: ProjectConfig, - experiment: Experiment, + experiment: ExperimentCore, evaluationAttribute: string, user: OptimizelyUserContext, loggingKey?: string | number, ): DecisionResponse { const decideReasons: DecisionReason[] = []; - const experimentAudienceConditions = getExperimentAudienceConditions(configObj, experiment.id); + const experimentAudienceConditions = experiment.audienceConditions || experiment.audienceIds; const audiencesById = getAudiencesById(configObj); + this.logger?.debug( EVALUATING_AUDIENCES_COMBINED, evaluationAttribute, @@ -560,7 +567,9 @@ export class DecisionService { loggingKey || experiment.key, JSON.stringify(experimentAudienceConditions), ]); + const result = this.audienceEvaluator.evaluate(experimentAudienceConditions, audiencesById, user); + this.logger?.info( AUDIENCE_EVALUATION_RESULT_COMBINED, evaluationAttribute, @@ -590,14 +599,15 @@ export class DecisionService { */ private buildBucketerParams( configObj: ProjectConfig, - experiment: Experiment, + experiment: Experiment | Holdout, bucketingId: string, userId: string ): BucketerParams { let validateEntity = true; - let trafficAllocationConfig: TrafficAllocation[] = getTrafficAllocation(configObj, experiment.id); - if (experiment.cmab) { + let trafficAllocationConfig = experiment.trafficAllocation; + + if ('cmab' in experiment && experiment.cmab) { trafficAllocationConfig = [{ entityId: CMAB_DUMMY_ENTITY_ID, endOfRange: experiment.cmab.trafficAllocation @@ -621,6 +631,99 @@ export class DecisionService { } } + /** + * Determines if a user should be bucketed into a holdout variation. + * @param {ProjectConfig} configObj - The parsed project configuration object. + * @param {Holdout} holdout - The holdout to evaluate. + * @param {OptimizelyUserContext} user - The user context. + * @returns {DecisionResponse} - DecisionResponse containing holdout decision and reasons. + */ + private getVariationForHoldout( + configObj: ProjectConfig, + holdout: Holdout, + user: OptimizelyUserContext, + ): DecisionResponse { + const userId = user.getUserId(); + const decideReasons: DecisionReason[] = []; + + if (holdout.status !== 'Running') { + const reason: DecisionReason = [HOLDOUT_NOT_RUNNING, holdout.key]; + decideReasons.push(reason); + this.logger?.info(HOLDOUT_NOT_RUNNING, holdout.key); + return { + result: { + experiment: null, + variation: null, + decisionSource: DECISION_SOURCES.HOLDOUT + }, + reasons: decideReasons + }; + } + + const audienceResult = this.checkIfUserIsInAudience( + configObj, + holdout, + AUDIENCE_EVALUATION_TYPES.EXPERIMENT, + user + ); + decideReasons.push(...audienceResult.reasons); + + if (!audienceResult.result) { + const reason: DecisionReason = [USER_DOESNT_MEET_CONDITIONS_FOR_HOLDOUT, userId, holdout.key]; + decideReasons.push(reason); + this.logger?.info(USER_DOESNT_MEET_CONDITIONS_FOR_HOLDOUT, userId, holdout.key); + return { + result: { + experiment: null, + variation: null, + decisionSource: DECISION_SOURCES.HOLDOUT + }, + reasons: decideReasons + }; + } + + const reason: DecisionReason = [USER_MEETS_CONDITIONS_FOR_HOLDOUT, userId, holdout.key]; + decideReasons.push(reason); + this.logger?.info(USER_MEETS_CONDITIONS_FOR_HOLDOUT, userId, holdout.key); + + const attributes = user.getAttributes(); + const bucketingId = this.getBucketingId(userId, attributes); + const bucketerParams = this.buildBucketerParams(configObj, holdout, bucketingId, userId); + const bucketResult = bucket(bucketerParams); + + decideReasons.push(...bucketResult.reasons); + + if (bucketResult.result) { + const variation = configObj.variationIdMap[bucketResult.result]; + if (variation) { + const bucketReason: DecisionReason = [USER_BUCKETED_INTO_HOLDOUT_VARIATION, userId, holdout.key, variation.key]; + decideReasons.push(bucketReason); + this.logger?.info(USER_BUCKETED_INTO_HOLDOUT_VARIATION, userId, holdout.key, variation.key); + + return { + result: { + experiment: holdout, + variation: variation, + decisionSource: DECISION_SOURCES.HOLDOUT + }, + reasons: decideReasons + }; + } + } + + const noBucketReason: DecisionReason = [USER_NOT_BUCKETED_INTO_HOLDOUT_VARIATION, userId]; + decideReasons.push(noBucketReason); + this.logger?.info(USER_NOT_BUCKETED_INTO_HOLDOUT_VARIATION, userId); + return { + result: { + experiment: null, + variation: null, + decisionSource: DECISION_SOURCES.HOLDOUT + }, + reasons: decideReasons + }; + } + /** * Pull the stored variation out of the experimentBucketMap for an experiment/userId * @param {ProjectConfig} configObj The parsed project configuration object @@ -835,6 +938,21 @@ export class DecisionService { reasons: decideReasons, }); } + if (featureToggle.holdout()) { + const holdouts = getHoldoutsForFlag(configObj, feature.key); + + for (const holdout of holdouts) { + const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user); + decideReasons.push(...holdoutDecision.reasons); + + if (holdoutDecision.result.variation) { + return Value.of(op, { + result: holdoutDecision.result, + reasons: decideReasons, + }); + } + } + } return this.getVariationForFeatureExperiment(op, configObj, feature, user, decideOptions, userProfileTracker).then((experimentDecision) => { if (experimentDecision.error || experimentDecision.result.variation !== null) { diff --git a/lib/notification_center/type.ts b/lib/notification_center/type.ts index b433c0121..cbf8467a4 100644 --- a/lib/notification_center/type.ts +++ b/lib/notification_center/type.ts @@ -15,7 +15,15 @@ */ import { LogEvent } from '../event_processor/event_dispatcher/event_dispatcher'; -import { EventTags, Experiment, FeatureVariableValue, UserAttributes, VariableType, Variation } from '../shared_types'; +import { + EventTags, + Experiment, + FeatureVariableValue, + Holdout, + UserAttributes, + VariableType, + Variation, +} from '../shared_types'; import { DecisionSource } from '../utils/enums'; import { Nullable } from '../utils/type'; @@ -25,7 +33,7 @@ export type UserEventListenerPayload = { } export type ActivateListenerPayload = UserEventListenerPayload & { - experiment: Experiment | null; + experiment: Experiment | Holdout | null; variation: Variation | null; logEvent: LogEvent; } diff --git a/lib/project_config/project_config.ts b/lib/project_config/project_config.ts index 7ae95e3e9..1b14e4408 100644 --- a/lib/project_config/project_config.ts +++ b/lib/project_config/project_config.ts @@ -370,6 +370,12 @@ const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => { } holdout.variationKeyMap = keyBy(holdout.variations, 'key'); + + projectConfig.variationIdMap = { + ...projectConfig.variationIdMap, + ...keyBy(holdout.variations, 'id'), + }; + if (holdout.includeFlags.length === 0) { projectConfig.globalHoldouts.push(holdout); diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 3d3492a2c..7c2046bf6 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -358,7 +358,7 @@ export interface Client { } export interface ActivateListenerPayload extends ListenerPayload { - experiment: import('./shared_types').Experiment; + experiment: import('./shared_types').ExperimentCore; variation: import('./shared_types').Variation; logEvent: Event; } diff --git a/lib/utils/enums/index.ts b/lib/utils/enums/index.ts index 892bff837..103cdac73 100644 --- a/lib/utils/enums/index.ts +++ b/lib/utils/enums/index.ts @@ -53,6 +53,7 @@ export const DECISION_SOURCES = { FEATURE_TEST: 'feature-test', ROLLOUT: 'rollout', EXPERIMENT: 'experiment', + HOLDOUT: 'holdout', } as const; export type DecisionSource = typeof DECISION_SOURCES[keyof typeof DECISION_SOURCES];