Skip to content

feat(AudienceEvaluator): Add the ability to provide custom condition evaluators #288

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jun 19, 2019
112 changes: 73 additions & 39 deletions packages/optimizely-sdk/lib/core/audience_evaluator/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,54 +16,88 @@
var conditionTreeEvaluator = require('../condition_tree_evaluator');
var customAttributeConditionEvaluator = require('../custom_attribute_condition_evaluator');
var enums = require('../../utils/enums');
var fns = require('../../utils/fns');
var sprintf = require('@optimizely/js-sdk-utils').sprintf;

var ERROR_MESSAGES = enums.ERROR_MESSAGES;
var LOG_LEVEL = enums.LOG_LEVEL;
var LOG_MESSAGES = enums.LOG_MESSAGES;
var MODULE_NAME = 'AUDIENCE_EVALUATOR';

module.exports = {
/**
* Determine if the given user attributes satisfy the given audience conditions
* @param {Array|String|null|undefined} audienceConditions Audience conditions to match the user attributes against - can be an array
* of audience IDs, a nested array of conditions, or a single leaf condition.
* Examples: ["5", "6"], ["and", ["or", "1", "2"], "3"], "1"
* @param {Object} audiencesById Object providing access to full audience objects for audience IDs
* contained in audienceConditions. Keys should be audience IDs, values
* should be full audience objects with conditions properties
* @param {Object} [userAttributes] User attributes which will be used in determining if audience conditions
* are met. If not provided, defaults to an empty object
* @param {Object} logger Logger instance.
* @return {Boolean} true if the user attributes match the given audience conditions, false
* otherwise
*/
evaluate: function(audienceConditions, audiencesById, userAttributes, logger) {
// if there are no audiences, return true because that means ALL users are included in the experiment
if (!audienceConditions || audienceConditions.length === 0) {
return true;
}

if (!userAttributes) {
userAttributes = {};
}
/**
* Construct an instance of AudienceEvaluator with a given logger and options
* @param {Logger} logger The Logger instance
* @param {Object=} UNSTABLE_conditionEvaluators A map of condition evaluators provided by the consumer. This enables matching
* condition types which are not supported natively by the SDK. Note that built in
* Optimizely evaluators cannot be overridden.
* @constructor
*/
function AudienceEvaluator(logger, UNSTABLE_conditionEvaluators) {
this.logger = logger;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of passing logger, we can import a singleton logger and use it directly (example from project_config_manager)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

this.typeToEvaluatorMap = fns.assignIn({}, UNSTABLE_conditionEvaluators, {
'custom_attribute': customAttributeConditionEvaluator
});
}

var evaluateConditionWithUserAttributes = function(condition) {
return customAttributeConditionEvaluator.evaluate(condition, userAttributes, logger);
};
/**
* Determine if the given user attributes satisfy the given audience conditions
* @param {Array|String|null|undefined} audienceConditions Audience conditions to match the user attributes against - can be an array
* of audience IDs, a nested array of conditions, or a single leaf condition.
* Examples: ["5", "6"], ["and", ["or", "1", "2"], "3"], "1"
* @param {Object} audiencesById Object providing access to full audience objects for audience IDs
* contained in audienceConditions. Keys should be audience IDs, values
* should be full audience objects with conditions properties
* @param {Object} [userAttributes] User attributes which will be used in determining if audience conditions
* are met. If not provided, defaults to an empty object
* @return {Boolean} true if the user attributes match the given audience conditions, false
* otherwise
*/
AudienceEvaluator.prototype.evaluate = function(audienceConditions, audiencesById, userAttributes) {
// if there are no audiences, return true because that means ALL users are included in the experiment
if (!audienceConditions || audienceConditions.length === 0) {
return true;
}

var evaluateAudience = function(audienceId) {
var audience = audiencesById[audienceId];
if (audience) {
logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.EVALUATING_AUDIENCE, MODULE_NAME, audienceId, JSON.stringify(audience.conditions)));
var result = conditionTreeEvaluator.evaluate(audience.conditions, evaluateConditionWithUserAttributes);
var resultText = result === null ? 'UNKNOWN' : result.toString().toUpperCase();
logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.AUDIENCE_EVALUATION_RESULT, MODULE_NAME, audienceId, resultText));
return result;
}
if (!userAttributes) {
userAttributes = {};
}

