From 7b5dc4395239b6c6997a9ee108a1bbda3668b1b9 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Wed, 30 Jul 2025 00:02:24 +0600 Subject: [PATCH 1/4] [FSSDK-11528] Holdout support in decision service (#1075) --- lib/feature_toggle.ts | 4 ++- lib/notification_center/type.ts | 4 ++- lib/optimizely/index.spec.ts | 63 ++++++++++++++++++++++++++++----- lib/optimizely/index.tests.js | 3 ++ lib/optimizely/index.ts | 23 +++++++++--- lib/shared_types.ts | 9 +++++ 6 files changed, 91 insertions(+), 15 deletions(-) diff --git a/lib/feature_toggle.ts b/lib/feature_toggle.ts index 0a647c169..54da8afff 100644 --- a/lib/feature_toggle.ts +++ b/lib/feature_toggle.ts @@ -31,4 +31,6 @@ * flag and all associated checks can be removed from the codebase. */ -export const holdout = () => true; +export const holdout = () => false as const; + +export type IfActive boolean, Y, N = unknown> = ReturnType extends true ? Y : N; diff --git a/lib/notification_center/type.ts b/lib/notification_center/type.ts index cbf8467a4..01adc56e5 100644 --- a/lib/notification_center/type.ts +++ b/lib/notification_center/type.ts @@ -26,6 +26,7 @@ import { } from '../shared_types'; import { DecisionSource } from '../utils/enums'; import { Nullable } from '../utils/type'; +import { holdout, IfActive } from '../feature_toggle'; export type UserEventListenerPayload = { userId: string; @@ -33,7 +34,8 @@ export type UserEventListenerPayload = { } export type ActivateListenerPayload = UserEventListenerPayload & { - experiment: Experiment | Holdout | null; + experiment: Experiment | null; + holdout: IfActive; variation: Variation | null; logEvent: LogEvent; } diff --git a/lib/optimizely/index.spec.ts b/lib/optimizely/index.spec.ts index 0c7e2fc79..81509fd1e 100644 --- a/lib/optimizely/index.spec.ts +++ b/lib/optimizely/index.spec.ts @@ -249,7 +249,8 @@ describe('Optimizely', () => { let projectConfig: any; let optimizely: any; let decisionService: any; - let notificationSpy: any; + let flagNotificationSpy: any; + let activateNotificationSpy: any; let eventProcessor: any; beforeEach(() => { @@ -282,10 +283,16 @@ describe('Optimizely', () => { decisionService = optimizely.decisionService; // Setup notification spy - notificationSpy = vi.fn(); + flagNotificationSpy = vi.fn(); optimizely.notificationCenter.addNotificationListener( NOTIFICATION_TYPES.DECISION, - notificationSpy + flagNotificationSpy + ); + + activateNotificationSpy = vi.fn(); + optimizely.notificationCenter.addNotificationListener( + NOTIFICATION_TYPES.ACTIVATE, + activateNotificationSpy ); }); @@ -408,7 +415,7 @@ describe('Optimizely', () => { expect(decision.ruleKey).toBe('holdout_test_key'); // Verify decision notification was sent - expect(notificationSpy).toHaveBeenCalledWith({ + expect(flagNotificationSpy).toHaveBeenCalledWith({ type: DECISION_NOTIFICATION_TYPES.FLAG, userId: 'test_user', attributes: { country: 'US' }, @@ -422,6 +429,14 @@ describe('Optimizely', () => { decisionEventDispatched: true, }), }); + + expect(activateNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + experiment: null, + holdout: projectConfig.holdouts[0], + userId: 'test_user', + attributes: { country: 'US' }, + variation: projectConfig.holdouts[0].variations[0] + })); }); it('should handle holdout with included flags', async () => { @@ -455,7 +470,7 @@ describe('Optimizely', () => { expect(decision.variationKey).toBe('holdout_variation_key'); // Verify notification shows holdout details - expect(notificationSpy).toHaveBeenCalledWith({ + expect(flagNotificationSpy).toHaveBeenCalledWith({ type: DECISION_NOTIFICATION_TYPES.FLAG, userId: 'test_user', attributes: { country: 'US' }, @@ -465,6 +480,14 @@ describe('Optimizely', () => { ruleKey: 'holdout_test_key', }), }); + + expect(activateNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + experiment: null, + holdout: modifiedHoldout, + userId: 'test_user', + attributes: { country: 'US' }, + variation: modifiedHoldout.variations[0] + })); }); it('should handle holdout with excluded flags', async () => { @@ -499,7 +522,7 @@ describe('Optimizely', () => { expect(decision.variationKey).toBe('variation_3'); // Verify notification shows normal experiment details (not holdout) - expect(notificationSpy).toHaveBeenCalledWith({ + expect(flagNotificationSpy).toHaveBeenCalledWith({ type: DECISION_NOTIFICATION_TYPES.FLAG, userId: 'test_user', attributes: { country: 'BD', age: 80 }, @@ -509,6 +532,14 @@ describe('Optimizely', () => { ruleKey: 'exp_3', }), }); + + expect(activateNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + experiment: projectConfig.experimentKeyMap['exp_3'], + holdout: null, + userId: 'test_user', + attributes: { country: 'BD', age: 80 }, + variation: projectConfig.variationIdMap['5003'] + })); }); it('should handle multiple holdouts with correct priority', async () => { @@ -568,7 +599,7 @@ describe('Optimizely', () => { expect(decision.variationKey).toBe('holdout_variation_key_2'); // Verify notification shows details of selected holdout - expect(notificationSpy).toHaveBeenCalledWith({ + expect(flagNotificationSpy).toHaveBeenCalledWith({ type: DECISION_NOTIFICATION_TYPES.FLAG, userId: 'test_user', attributes: { country: 'US' }, @@ -578,6 +609,14 @@ describe('Optimizely', () => { ruleKey: 'holdout_test_key_2', }), }); + + expect(activateNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + experiment: null, + holdout: holdout2, + userId: 'test_user', + attributes: { country: 'US' }, + variation: holdout2.variations[0] + })); }); it('should respect sendFlagDecisions setting for holdout events - false', async () => { @@ -744,7 +783,7 @@ describe('Optimizely', () => { expect(typeof decision.variables).toBe('object'); // Verify notification includes variable information - expect(notificationSpy).toHaveBeenCalledWith({ + expect(flagNotificationSpy).toHaveBeenCalledWith({ type: DECISION_NOTIFICATION_TYPES.FLAG, userId: 'test_user', attributes: { country: 'US' }, @@ -754,6 +793,14 @@ describe('Optimizely', () => { enabled: false, }), }); + + expect(activateNotificationSpy).toHaveBeenCalledWith(expect.objectContaining({ + experiment: null, + holdout: projectConfig.holdouts[0], + userId: 'test_user', + attributes: { country: 'US' }, + variation: projectConfig.holdouts[0].variations[0] + })); }); it('should handle disable decision event option for holdout', async () => { diff --git a/lib/optimizely/index.tests.js b/lib/optimizely/index.tests.js index dc9d6f6ed..d3f350bba 100644 --- a/lib/optimizely/index.tests.js +++ b/lib/optimizely/index.tests.js @@ -67,6 +67,7 @@ import { import { USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP } from '../core/bucketer'; import { resolvablePromise } from '../utils/promise/resolvablePromise'; +import { holdout } from '../feature_toggle'; var LOG_LEVEL = enums.LOG_LEVEL; var DECISION_SOURCES = enums.DECISION_SOURCES; @@ -2281,6 +2282,7 @@ describe('lib/optimizely', function() { var instanceExperiments = optlyInstance.projectConfigManager.getConfig().experiments; var expectedArgument = { experiment: instanceExperiments[0], + holdout: null, userId: 'testUser', attributes: undefined, variation: instanceExperiments[0].variations[1], @@ -2351,6 +2353,7 @@ describe('lib/optimizely', function() { var instanceExperiments = optlyInstance.projectConfigManager.getConfig().experiments; var expectedArgument = { experiment: instanceExperiments[0], + holdout: null, userId: 'testUser', attributes: attributes, variation: instanceExperiments[0].variations[1], diff --git a/lib/optimizely/index.ts b/lib/optimizely/index.ts index 2f3e3277f..e7929c909 100644 --- a/lib/optimizely/index.ts +++ b/lib/optimizely/index.ts @@ -37,6 +37,7 @@ import { OptimizelyDecision, Client, UserProfileServiceAsync, + isHoldout, } from '../shared_types'; import { newErrorDecision } from '../optimizely_decision'; import OptimizelyUserContext from '../optimizely_user_context'; @@ -62,7 +63,7 @@ import { import { Fn, Maybe, OpType } from '../utils/type'; import { resolvablePromise } from '../utils/promise/resolvablePromise'; -import { NOTIFICATION_TYPES, DecisionNotificationType, DECISION_NOTIFICATION_TYPES } from '../notification_center/type'; +import { NOTIFICATION_TYPES, DecisionNotificationType, DECISION_NOTIFICATION_TYPES, ActivateListenerPayload } from '../notification_center/type'; import { FEATURE_NOT_IN_DATAFILE, INVALID_INPUT_FORMAT, @@ -382,17 +383,29 @@ export default class Optimizely extends BaseService implements Client { this.eventProcessor.process(impressionEvent); const logEvent = buildLogEvent([impressionEvent]); - this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, { - experiment: decisionObj.experiment, + + const activateNotificationPayload: ActivateListenerPayload = { + experiment: null, + holdout: null, userId: userId, attributes: attributes, variation: decisionObj.variation, logEvent, - }); + }; + + if (decisionObj.experiment) { + if (isHoldout(decisionObj.experiment)) { + activateNotificationPayload.holdout = decisionObj.experiment; + } else { + activateNotificationPayload.experiment = decisionObj.experiment; + } + } + + this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, activateNotificationPayload); } /** - * Sends conversion event to Optimizely. + * Sends conversion event to Optimizely.` * @param {string} eventKey * @param {string} userId * @param {UserAttributes} attributes diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 65b9594b2..8e37264c1 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -181,6 +181,15 @@ export interface Holdout extends ExperimentCore { excludedFlags: string[]; } +export function isHoldout(obj: Experiment | Holdout): obj is Holdout { + // Holdout has 'status', 'includedFlags', and 'excludedFlags' properties + return ( + (obj as Holdout).status !== undefined && + Array.isArray((obj as Holdout).includeFlags) && + Array.isArray((obj as Holdout).excludeFlags) + ); +} + export enum VariableType { BOOLEAN = 'boolean', DOUBLE = 'double', From 020ee129b504993500db5af28086a34692f98a2d Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Tue, 2 Sep 2025 20:26:23 +0600 Subject: [PATCH 2/4] up --- lib/optimizely/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/optimizely/index.ts b/lib/optimizely/index.ts index e7929c909..f6e2b4f35 100644 --- a/lib/optimizely/index.ts +++ b/lib/optimizely/index.ts @@ -405,7 +405,7 @@ export default class Optimizely extends BaseService implements Client { } /** - * Sends conversion event to Optimizely.` + * Sends conversion event to Optimizely. * @param {string} eventKey * @param {string} userId * @param {UserAttributes} attributes From bca01ea2ea47c116dbb0aadd58de99a9ab3f8762 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Tue, 2 Sep 2025 20:29:13 +0600 Subject: [PATCH 3/4] fix --- lib/shared_types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 8e37264c1..a79a88b03 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -185,8 +185,8 @@ export function isHoldout(obj: Experiment | Holdout): obj is Holdout { // Holdout has 'status', 'includedFlags', and 'excludedFlags' properties return ( (obj as Holdout).status !== undefined && - Array.isArray((obj as Holdout).includeFlags) && - Array.isArray((obj as Holdout).excludeFlags) + Array.isArray((obj as Holdout).includedFlags) && + Array.isArray((obj as Holdout).excludedFlags) ); } From 67355772d0d387ecb5372c2213d69915f433c80d Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Tue, 2 Sep 2025 21:12:14 +0600 Subject: [PATCH 4/4] holdout flag true --- lib/feature_toggle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/feature_toggle.ts b/lib/feature_toggle.ts index 54da8afff..cea8adf67 100644 --- a/lib/feature_toggle.ts +++ b/lib/feature_toggle.ts @@ -31,6 +31,6 @@ * flag and all associated checks can be removed from the codebase. */ -export const holdout = () => false as const; +export const holdout = () => true as const; export type IfActive boolean, Y, N = unknown> = ReturnType extends true ? Y : N;