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 logging = require('@optimizely/js-sdk-logging');
var logger = logging.getLogger();

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 given options
* @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(UNSTABLE_conditionEvaluators) {
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) {
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();
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) {
logger.log(LOG_LEVEL.WARNING, sprintf(LOG_MESSAGES.UNKNOWN_CONDITION_TYPE, MODULE_NAME, JSON.stringify(condition)));
return null;
}
try {
return evaluator.evaluate(condition, userAttributes, logger);
} catch (err) {
logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.CONDITION_EVALUATOR_ERROR, MODULE_NAME, condition.type, err.message));
}
return null;
};

module.exports = AudienceEvaluator;
61 changes: 30 additions & 31 deletions packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@
* 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');
var assert = chai.assert;
var logger = require('../../plugins/logger');
var logging = require('@optimizely/js-sdk-logging');
var mockLogger = logging.getLogger();
var enums = require('../../utils/enums');
var LOG_LEVEL = enums.LOG_LEVEL;

Expand Down Expand Up @@ -53,11 +53,14 @@ var audiencesById = {
};

describe('lib/core/audience_evaluator', function() {
var audienceEvaluator;
beforeEach(function() {
audienceEvaluator = new AudienceEvaluator();
});

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

beforeEach(function () {
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,22 +109,21 @@ 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() {
it('should return true if any of the audiences in an "OR" condition pass', function() {
var result = audienceEvaluator.evaluate(
['or', '0', '1'],
audiencesById,
{ browser_type: 'chrome' },
mockLogger
{ browser_type: 'chrome' }
);
assert.isTrue(result);
});
Expand All @@ -130,8 +132,7 @@ describe('lib/core/audience_evaluator', function() {
var result = audienceEvaluator.evaluate(
['and', '0', '1'],
audiencesById,
{ browser_type: 'chrome', device_model: 'iphone' },
mockLogger
{ browser_type: 'chrome', device_model: 'iphone' }
);
assert.isTrue(result);
});
Expand All @@ -140,8 +141,7 @@ describe('lib/core/audience_evaluator', function() {
var result = audienceEvaluator.evaluate(
['not', '1'],
audiencesById,
{ device_model: 'android' },
mockLogger
{ device_model: 'android' }
);
assert.isTrue(result);
});
Expand All @@ -165,8 +165,7 @@ describe('lib/core/audience_evaluator', function() {
var result = audienceEvaluator.evaluate(
['or', '0', '1'],
audiencesById,
{ browser_type: 'chrome' },
mockLogger
{ browser_type: 'chrome' }
);
assert.isTrue(result);
});
Expand All @@ -176,8 +175,7 @@ describe('lib/core/audience_evaluator', function() {
var result = audienceEvaluator.evaluate(
['or', '0', '1'],
audiencesById,
{ browser_type: 'safari' },
mockLogger
{ browser_type: 'safari' }
);
assert.isFalse(result);
});
Expand All @@ -187,8 +185,7 @@ describe('lib/core/audience_evaluator', function() {
var result = audienceEvaluator.evaluate(
['or', '0', '1'],
audiencesById,
{ state: 'California' },
mockLogger
{ state: 'California' }
);
assert.isFalse(result);
});
Expand All @@ -199,8 +196,9 @@ 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);
console.log('args: ', customAttributeConditionEvaluator.evaluate.firstCall.args)
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
assert.isFalse(result);
});
Expand All @@ -224,7 +222,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 +237,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,8 +252,9 @@ 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);
console.log('args: ', customAttributeConditionEvaluator.evaluate.firstCall.args)
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
assert.isFalse(result);
assert.strictEqual(2, mockLogger.log.callCount);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,9 @@ EVALUATORS_BY_MATCH_TYPE[SUBSTRING_MATCH_TYPE] = substringEvaluator;
* @param {Object} logger
* @return {?Boolean} true/false if the given user attributes match/don't match the given condition,
* null if the given user attributes and condition can't be evaluated
* TODO: Change to accept and object with named properties
*/
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
Loading