return null;
};
var evaluateAudience = function(audienceId) {
var audience = audiencesById[audienceId];
if (audience) {
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.EVALUATING_AUDIENCE, MODULE_NAME, audienceId, JSON.stringify(audience.conditions)));
var result = conditionTreeEvaluator.evaluate(audience.conditions, this.evaluateConditionWithUserAttributes.bind(this, userAttributes));
var resultText = result === null ? 'UNKNOWN' : result.toString().toUpperCase();
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.AUDIENCE_EVALUATION_RESULT, MODULE_NAME, audienceId, resultText));
return result;
}

return conditionTreeEvaluator.evaluate(audienceConditions, evaluateAudience) || false;
},
return null;
}.bind(this);

return conditionTreeEvaluator.evaluate(audienceConditions, evaluateAudience) || false;
};

/**
* Wrapper around evaluator.evaluate that is passed to the conditionTreeEvaluator.
* Evaluates the condition provided given the user attributes if an evaluator has been defined for the condition type.
* @param {Object} userAttributes A map of user attributes.
* @param {Object} condition A single condition object to evaluate.
* @return {Boolean|null} true if the condition is satisfied, null if a matcher is not found.
*/
AudienceEvaluator.prototype.evaluateConditionWithUserAttributes = function(userAttributes, condition) {
var evaluator = this.typeToEvaluatorMap[condition.type];
if (!evaluator) {
this.logger.log(LOG_LEVEL.WARNING, sprintf(LOG_MESSAGES.UNKNOWN_CONDITION_TYPE, MODULE_NAME, JSON.stringify(condition)));
return null;
}
try {
return evaluator.evaluate(condition, userAttributes, this.logger);
} catch (err) {
this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.CONDITION_EVALUATOR_ERROR, MODULE_NAME, condition.type, err.message));
}
return null;
};

module.exports = AudienceEvaluator;
37 changes: 20 additions & 17 deletions packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
var audienceEvaluator = require('./');
var AudienceEvaluator = require('./');
var chai = require('chai');
var sprintf = require('@optimizely/js-sdk-utils').sprintf;
var conditionTreeEvaluator = require('../condition_tree_evaluator');
var customAttributeConditionEvaluator = require('../custom_attribute_condition_evaluator');
var sinon = require('sinon');
Expand Down Expand Up @@ -53,10 +52,14 @@ var audiencesById = {
};

describe('lib/core/audience_evaluator', function() {
var audienceEvaluator;
var mockLogger = logger.createLogger({logLevel: LOG_LEVEL.INFO});
beforeEach(function() {
audienceEvaluator = new AudienceEvaluator(mockLogger);
});

describe('APIs', function() {
describe('evaluate', function() {
var mockLogger = logger.createLogger({logLevel: LOG_LEVEL.INFO});

beforeEach(function () {
sinon.stub(mockLogger, 'log');
});
Expand All @@ -66,11 +69,11 @@ describe('lib/core/audience_evaluator', function() {
});

it('should return true if there are no audiences', function() {
assert.isTrue(audienceEvaluator.evaluate([], audiencesById, {}, mockLogger));
assert.isTrue(audienceEvaluator.evaluate([], audiencesById, {}));
});

it('should return false if there are audiences but no attributes', function() {
assert.isFalse(audienceEvaluator.evaluate(['0'], audiencesById, {}, mockLogger));
assert.isFalse(audienceEvaluator.evaluate(['0'], audiencesById, {}));
});

it('should return true if any of the audience conditions are met', function() {
Expand All @@ -87,9 +90,9 @@ describe('lib/core/audience_evaluator', function() {
'device_model': 'iphone',
};

assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, iphoneUsers, mockLogger));
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, chromeUsers, mockLogger));
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, iphoneChromeUsers, mockLogger));
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, iphoneUsers));
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, chromeUsers));
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, iphoneChromeUsers));
});

it('should return false if none of the audience conditions are met', function() {
Expand All @@ -106,13 +109,13 @@ describe('lib/core/audience_evaluator', function() {
'device_model': 'nexus5',
};

assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, nexusUsers, mockLogger));
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, safariUsers, mockLogger));
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, nexusSafariUsers, mockLogger));
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, nexusUsers));
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, safariUsers));
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, nexusSafariUsers));
});

it('should return true if no attributes are passed and the audience conditions evaluate to true in the absence of attributes', function() {
assert.isTrue(audienceEvaluator.evaluate(['2'], audiencesById, null, mockLogger));
assert.isTrue(audienceEvaluator.evaluate(['2'], audiencesById, null));
});

describe('complex audience conditions', function() {
Expand Down Expand Up @@ -199,7 +202,7 @@ describe('lib/core/audience_evaluator', function() {
});
customAttributeConditionEvaluator.evaluate.returns(false);
var userAttributes = { device_model: 'android' };
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes, mockLogger);
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
assert.isFalse(result);
Expand All @@ -224,7 +227,7 @@ describe('lib/core/audience_evaluator', function() {
});
customAttributeConditionEvaluator.evaluate.returns(null);
var userAttributes = { device_model: 5.5 };
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes, mockLogger);
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
assert.isFalse(result);
Expand All @@ -239,7 +242,7 @@ describe('lib/core/audience_evaluator', function() {
});
customAttributeConditionEvaluator.evaluate.returns(true);
var userAttributes = { device_model: 'iphone' };
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes, mockLogger);
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
assert.isTrue(result);
Expand All @@ -254,7 +257,7 @@ describe('lib/core/audience_evaluator', function() {
});
customAttributeConditionEvaluator.evaluate.returns(false);
var userAttributes = { device_model: 'android' };
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes, mockLogger);
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
assert.isFalse(result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,6 @@ EVALUATORS_BY_MATCH_TYPE[SUBSTRING_MATCH_TYPE] = substringEvaluator;
* null if the given user attributes and condition can't be evaluated
*/
function evaluate(condition, userAttributes, logger) {
if (condition.type !== CUSTOM_ATTRIBUTE_CONDITION_TYPE) {
logger.log(LOG_LEVEL.WARNING, sprintf(LOG_MESSAGES.UNKNOWN_CONDITION_TYPE, MODULE_NAME, JSON.stringify(condition)));
return null;
}

var conditionMatch = condition.match;
if (typeof conditionMatch !== 'undefined' && MATCH_TYPES.indexOf(conditionMatch) === -1) {
logger.log(LOG_LEVEL.WARNING, sprintf(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, MODULE_NAME, JSON.stringify(condition)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,42 +85,6 @@ describe('lib/core/custom_attribute_condition_evaluator', function() {
assert.isTrue(customAttributeEvaluator.evaluate(doubleCondition, userAttributes, mockLogger));
});

it('should log and return null when condition has an invalid type property', function() {
var result = customAttributeEvaluator.evaluate(
{ match: 'exact', name: 'weird_condition', type: 'weird', value: 'hi' },
{ weird_condition: 'bye' },
mockLogger
);
assert.isNull(result);
sinon.assert.calledOnce(mockLogger.log);
sinon.assert.calledWithExactly(mockLogger.log, LOG_LEVEL.WARNING,
'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"exact","name":"weird_condition","type":"weird","value":"hi"} has an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.');
});

it('should log and return null when condition has no type property', function() {
var result = customAttributeEvaluator.evaluate(
{ match: 'exact', name: 'weird_condition', value: 'hi' },
{ weird_condition: 'bye' },
mockLogger
);
assert.isNull(result);
sinon.assert.calledOnce(mockLogger.log);
sinon.assert.calledWithExactly(mockLogger.log, LOG_LEVEL.WARNING,
'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"exact","name":"weird_condition","value":"hi"} has an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.');
});

it('should log and return null when condition has an invalid match property', function() {
var result = customAttributeEvaluator.evaluate(
{ match: 'weird', name: 'weird_condition', type: 'custom_attribute', value: 'hi' },
{ weird_condition: 'bye' },
mockLogger
);
assert.isNull(result);
sinon.assert.calledOnce(mockLogger.log);
sinon.assert.calledWithExactly(mockLogger.log, LOG_LEVEL.WARNING,
'CUSTOM_ATTRIBUTE_CONDITION_EVALUATOR: Audience condition {"match":"weird","name":"weird_condition","type":"custom_attribute","value":"hi"} uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK.');
});

describe('exists match type', function() {
var existsCondition = {
match: 'exists',
Expand Down
9 changes: 5 additions & 4 deletions packages/optimizely-sdk/lib/core/decision_service/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License. *
***************************************************************************/

var audienceEvaluator = require('../audience_evaluator');
var AudienceEvaluator = require('../audience_evaluator');
var bucketer = require('../bucketer');
var enums = require('../../utils/enums');
var fns = require('../../utils/fns');
Expand Down Expand Up @@ -49,9 +49,10 @@ var DECISION_SOURCES = enums.DECISION_SOURCES;
* @returns {Object}
*/
function DecisionService(options) {
this.userProfileService = options.userProfileService || null;
this.logger = options.logger;
this.audienceEvaluator = new AudienceEvaluator(options.logger, options.UNSTABLE_conditionEvaluators);
this.forcedVariationMap = {};
this.logger = options.logger;
this.userProfileService = options.userProfileService || null;
}

/**
Expand Down Expand Up @@ -171,7 +172,7 @@ DecisionService.prototype.__checkIfUserIsInAudience = function(configObj, experi
var experimentAudienceConditions = projectConfig.getExperimentAudienceConditions(configObj, experimentKey);
var audiencesById = projectConfig.getAudiencesById(configObj);
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.EVALUATING_AUDIENCES_COMBINED, MODULE_NAME, experimentKey, JSON.stringify(experimentAudienceConditions)));
var result = audienceEvaluator.evaluate(experimentAudienceConditions, audiencesById, attributes, this.logger);
var result = this.audienceEvaluator.evaluate(experimentAudienceConditions, audiencesById, attributes);
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.AUDIENCE_EVALUATION_RESULT_COMBINED, MODULE_NAME, experimentKey, result.toString().toUpperCase()));

if (!result) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ var sprintf = require('@optimizely/js-sdk-utils').sprintf;
var testData = require('../../tests/test_data').getTestProjectConfig();
var testDataWithFeatures = require('../../tests/test_data').getTestProjectConfigWithFeatures();
var jsonSchemaValidator = require('../../utils/json_schema_validator');
var audienceEvaluator = require('../audience_evaluator');
var AudienceEvaluator = require('../audience_evaluator');

var chai = require('chai');
var sinon = require('sinon');
Expand Down Expand Up @@ -420,7 +420,7 @@ describe('lib/core/decision_service', function() {
var __audienceEvaluateSpy;

beforeEach(function() {
__audienceEvaluateSpy = sinon.spy(audienceEvaluator, 'evaluate');
__audienceEvaluateSpy = sinon.spy(AudienceEvaluator.prototype, 'evaluate');
});

afterEach(function() {
Expand Down
1 change: 1 addition & 0 deletions packages/optimizely-sdk/lib/optimizely/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ function Optimizely(config) {
this.decisionService = decisionService.createDecisionService({
userProfileService: userProfileService,
logger: this.logger,
UNSTABLE_conditionEvaluators: config.UNSTABLE_conditionEvaluators
});

this.notificationCenter = notificationCenter.createNotificationCenter({
Expand Down
Loading