diff --git a/packages/optimizely-sdk/lib/core/decision_service/index.js b/packages/optimizely-sdk/lib/core/decision_service/index.js index d0daa6f9d..1ef78b311 100644 --- a/packages/optimizely-sdk/lib/core/decision_service/index.js +++ b/packages/optimizely-sdk/lib/core/decision_service/index.js @@ -303,12 +303,7 @@ DecisionService.prototype.getVariationForFeature = function(feature, userId, att } this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_NOT_IN_ROLLOUT, MODULE_NAME, userId, feature.key)); - - return { - experiment: null, - variation: null, - decisionSource: null, - }; + return rolloutDecision; }; DecisionService.prototype._getVariationForFeatureExperiment = function(feature, userId, attributes) { diff --git a/packages/optimizely-sdk/lib/core/decision_service/index.tests.js b/packages/optimizely-sdk/lib/core/decision_service/index.tests.js index 20693e4a5..a65c47133 100644 --- a/packages/optimizely-sdk/lib/core/decision_service/index.tests.js +++ b/packages/optimizely-sdk/lib/core/decision_service/index.tests.js @@ -843,12 +843,12 @@ describe('lib/core/decision_service', function() { getVariationStub.returns(null); }); - it('returns a decision with no variation', function() { + it('returns a decision with no variation and source rollout', function() { var decision = decisionServiceInstance.getVariationForFeature(feature, 'user1'); var expectedDecision = { experiment: null, variation: null, - decisionSource: null, + decisionSource: DECISION_SOURCES.ROLLOUT, }; assert.deepEqual(decision, expectedDecision); sinon.assert.calledWithExactly(mockLogger.log, LOG_LEVEL.DEBUG, 'DECISION_SERVICE: User user1 is not in any experiment on the feature test_feature_for_experiment.'); @@ -915,12 +915,12 @@ describe('lib/core/decision_service', function() { getVariationStub.returns(null); }); - it('returns a decision with no experiment and no variation', function() { + it('returns a decision with no experiment, no variation and source rollout', function() { var decision = decisionServiceInstance.getVariationForFeature(feature, 'user1'); var expectedDecision = { experiment: null, variation: null, - decisionSource: null, + decisionSource: DECISION_SOURCES.ROLLOUT, }; assert.deepEqual(decision, expectedDecision); sinon.assert.calledWithExactly(mockLogger.log, LOG_LEVEL.DEBUG, 'DECISION_SERVICE: User user1 is not in any experiment on the feature feature_with_group.'); @@ -932,7 +932,7 @@ describe('lib/core/decision_service', function() { var expectedDecision = { experiment: null, variation: null, - decisionSource: null, + decisionSource: DECISION_SOURCES.ROLLOUT, }; assert.deepEqual(decision, expectedDecision); sinon.assert.calledWithExactly(mockLogger.log, LOG_LEVEL.DEBUG, 'DECISION_SERVICE: User user1 is not in any experiment on the feature feature_exp_no_traffic.'); @@ -946,12 +946,12 @@ describe('lib/core/decision_service', function() { bucketUserIntoExperimentStub.returns(null); }); - it('returns a decision with no experiment and no variation', function() { + it('returns a decision with no experiment, no variation and source rollout', function() { var decision = decisionServiceInstance.getVariationForFeature(feature, 'user1'); var expectedDecision = { experiment: null, variation: null, - decisionSource: null, + decisionSource: DECISION_SOURCES.ROLLOUT, }; assert.deepEqual(decision, expectedDecision); sinon.assert.calledWithExactly(mockLogger.log, LOG_LEVEL.DEBUG, 'DECISION_SERVICE: User user1 is not in any experiment on the feature feature_with_group.'); @@ -1168,12 +1168,12 @@ describe('lib/core/decision_service', function() { bucketStub.returns(null); }); - it('returns a decision with no variation and no experiment', function() { + it('returns a decision with no variation, no experiment and source rollout', function() { var decision = decisionServiceInstance.getVariationForFeature(feature, 'user1'); var expectedDecision = { experiment: null, variation: null, - decisionSource: null, + decisionSource: DECISION_SOURCES.ROLLOUT, }; assert.deepEqual(decision, expectedDecision); sinon.assert.calledWithExactly(mockLogger.log, LOG_LEVEL.DEBUG, 'DECISION_SERVICE: User user1 does not meet conditions for targeting rule 1.'); @@ -1378,12 +1378,12 @@ describe('lib/core/decision_service', function() { feature = configObj.featureKeyMap.unused_flag; }); - it('returns a decision with no variation and no experiment', function() { + it('returns a decision with no variation, no experiment and source rollout', function() { var decision = decisionServiceInstance.getVariationForFeature(feature, 'user1'); var expectedDecision = { experiment: null, variation: null, - decisionSource: null, + decisionSource: DECISION_SOURCES.ROLLOUT, }; var expectedDecision = assert.deepEqual(decision, expectedDecision); sinon.assert.calledWithExactly(mockLogger.log, LOG_LEVEL.DEBUG, 'DECISION_SERVICE: Feature unused_flag is not attached to any experiments.'); diff --git a/packages/optimizely-sdk/lib/optimizely/index.js b/packages/optimizely-sdk/lib/optimizely/index.js index ed73c029b..f47159e41 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.js +++ b/packages/optimizely-sdk/lib/optimizely/index.js @@ -502,26 +502,46 @@ Optimizely.prototype.isFeatureEnabled = function(featureKey, userId, attributes) return false; } + var featureEnabled = false; + var experimentKey = null; + var variationKey = null; var decision = this.decisionService.getVariationForFeature(feature, userId, attributes); var variation = decision.variation; + if (!!variation) { + featureEnabled = variation.featureEnabled; if (decision.decisionSource === DECISION_SOURCES.EXPERIMENT) { + experimentKey = decision.experiment.key; + variationKey = decision.variation.key; // got a variation from the exp, so we track the impression this._sendImpressionEvent(decision.experiment.key, decision.variation.key, userId, attributes); } - if (variation.featureEnabled === true) { - this.logger.log( - LOG_LEVEL.INFO, - sprintf(LOG_MESSAGES.FEATURE_ENABLED_FOR_USER, MODULE_NAME, featureKey, userId) - ); - return true; - } } - this.logger.log( - LOG_LEVEL.INFO, - sprintf(LOG_MESSAGES.FEATURE_NOT_ENABLED_FOR_USER, MODULE_NAME, featureKey, userId) + + if (featureEnabled === true) { + this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.FEATURE_ENABLED_FOR_USER, MODULE_NAME, featureKey, userId)); + } else { + this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.FEATURE_NOT_ENABLED_FOR_USER, MODULE_NAME, featureKey, userId)); + featureEnabled = false; + } + + this.notificationCenter.sendNotifications( + enums.NOTIFICATION_TYPES.DECISION, + { + type: DECISION_INFO_TYPES.FEATURE, + userId: userId, + attributes: attributes || {}, + decisionInfo: { + featureKey: featureKey, + featureEnabled: featureEnabled, + source: decision.decisionSource, + sourceExperimentKey: experimentKey, + sourceVariationKey: variationKey, + } + } ); - return false; + + return featureEnabled; } catch (e) { this.logger.log(LOG_LEVEL.ERROR, e.message); this.errorHandler.handleError(e); diff --git a/packages/optimizely-sdk/lib/optimizely/index.tests.js b/packages/optimizely-sdk/lib/optimizely/index.tests.js index 7146a3f99..6b8b74bfd 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.tests.js +++ b/packages/optimizely-sdk/lib/optimizely/index.tests.js @@ -2340,13 +2340,27 @@ describe('lib/optimizely', function() { var decisionListener; beforeEach(function() { decisionListener = sinon.spy(); - optlyInstance.notificationCenter.addNotificationListener( - enums.NOTIFICATION_TYPES.DECISION, - decisionListener - ); }); describe('activate', function() { + beforeEach(function() { + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + datafile: testData.getTestProjectConfig(), + eventBuilder: eventBuilder, + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + isValidInstance: true, + }); + + optlyInstance.notificationCenter.addNotificationListener( + enums.NOTIFICATION_TYPES.DECISION, + decisionListener + ); + }); + it('should send notification with actual variation key when activate returns variation', function() { bucketStub.returns('111129'); var variation = optlyInstance.activate('testExperiment', 'testUser'); @@ -2379,6 +2393,24 @@ describe('lib/optimizely', function() { }); describe('getVariation', function() { + beforeEach(function() { + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + datafile: testData.getTestProjectConfig(), + eventBuilder: eventBuilder, + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + isValidInstance: true, + }); + + optlyInstance.notificationCenter.addNotificationListener( + enums.NOTIFICATION_TYPES.DECISION, + decisionListener + ); + }); + it('should send notification with actual variation key when getVariation returns variation', function() { bucketStub.returns('111129'); var variation = optlyInstance.getVariation('testExperiment', 'testUser'); @@ -2408,6 +2440,192 @@ describe('lib/optimizely', function() { }); }); }); + + describe('feature management', function() { + var sandbox = sinon.sandbox.create(); + + beforeEach(function() { + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + datafile: testData.getTestProjectConfigWithFeatures(), + eventBuilder: eventBuilder, + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + isValidInstance: true, + }); + + optlyInstance.notificationCenter.addNotificationListener( + enums.NOTIFICATION_TYPES.DECISION, + decisionListener + ); + }); + + afterEach(function() { + sandbox.restore(); + }); + + describe('isFeatureEnabled', function() { + describe('when the user bucketed into a variation of an experiment of the feature', function() { + var attributes = { test_attribute: 'test_value' }; + + describe('when the variation is toggled ON', function() { + beforeEach(function() { + var experiment = optlyInstance.configObj.experimentKeyMap.testing_my_feature; + var variation = experiment.variations[0]; + sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ + experiment: experiment, + variation: variation, + decisionSource: DECISION_SOURCES.EXPERIMENT, + }); + }); + + it('should return true and send notification', function() { + var result = optlyInstance.isFeatureEnabled('test_feature_for_experiment', 'user1', attributes); + assert.strictEqual(result, true); + sinon.assert.calledWith(decisionListener, { + type: DECISION_INFO_TYPES.FEATURE, + userId: 'user1', + attributes: attributes, + decisionInfo: { + featureKey: 'test_feature_for_experiment', + featureEnabled: true, + source: DECISION_SOURCES.EXPERIMENT, + sourceExperimentKey: 'testing_my_feature', + sourceVariationKey: 'variation' + } + }); + }); + }); + + describe('when the variation is toggled OFF', function() { + beforeEach(function() { + var experiment = optlyInstance.configObj.experimentKeyMap.test_shared_feature; + var variation = experiment.variations[1]; + sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ + experiment: experiment, + variation: variation, + decisionSource: DECISION_SOURCES.EXPERIMENT, + }); + }); + + it('should return false and send notification', function() { + var result = optlyInstance.isFeatureEnabled('shared_feature', 'user1', attributes); + assert.strictEqual(result, false); + sinon.assert.calledWith(decisionListener, { + type: DECISION_INFO_TYPES.FEATURE, + userId: 'user1', + attributes: attributes, + decisionInfo: { + featureKey: 'shared_feature', + featureEnabled: false, + source: DECISION_SOURCES.EXPERIMENT, + sourceExperimentKey: 'test_shared_feature', + sourceVariationKey: 'control' + } + }); + }); + }); + }); + + describe('user bucketed into a variation of a rollout of the feature', function() { + describe('when the variation is toggled ON', function() { + beforeEach(function() { + // This experiment is the first audience targeting rule in the rollout of feature 'test_feature' + var experiment = optlyInstance.configObj.experimentKeyMap['594031']; + var variation = experiment.variations[0]; + sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ + experiment: experiment, + variation: variation, + decisionSource: DECISION_SOURCES.ROLLOUT, + }); + }); + + it('should return true and send notification', function() { + var result = optlyInstance.isFeatureEnabled('test_feature', 'user1', { + test_attribute: 'test_value', + }); + assert.strictEqual(result, true); + sinon.assert.calledWith(decisionListener, { + type: DECISION_INFO_TYPES.FEATURE, + userId: 'user1', + attributes: { test_attribute: 'test_value' }, + decisionInfo: { + featureKey: 'test_feature', + featureEnabled: true, + source: DECISION_SOURCES.ROLLOUT, + sourceExperimentKey: null, + sourceVariationKey: null + } + }); + }); + }); + + describe('when the variation is toggled OFF', function() { + beforeEach(function() { + // This experiment is the second audience targeting rule in the rollout of feature 'test_feature' + var experiment = optlyInstance.configObj.experimentKeyMap['594037']; + var variation = experiment.variations[0]; + sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ + experiment: experiment, + variation: variation, + decisionSource: DECISION_SOURCES.ROLLOUT, + }); + }); + + it('returns false and send notification', function() { + var result = optlyInstance.isFeatureEnabled('test_feature', 'user1', { + test_attribute: 'test_value', + }); + assert.strictEqual(result, false); + sinon.assert.calledWith(createdLogger.log, LOG_LEVEL.INFO, 'OPTIMIZELY: Feature test_feature is not enabled for user user1.'); + + var expectedArguments = { + type: DECISION_INFO_TYPES.FEATURE, + userId: 'user1', + attributes: { test_attribute: 'test_value' }, + decisionInfo: { + featureKey: 'test_feature', + featureEnabled: false, + source: DECISION_SOURCES.ROLLOUT, + sourceExperimentKey: null, + sourceVariationKey: null + } + }; + sinon.assert.calledWith(decisionListener, expectedArguments); + }); + }); + }); + + describe('user not bucketed into an experiment or a rollout', function() { + beforeEach(function() { + sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ + experiment: null, + variation: null, + decisionSource: DECISION_SOURCES.ROLLOUT, + }); + }); + + it('returns false and send notification', function() { + var result = optlyInstance.isFeatureEnabled('test_feature', 'user1'); + assert.strictEqual(result, false); + sinon.assert.calledWith(decisionListener, { + type: DECISION_INFO_TYPES.FEATURE, + userId: 'user1', + attributes: {}, + decisionInfo: { + featureKey: 'test_feature', + featureEnabled: false, + source: DECISION_SOURCES.ROLLOUT, + sourceExperimentKey: null, + sourceVariationKey: null + } + }); + }); + }); + }); + }); }); }); }); @@ -2480,6 +2698,7 @@ describe('lib/optimizely', function() { }); var optlyInstance; var clock; + beforeEach(function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', @@ -2555,6 +2774,7 @@ describe('lib/optimizely', function() { 'user1', attributes ); + sinon.assert.calledOnce(eventDispatcher.dispatchEvent); var expectedImpressionEvent = { 'httpVerb': 'POST', @@ -2845,7 +3065,7 @@ describe('lib/optimizely', function() { }); }); - it('returns false ', function() { + it('returns false', function() { var result = optlyInstance.isFeatureEnabled('test_feature', 'user1', { test_attribute: 'test_value', }); @@ -2860,7 +3080,7 @@ describe('lib/optimizely', function() { sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: null, variation: null, - decisionSource: null, + decisionSource: DECISION_SOURCES.ROLLOUT, }); }); @@ -2948,6 +3168,111 @@ describe('lib/optimizely', function() { attributes ); }); + + it('return features that are enabled for the user and send notification for every feature', function() { + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + datafile: testData.getTestProjectConfigWithFeatures(), + eventBuilder: eventBuilder, + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + isValidInstance: true, + }); + + var decisionListener = sinon.spy(); + var attributes = { test_attribute: 'test_value' }; + optlyInstance.notificationCenter.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionListener); + var result = optlyInstance.getEnabledFeatures('test_user', attributes); + assert.strictEqual(result.length, 3); + assert.deepEqual(result, ['test_feature_2', 'test_feature_for_experiment', 'shared_feature']); + + sinon.assert.calledWithExactly(decisionListener.getCall(0), { + type: DECISION_INFO_TYPES.FEATURE, + userId: 'test_user', + attributes: attributes, + decisionInfo: { + featureKey: 'test_feature', + featureEnabled: false, + source: DECISION_SOURCES.ROLLOUT, + sourceExperimentKey: null, + sourceVariationKey: null + } + }); + sinon.assert.calledWithExactly(decisionListener.getCall(1), { + type: DECISION_INFO_TYPES.FEATURE, + userId: 'test_user', + attributes: attributes, + decisionInfo: { + featureKey: 'test_feature_2', + featureEnabled: true, + source: DECISION_SOURCES.ROLLOUT, + sourceExperimentKey: null, + sourceVariationKey: null + } + }); + sinon.assert.calledWithExactly(decisionListener.getCall(2), { + type: DECISION_INFO_TYPES.FEATURE, + userId: 'test_user', + attributes: attributes, + decisionInfo: { + featureKey: 'test_feature_for_experiment', + featureEnabled: true, + source: DECISION_SOURCES.EXPERIMENT, + sourceExperimentKey: 'testing_my_feature', + sourceVariationKey: 'variation' + } + }); + sinon.assert.calledWithExactly(decisionListener.getCall(3), { + type: DECISION_INFO_TYPES.FEATURE, + userId: 'test_user', + attributes: attributes, + decisionInfo: { + featureKey: 'feature_with_group', + featureEnabled: false, + source: DECISION_SOURCES.ROLLOUT, + sourceExperimentKey: null, + sourceVariationKey: null + } + }); + sinon.assert.calledWithExactly(decisionListener.getCall(4), { + type: DECISION_INFO_TYPES.FEATURE, + userId: 'test_user', + attributes: attributes, + decisionInfo: { + featureKey: 'shared_feature', + featureEnabled: true, + source: DECISION_SOURCES.EXPERIMENT, + sourceExperimentKey: 'test_shared_feature', + sourceVariationKey: 'treatment' + } + }); + sinon.assert.calledWithExactly(decisionListener.getCall(5), { + type: DECISION_INFO_TYPES.FEATURE, + userId: 'test_user', + attributes: attributes, + decisionInfo: { + featureKey: 'unused_flag', + featureEnabled: false, + source: DECISION_SOURCES.ROLLOUT, + sourceExperimentKey: null, + sourceVariationKey: null + } + }); + sinon.assert.calledWithExactly(decisionListener.getCall(6), { + type: DECISION_INFO_TYPES.FEATURE, + userId: 'test_user', + attributes: attributes, + decisionInfo: { + featureKey: 'feature_exp_no_traffic', + featureEnabled: false, + source: DECISION_SOURCES.ROLLOUT, + sourceExperimentKey: null, + sourceVariationKey: null + } + }); + }); }); describe('feature variable APIs', function() { diff --git a/packages/optimizely-sdk/lib/utils/enums/index.js b/packages/optimizely-sdk/lib/utils/enums/index.js index 2ccbc5ead..726df335a 100644 --- a/packages/optimizely-sdk/lib/utils/enums/index.js +++ b/packages/optimizely-sdk/lib/utils/enums/index.js @@ -190,6 +190,7 @@ exports.NOTIFICATION_TYPES = { exports.DECISION_INFO_TYPES = { EXPERIMENT: 'experiment', + FEATURE: 'feature', }; /* @@ -199,8 +200,8 @@ exports.DECISION_INFO_TYPES = { * Optimizely. */ exports.DECISION_SOURCES = { - EXPERIMENT: 'experiment', - ROLLOUT: 'rollout', + EXPERIMENT: 'EXPERIMENT', + ROLLOUT: 'ROLLOUT', }; /*