Skip to content
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