diff --git a/lib/core/decision_service/index.tests.js b/lib/core/decision_service/index.tests.js index f61d67630..9787a62bd 100644 --- a/lib/core/decision_service/index.tests.js +++ b/lib/core/decision_service/index.tests.js @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2022 Optimizely, Inc. and contributors * + * Copyright 2017-2022,2024 Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -379,7 +379,7 @@ describe('lib/core/decision_service', function() { ); assert.strictEqual( buildLogMessageFromArgs(mockLogger.log.args[4]), - 'DECISION_SERVICE: Saved variation "control" of experiment "testExperiment" for user "decision_service_user".' + 'DECISION_SERVICE: Saved user profile for user "decision_service_user".' ); }); @@ -392,6 +392,7 @@ describe('lib/core/decision_service', function() { optimizely: {}, userId: 'decision_service_user', }); + assert.strictEqual( 'control', decisionServiceInstance.getVariation(configObj, experiment, user).result @@ -400,11 +401,11 @@ describe('lib/core/decision_service', function() { sinon.assert.calledOnce(bucketerStub); // should still go through with bucketing assert.strictEqual( buildLogMessageFromArgs(mockLogger.log.args[0]), - 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.' + 'DECISION_SERVICE: Error while looking up user profile for user ID "decision_service_user": I am an error.' ); assert.strictEqual( buildLogMessageFromArgs(mockLogger.log.args[1]), - 'DECISION_SERVICE: Error while looking up user profile for user ID "decision_service_user": I am an error.' + 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.' ); }); @@ -1277,7 +1278,7 @@ describe('lib/core/decision_service', function() { reasons: [], }; experiment = configObj.experimentIdMap['594098']; - getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation'); + getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation'); getVariationStub.returns(fakeDecisionResponse); getVariationStub.withArgs(configObj, experiment, user).returns(fakeDecisionResponseWithArgs); }); @@ -1493,12 +1494,11 @@ describe('lib/core/decision_service', function() { decisionSource: DECISION_SOURCES.FEATURE_TEST, }; assert.deepEqual(decision, expectedDecision); - sinon.assert.calledWithExactly( + sinon.assert.calledWith( getVariationStub, configObj, experiment, user, - {} ); }); }); @@ -1511,7 +1511,7 @@ describe('lib/core/decision_service', function() { optimizely: {}, userId: 'user1', }); - getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation'); + getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation'); getVariationStub.returns(fakeDecisionResponse); }); @@ -1550,7 +1550,7 @@ describe('lib/core/decision_service', function() { result: 'var', reasons: [], }; - getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation'); + getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation'); getVariationStub.returns(fakeDecisionResponseWithArgs); getVariationStub.withArgs(configObj, 'exp_with_group', user).returns(fakeDecisionResponseWithArgs); }); @@ -1607,7 +1607,7 @@ describe('lib/core/decision_service', function() { optimizely: {}, userId: 'user1', }); - getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation'); + getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation'); getVariationStub.returns(fakeDecisionResponse); }); diff --git a/lib/core/decision_service/index.ts b/lib/core/decision_service/index.ts index 28f97a09e..1d1581273 100644 --- a/lib/core/decision_service/index.ts +++ b/lib/core/decision_service/index.ts @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2022 Optimizely, Inc. and contributors * + * Copyright 2017-2022,2024 Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -55,7 +55,7 @@ import { Variation, } from '../../shared_types'; -const MODULE_NAME = 'DECISION_SERVICE'; +export const MODULE_NAME = 'DECISION_SERVICE'; export interface DecisionObj { experiment: Experiment | null; @@ -73,6 +73,11 @@ interface DeliveryRuleResponse extends DecisionResponse { skipToEveryoneElse: K; } +interface UserProfileTracker { + userProfile: ExperimentBucketMap | null; + isProfileUpdated: boolean; +} + /** * Optimizely's decision service that determines which variation of an experiment the user will be allocated to. * @@ -102,20 +107,21 @@ export class DecisionService { } /** - * Gets variation where visitor will be bucketed. - * @param {ProjectConfig} configObj The parsed project configuration object - * @param {Experiment} experiment - * @param {OptimizelyUserContext} user A user context - * @param {[key: string]: boolean} options Optional map of decide options - * @return {DecisionResponse} DecisionResponse containing the variation the user is bucketed into - * and the decide reasons. + * Resolves the variation into which the visitor will be bucketed. + * + * @param {ProjectConfig} configObj - The parsed project configuration object. + * @param {Experiment} experiment - The experiment for which the variation is being resolved. + * @param {OptimizelyUserContext} user - The user context associated with this decision. + * @returns {DecisionResponse} - A DecisionResponse containing the variation the user is bucketed into, + * along with the decision reasons. */ - getVariation( + private resolveVariation( configObj: ProjectConfig, experiment: Experiment, user: OptimizelyUserContext, - options: { [key: string]: boolean } = {} - ): DecisionResponse { + shouldIgnoreUPS: boolean, + userProfileTracker: UserProfileTracker + ): DecisionResponse { const userId = user.getUserId(); const attributes = user.getAttributes(); // by default, the bucketing ID should be the user ID @@ -150,12 +156,10 @@ export class DecisionService { }; } - const shouldIgnoreUPS = options[OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE]; - const experimentBucketMap = this.resolveExperimentBucketMap(userId, attributes); // check for sticky bucketing if decide options do not include shouldIgnoreUPS if (!shouldIgnoreUPS) { - variation = this.getStoredVariation(configObj, experiment, userId, experimentBucketMap); + variation = this.getStoredVariation(configObj, experiment, userId, userProfileTracker.userProfile); if (variation) { this.logger.log( LOG_LEVEL.INFO, @@ -252,7 +256,7 @@ export class DecisionService { ]); // persist bucketing if decide options do not include shouldIgnoreUPS if (!shouldIgnoreUPS) { - this.saveUserProfile(experiment, variation, userId, experimentBucketMap); + this.updateUserProfile(experiment, variation, userProfileTracker); } return { @@ -261,6 +265,39 @@ export class DecisionService { }; } + /** + * Gets variation where visitor will be bucketed. + * @param {ProjectConfig} configObj The parsed project configuration object + * @param {Experiment} experiment + * @param {OptimizelyUserContext} user A user context + * @param {[key: string]: boolean} options Optional map of decide options + * @return {DecisionResponse} DecisionResponse containing the variation the user is bucketed into + * and the decide reasons. + */ + getVariation( + configObj: ProjectConfig, + experiment: Experiment, + user: OptimizelyUserContext, + options: { [key: string]: boolean } = {} + ): DecisionResponse { + const shouldIgnoreUPS = options[OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE]; + const userProfileTracker: UserProfileTracker = { + isProfileUpdated: false, + userProfile: null, + } + if(!shouldIgnoreUPS) { + userProfileTracker.userProfile = this.resolveExperimentBucketMap(user.getUserId(), user.getAttributes()); + } + + const result = this.resolveVariation(configObj, experiment, user, shouldIgnoreUPS, userProfileTracker); + + if(!shouldIgnoreUPS) { + this.saveUserProfile(user.getUserId(), userProfileTracker) + } + + return result + } + /** * Merges attributes from attributes[STICKY_BUCKETING_KEY] and userProfileService * @param {string} userId @@ -446,9 +483,9 @@ export class DecisionService { configObj: ProjectConfig, experiment: Experiment, userId: string, - experimentBucketMap: ExperimentBucketMap + experimentBucketMap: ExperimentBucketMap | null ): Variation | null { - if (experimentBucketMap.hasOwnProperty(experiment.id)) { + if (experimentBucketMap?.hasOwnProperty(experiment.id)) { const decision = experimentBucketMap[experiment.id]; const variationId = decision.variation_id; if (configObj.variationIdMap.hasOwnProperty(variationId)) { @@ -497,6 +534,21 @@ export class DecisionService { return null; } + private updateUserProfile( + experiment: Experiment, + variation: Variation, + userProfileTracker: UserProfileTracker + ): void { + if(!userProfileTracker.userProfile) { + return + } + + userProfileTracker.userProfile[experiment.id] = { + variation_id: variation.id + } + userProfileTracker.isProfileUpdated = true + } + /** * Saves the bucketing decision to the user profile * @param {Experiment} experiment @@ -505,31 +557,25 @@ export class DecisionService { * @param {ExperimentBucketMap} experimentBucketMap */ private saveUserProfile( - experiment: Experiment, - variation: Variation, userId: string, - experimentBucketMap: ExperimentBucketMap + userProfileTracker: UserProfileTracker ): void { - if (!this.userProfileService) { + const { userProfile, isProfileUpdated } = userProfileTracker; + + if (!this.userProfileService || !userProfile || !isProfileUpdated) { return; } try { - experimentBucketMap[experiment.id] = { - variation_id: variation.id - }; - this.userProfileService.save({ user_id: userId, - experiment_bucket_map: experimentBucketMap, + experiment_bucket_map: userProfile, }); this.logger.log( LOG_LEVEL.INFO, - LOG_MESSAGES.SAVED_VARIATION, + LOG_MESSAGES.SAVED_USER_VARIATION, MODULE_NAME, - variation.key, - experiment.key, userId, ); } catch (ex: any) { @@ -537,6 +583,74 @@ export class DecisionService { } } + /** + * Determines variations for the specified feature flags. + * + * @param {ProjectConfig} configObj - The parsed project configuration object. + * @param {FeatureFlag[]} featureFlags - The feature flags for which variations are to be determined. + * @param {OptimizelyUserContext} user - The user context associated with this decision. + * @param {Record} options - An optional map of decision options. + * @returns {DecisionResponse[]} - An array of DecisionResponse containing objects with + * experiment, variation, decisionSource properties, and decision reasons. + */ + getVariationsForFeatureList(configObj: ProjectConfig, + featureFlags: FeatureFlag[], + user: OptimizelyUserContext, + options: { [key: string]: boolean } = {}): DecisionResponse[] { + const userId = user.getUserId(); + const attributes = user.getAttributes(); + const decisions: DecisionResponse[] = []; + const userProfileTracker : UserProfileTracker = { + isProfileUpdated: false, + userProfile: null, + } + const shouldIgnoreUPS = options[OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE]; + + if(!shouldIgnoreUPS) { + userProfileTracker.userProfile = this.resolveExperimentBucketMap(userId, attributes); + } + + for(const feature of featureFlags) { + const decideReasons: (string | number)[][] = []; + const decisionVariation = this.getVariationForFeatureExperiment(configObj, feature, user, shouldIgnoreUPS, userProfileTracker); + decideReasons.push(...decisionVariation.reasons); + const experimentDecision = decisionVariation.result; + + if (experimentDecision.variation !== null) { + decisions.push({ + result: experimentDecision, + reasons: decideReasons, + }); + continue; + } + + const decisionRolloutVariation = this.getVariationForRollout(configObj, feature, user); + decideReasons.push(...decisionRolloutVariation.reasons); + const rolloutDecision = decisionRolloutVariation.result; + const userId = user.getUserId(); + + if (rolloutDecision.variation) { + this.logger.log(LOG_LEVEL.DEBUG, LOG_MESSAGES.USER_IN_ROLLOUT, MODULE_NAME, userId, feature.key); + decideReasons.push([LOG_MESSAGES.USER_IN_ROLLOUT, MODULE_NAME, userId, feature.key]); + } else { + this.logger.log(LOG_LEVEL.DEBUG, LOG_MESSAGES.USER_NOT_IN_ROLLOUT, MODULE_NAME, userId, feature.key); + decideReasons.push([LOG_MESSAGES.USER_NOT_IN_ROLLOUT, MODULE_NAME, userId, feature.key]); + } + + decisions.push({ + result: rolloutDecision, + reasons: decideReasons, + }); + } + + if(!shouldIgnoreUPS) { + this.saveUserProfile(userId, userProfileTracker) + } + + return decisions + + } + /** * Given a feature, user ID, and attributes, returns a decision response containing * an object representing a decision and decide reasons. If the user was bucketed into @@ -558,45 +672,15 @@ export class DecisionService { user: OptimizelyUserContext, options: { [key: string]: boolean } = {} ): DecisionResponse { - - const decideReasons: (string | number)[][] = []; - const decisionVariation = this.getVariationForFeatureExperiment(configObj, feature, user, options); - decideReasons.push(...decisionVariation.reasons); - const experimentDecision = decisionVariation.result; - - if (experimentDecision.variation !== null) { - return { - result: experimentDecision, - reasons: decideReasons, - }; - } - - const decisionRolloutVariation = this.getVariationForRollout(configObj, feature, user); - decideReasons.push(...decisionRolloutVariation.reasons); - const rolloutDecision = decisionRolloutVariation.result; - const userId = user.getUserId(); - if (rolloutDecision.variation) { - this.logger.log(LOG_LEVEL.DEBUG, LOG_MESSAGES.USER_IN_ROLLOUT, MODULE_NAME, userId, feature.key); - decideReasons.push([LOG_MESSAGES.USER_IN_ROLLOUT, MODULE_NAME, userId, feature.key]); - return { - result: rolloutDecision, - reasons: decideReasons, - }; - } - - this.logger.log(LOG_LEVEL.DEBUG, LOG_MESSAGES.USER_NOT_IN_ROLLOUT, MODULE_NAME, userId, feature.key); - decideReasons.push([LOG_MESSAGES.USER_NOT_IN_ROLLOUT, MODULE_NAME, userId, feature.key]); - return { - result: rolloutDecision, - reasons: decideReasons, - }; + return this.getVariationsForFeatureList(configObj, [feature], user, options)[0] } private getVariationForFeatureExperiment( configObj: ProjectConfig, feature: FeatureFlag, user: OptimizelyUserContext, - options: { [key: string]: boolean } = {} + shouldIgnoreUPS: boolean, + userProfileTracker: UserProfileTracker ): DecisionResponse { const decideReasons: (string | number)[][] = []; @@ -611,7 +695,7 @@ export class DecisionService { for (index = 0; index < feature.experimentIds.length; index++) { const experiment = getExperimentFromId(configObj, feature.experimentIds[index], this.logger); if (experiment) { - decisionVariation = this.getVariationFromExperimentRule(configObj, feature.key, experiment, user, options); + decisionVariation = this.getVariationFromExperimentRule(configObj, feature.key, experiment, user, shouldIgnoreUPS, userProfileTracker); decideReasons.push(...decisionVariation.reasons); variationKey = decisionVariation.result; if (variationKey) { @@ -1108,7 +1192,8 @@ export class DecisionService { flagKey: string, rule: Experiment, user: OptimizelyUserContext, - options: { [key: string]: boolean } = {} + shouldIgnoreUPS: boolean, + userProfileTracker: UserProfileTracker ): DecisionResponse { const decideReasons: (string | number)[][] = []; @@ -1123,7 +1208,7 @@ export class DecisionService { reasons: decideReasons, }; } - const decisionVariation = this.getVariation(configObj, rule, user, options); + const decisionVariation = this.resolveVariation(configObj, rule, user, shouldIgnoreUPS, userProfileTracker); decideReasons.push(...decisionVariation.reasons); const variationKey = decisionVariation.result; diff --git a/lib/optimizely/index.tests.js b/lib/optimizely/index.tests.js index 76b0816cb..af8b3fe08 100644 --- a/lib/optimizely/index.tests.js +++ b/lib/optimizely/index.tests.js @@ -5903,6 +5903,81 @@ describe('lib/optimizely', function() { assert.deepEqual(decision, expectedDecision); sinon.assert.calledTwice(eventDispatcher.dispatchEvent); }); + describe('UPS Batching', function() { + var userProfileServiceInstance = { + lookup: function() {}, + save: function() {}, + }; + beforeEach(function() { + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + datafile: testData.getTestDecideProjectConfig(), + userProfileService: userProfileServiceInstance, + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + isValidInstance: true, + eventBatchSize: 1, + defaultDecideOptions: [], + notificationCenter, + eventProcessor, + }); + + sinon.stub(optlyInstance.decisionService.userProfileService, 'lookup') + sinon.stub(optlyInstance.decisionService.userProfileService, 'save') + // + }); + + it('Should call UPS methods only once', function() { + var flagKeysArray = ['feature_1', 'feature_2']; + var user = optlyInstance.createUserContext(userId); + var expectedVariables1 = optlyInstance.getAllFeatureVariables(flagKeysArray[0], userId); + var expectedVariables2 = optlyInstance.getAllFeatureVariables(flagKeysArray[1], userId); + optlyInstance.decisionService.userProfileService.save.resetHistory(); + optlyInstance.decisionService.userProfileService.lookup.resetHistory(); + var decisionsMap = optlyInstance.decideForKeys(user, flagKeysArray); + var decision1 = decisionsMap[flagKeysArray[0]]; + var decision2 = decisionsMap[flagKeysArray[1]]; + var expectedDecision1 = { + variationKey: '18257766532', + enabled: true, + variables: expectedVariables1, + ruleKey: '18322080788', + flagKey: flagKeysArray[0], + userContext: user, + reasons: [], + }; + var expectedDecision2 = { + variationKey: 'variation_with_traffic', + enabled: true, + variables: expectedVariables2, + ruleKey: 'exp_no_audience', + flagKey: flagKeysArray[1], + userContext: user, + reasons: [], + }; + var userProfile = { + user_id: userId, + experiment_bucket_map: { + '10420810910': { // ruleKey from expectedDecision1 + variation_id: '10418551353' // variationKey from expectedDecision1 + } + } + }; + + assert.deepEqual(Object.values(decisionsMap).length, 2); + assert.deepEqual(decision1, expectedDecision1); + assert.deepEqual(decision2, expectedDecision2); + // UPS batch assertion + sinon.assert.calledOnce(optlyInstance.decisionService.userProfileService.lookup); + sinon.assert.calledOnce(optlyInstance.decisionService.userProfileService.save); + + // UPS save assertion + sinon.assert.calledWithExactly(optlyInstance.decisionService.userProfileService.save, userProfile); + }); + }) + }); describe('#decideAll', function() { @@ -6096,6 +6171,69 @@ describe('lib/optimizely', function() { sinon.assert.calledThrice(eventDispatcher.dispatchEvent); }); }); + + describe('UPS batching', function() { + beforeEach(function() { + var userProfileServiceInstance = { + lookup: function() {}, + save: function() {}, + }; + + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + datafile: testData.getTestDecideProjectConfig(), + userProfileService: userProfileServiceInstance, + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + isValidInstance: true, + eventBatchSize: 1, + defaultDecideOptions: [OptimizelyDecideOption.ENABLED_FLAGS_ONLY], + eventProcessor, + notificationCenter, + }); + + sinon.stub(optlyInstance.decisionService.userProfileService, 'lookup') + sinon.stub(optlyInstance.decisionService.userProfileService, 'save') + }); + + it('should call UPS methods only once', function() { + var flagKey1 = 'feature_1'; + var flagKey2 = 'feature_2'; + var user = optlyInstance.createUserContext(userId, { gender: 'female' }); + var decisionsMap = optlyInstance.decideAll(user, [OptimizelyDecideOption.EXCLUDE_VARIABLES]); + var decision1 = decisionsMap[flagKey1]; + var decision2 = decisionsMap[flagKey2]; + var expectedDecision1 = { + variationKey: '18257766532', + enabled: true, + variables: {}, + ruleKey: '18322080788', + flagKey: flagKey1, + userContext: user, + reasons: [], + }; + var expectedDecision2 = { + variationKey: 'variation_with_traffic', + enabled: true, + variables: {}, + ruleKey: 'exp_no_audience', + flagKey: flagKey2, + userContext: user, + reasons: [], + }; + + // Decision assertion + assert.deepEqual(Object.values(decisionsMap).length, 2); + assert.deepEqual(decision1, expectedDecision1); + assert.deepEqual(decision2, expectedDecision2); + + // UPS batch assertion + sinon.assert.calledOnce(optlyInstance.decisionService.userProfileService.lookup); + sinon.assert.calledOnce(optlyInstance.decisionService.userProfileService.save); + }) + }); }); }); diff --git a/lib/optimizely/index.ts b/lib/optimizely/index.ts index 5cd78be63..afc221b22 100644 --- a/lib/optimizely/index.ts +++ b/lib/optimizely/index.ts @@ -79,6 +79,8 @@ type InputKey = 'feature_key' | 'user_id' | 'variable_key' | 'experiment_key' | type StringInputs = Partial>; +type DecisionReasons = (string | number)[]; + export default class Optimizely implements Client { private isOptimizelyConfigValid: boolean; private disposeOnUpdate: (() => void) | null; @@ -1490,105 +1492,14 @@ export default class Optimizely implements Client { } decide(user: OptimizelyUserContext, key: string, options: OptimizelyDecideOption[] = []): OptimizelyDecision { - const userId = user.getUserId(); - const attributes = user.getAttributes(); const configObj = this.projectConfigManager.getConfig(); - const reasons: (string | number)[][] = []; - let decisionObj: DecisionObj; + if (!this.isValidInstance() || !configObj) { this.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'decide'); return newErrorDecision(key, user, [DECISION_MESSAGES.SDK_NOT_READY]); } - const feature = configObj.featureKeyMap[key]; - if (!feature) { - this.logger.log(LOG_LEVEL.ERROR, ERROR_MESSAGES.FEATURE_NOT_IN_DATAFILE, MODULE_NAME, key); - return newErrorDecision(key, user, [sprintf(DECISION_MESSAGES.FLAG_KEY_INVALID, key)]); - } - - const allDecideOptions = this.getAllDecideOptions(options); - - const forcedDecisionResponse = this.decisionService.findValidatedForcedDecision(configObj, user, key); - reasons.push(...forcedDecisionResponse.reasons); - const variation = forcedDecisionResponse.result; - if (variation) { - decisionObj = { - experiment: null, - variation: variation, - decisionSource: DECISION_SOURCES.FEATURE_TEST, - }; - } else { - const decisionVariation = this.decisionService.getVariationForFeature(configObj, feature, user, allDecideOptions); - reasons.push(...decisionVariation.reasons); - decisionObj = decisionVariation.result; - } - const decisionSource = decisionObj.decisionSource; - const experimentKey = decisionObj.experiment?.key ?? null; - const variationKey = decisionObj.variation?.key ?? null; - const flagEnabled: boolean = decision.getFeatureEnabledFromVariation(decisionObj); - if (flagEnabled === true) { - this.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.FEATURE_ENABLED_FOR_USER, MODULE_NAME, key, userId); - } else { - this.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.FEATURE_NOT_ENABLED_FOR_USER, MODULE_NAME, key, userId); - } - - const variablesMap: { [key: string]: unknown } = {}; - let decisionEventDispatched = false; - - if (!allDecideOptions[OptimizelyDecideOption.EXCLUDE_VARIABLES]) { - feature.variables.forEach(variable => { - variablesMap[variable.key] = this.getFeatureVariableValueFromVariation( - key, - flagEnabled, - decisionObj.variation, - variable, - userId - ); - }); - } - - if ( - !allDecideOptions[OptimizelyDecideOption.DISABLE_DECISION_EVENT] && - (decisionSource === DECISION_SOURCES.FEATURE_TEST || - (decisionSource === DECISION_SOURCES.ROLLOUT && projectConfig.getSendFlagDecisionsValue(configObj))) - ) { - this.sendImpressionEvent(decisionObj, key, userId, flagEnabled, attributes); - decisionEventDispatched = true; - } - - const shouldIncludeReasons = allDecideOptions[OptimizelyDecideOption.INCLUDE_REASONS]; - - let reportedReasons: string[] = []; - if (shouldIncludeReasons) { - reportedReasons = reasons.map(reason => sprintf(reason[0] as string, ...reason.slice(1))); - } - - const featureInfo = { - flagKey: key, - enabled: flagEnabled, - variationKey: variationKey, - ruleKey: experimentKey, - variables: variablesMap, - reasons: reportedReasons, - decisionEventDispatched: decisionEventDispatched, - }; - - this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.DECISION, { - type: DECISION_NOTIFICATION_TYPES.FLAG, - userId: userId, - attributes: attributes, - decisionInfo: featureInfo, - }); - - return { - variationKey: variationKey, - enabled: flagEnabled, - variables: variablesMap, - ruleKey: experimentKey, - flagKey: key, - userContext: user, - reasons: reportedReasons, - }; + return this.decideForKeys(user, [key], options, true)[key]; } /** @@ -1614,6 +1525,98 @@ export default class Optimizely implements Client { return allDecideOptions; } + /** + * Makes a decision for a given feature key. + * + * @param {OptimizelyUserContext} user - The user context associated with this Optimizely client. + * @param {string} key - The feature key for which a decision will be made. + * @param {DecisionObj} decisionObj - The decision object containing decision details. + * @param {DecisionReasons[]} reasons - An array of reasons for the decision. + * @param {Record} options - A map of options for decision-making. + * @param {projectConfig.ProjectConfig} configObj - The project configuration object. + * @returns {OptimizelyDecision} - The decision object for the feature flag. + */ + private generateDecision( + user: OptimizelyUserContext, + key: string, + decisionObj: DecisionObj, + reasons: DecisionReasons[], + options: Record, + configObj: projectConfig.ProjectConfig, + ): OptimizelyDecision { + const userId = user.getUserId() + const attributes = user.getAttributes() + const feature = configObj.featureKeyMap[key] + const decisionSource = decisionObj.decisionSource; + const experimentKey = decisionObj.experiment?.key ?? null; + const variationKey = decisionObj.variation?.key ?? null; + const flagEnabled: boolean = decision.getFeatureEnabledFromVariation(decisionObj); + const variablesMap: { [key: string]: unknown } = {}; + let decisionEventDispatched = false; + + if (flagEnabled) { + this.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.FEATURE_ENABLED_FOR_USER, MODULE_NAME, key, userId); + } else { + this.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.FEATURE_NOT_ENABLED_FOR_USER, MODULE_NAME, key, userId); + } + + + if (!options[OptimizelyDecideOption.EXCLUDE_VARIABLES]) { + feature.variables.forEach(variable => { + variablesMap[variable.key] = this.getFeatureVariableValueFromVariation( + key, + flagEnabled, + decisionObj.variation, + variable, + userId + ); + }); + } + + if ( + !options[OptimizelyDecideOption.DISABLE_DECISION_EVENT] && + (decisionSource === DECISION_SOURCES.FEATURE_TEST || + (decisionSource === DECISION_SOURCES.ROLLOUT && projectConfig.getSendFlagDecisionsValue(configObj))) + ) { + this.sendImpressionEvent(decisionObj, key, userId, flagEnabled, attributes); + decisionEventDispatched = true; + } + + const shouldIncludeReasons = options[OptimizelyDecideOption.INCLUDE_REASONS]; + + let reportedReasons: string[] = []; + if (shouldIncludeReasons) { + reportedReasons = reasons.map(reason => sprintf(reason[0] as string, ...reason.slice(1))); + } + + const featureInfo = { + flagKey: key, + enabled: flagEnabled, + variationKey: variationKey, + ruleKey: experimentKey, + variables: variablesMap, + reasons: reportedReasons, + decisionEventDispatched: decisionEventDispatched, + }; + + this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.DECISION, { + type: DECISION_NOTIFICATION_TYPES.FLAG, + userId: userId, + attributes: attributes, + decisionInfo: featureInfo, + }); + + return { + variationKey: variationKey, + enabled: flagEnabled, + variables: variablesMap, + ruleKey: experimentKey, + flagKey: key, + userContext: user, + reasons: reportedReasons, + }; + } + /** * Returns an object of decision results for multiple flag keys and a user context. * If the SDK finds an error for a key, the response will include a decision for the key showing reasons for the error. @@ -1626,10 +1629,18 @@ export default class Optimizely implements Client { decideForKeys( user: OptimizelyUserContext, keys: string[], - options: OptimizelyDecideOption[] = [] - ): { [key: string]: OptimizelyDecision } { - const decisionMap: { [key: string]: OptimizelyDecision } = {}; - if (!this.isValidInstance()) { + options: OptimizelyDecideOption[] = [], + ignoreEnabledFlagOption?:boolean + ): Record { + const decisionMap: Record = {}; + const flagDecisions: Record = {}; + const decisionReasonsMap: Record = {}; + const flagsWithoutForcedDecision = []; + const validKeys = []; + + const configObj = this.projectConfigManager.getConfig() + + if (!this.isValidInstance() || !configObj) { this.logger.log(LOG_LEVEL.ERROR, LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'decideForKeys'); return decisionMap; } @@ -1638,12 +1649,51 @@ export default class Optimizely implements Client { } const allDecideOptions = this.getAllDecideOptions(options); - keys.forEach(key => { - const optimizelyDecision: OptimizelyDecision = this.decide(user, key, options); - if (!allDecideOptions[OptimizelyDecideOption.ENABLED_FLAGS_ONLY] || optimizelyDecision.enabled) { - decisionMap[key] = optimizelyDecision; + + if (ignoreEnabledFlagOption) { + delete allDecideOptions[OptimizelyDecideOption.ENABLED_FLAGS_ONLY]; + } + + for(const key of keys) { + const feature = configObj.featureKeyMap[key]; + if (!feature) { + this.logger.log(LOG_LEVEL.ERROR, ERROR_MESSAGES.FEATURE_NOT_IN_DATAFILE, MODULE_NAME, key); + decisionMap[key] = newErrorDecision(key, user, [sprintf(DECISION_MESSAGES.FLAG_KEY_INVALID, key)]); + continue } - }); + + validKeys.push(key); + const forcedDecisionResponse = this.decisionService.findValidatedForcedDecision(configObj, user, key); + decisionReasonsMap[key] = forcedDecisionResponse.reasons + const variation = forcedDecisionResponse.result; + + if (variation) { + flagDecisions[key] = { + experiment: null, + variation: variation, + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }; + } else { + flagsWithoutForcedDecision.push(feature) + } + } + + const decisionList = this.decisionService.getVariationsForFeatureList(configObj, flagsWithoutForcedDecision, user, allDecideOptions); + + for(let i = 0; i < flagsWithoutForcedDecision.length; i++) { + const key = flagsWithoutForcedDecision[i].key; + const decision = decisionList[i]; + flagDecisions[key] = decision.result; + decisionReasonsMap[key] = [...decisionReasonsMap[key], ...decision.reasons]; + } + + for(const validKey of validKeys) { + const decision = this.generateDecision(user, validKey, flagDecisions[validKey], decisionReasonsMap[validKey], allDecideOptions, configObj); + + if(!allDecideOptions[OptimizelyDecideOption.ENABLED_FLAGS_ONLY] || decision.enabled) { + decisionMap[validKey] = decision; + } + } return decisionMap; } diff --git a/lib/optimizely_user_context/index.tests.js b/lib/optimizely_user_context/index.tests.js index 2b6ce0653..12da11aef 100644 --- a/lib/optimizely_user_context/index.tests.js +++ b/lib/optimizely_user_context/index.tests.js @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020-2023, Optimizely, Inc. and contributors * + * Copyright 2020-2024, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -35,7 +35,7 @@ describe('lib/optimizely_user_context', function() { describe('APIs', function() { var fakeOptimizely; var userId = 'tester'; - var options = 'fakeOption'; + var options = ['fakeOption']; describe('#setAttribute', function() { fakeOptimizely = { decide: sinon.stub().returns({}), diff --git a/lib/utils/enums/index.ts b/lib/utils/enums/index.ts index 962d06c30..211c230da 100644 --- a/lib/utils/enums/index.ts +++ b/lib/utils/enums/index.ts @@ -128,7 +128,8 @@ export const LOG_MESSAGES = { RETURNING_STORED_VARIATION: '%s: Returning previously activated variation "%s" of experiment "%s" for user "%s" from user profile.', ROLLOUT_HAS_NO_EXPERIMENTS: '%s: Rollout of feature %s has no experiments', - SAVED_VARIATION: '%s: Saved variation "%s" of experiment "%s" for user "%s".', + SAVED_USER_VARIATION: '%s: Saved user profile for user "%s".', + UPDATED_USER_VARIATION: '%s: Updated variation "%s" of experiment "%s" for user "%s".', SAVED_VARIATION_NOT_FOUND: '%s: User %s was previously bucketed into variation with ID %s for experiment %s, but no matching variation was found.', SHOULD_NOT_DISPATCH_ACTIVATE: '%s: Experiment %s is not in "Running" state. Not activating user.',