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]; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy