Skip to content

Commit 2ff9d2e

Browse files
author
James Fox
committed
add the ability to provide custom condition evaluators
rebase with latest audience editor changes updates per code review
1 parent 6d8cceb commit 2ff9d2e

File tree

8 files changed

+111
-68
lines changed

8 files changed

+111
-68
lines changed

packages/optimizely-sdk/lib/core/audience_evaluator/index.js

+73-39
Original file line numberDiff line numberDiff line change
@@ -16,54 +16,88 @@
1616
var conditionTreeEvaluator = require('../condition_tree_evaluator');
1717
var customAttributeConditionEvaluator = require('../custom_attribute_condition_evaluator');
1818
var enums = require('../../utils/enums');
19+
var fns = require('../../utils/fns');
1920
var sprintf = require('@optimizely/js-sdk-utils').sprintf;
2021

22+
var ERROR_MESSAGES = enums.ERROR_MESSAGES;
2123
var LOG_LEVEL = enums.LOG_LEVEL;
2224
var LOG_MESSAGES = enums.LOG_MESSAGES;
2325
var MODULE_NAME = 'AUDIENCE_EVALUATOR';
2426

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

46-
if (!userAttributes) {
47-
userAttributes = {};
48-
}
28+
/**
29+
* Construct an instance of AudienceEvaluator with a given logger and options
30+
* @param {Logger} logger The Logger instance
31+
* @param {Object=} __exploratoryConditionEvaluators A map of condition evaluators provided by the consumer. This enables matching
32+
* condition types which are not supported natively by the SDK. Note that built in
33+
* Optimizely evaluators cannot be overridden.
34+
* @constructor
35+
*/
36+
function AudienceEvaluator(logger, __exploratoryConditionEvaluators) {
37+
this.logger = logger;
38+
this.typeToEvaluatorMap = fns.assignIn({}, __exploratoryConditionEvaluators, {
39+
'custom_attribute': customAttributeConditionEvaluator
40+
});
41+
}
4942

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

54-
var evaluateAudience = function(audienceId) {
55-
var audience = audiencesById[audienceId];
56-
if (audience) {
57-
logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.EVALUATING_AUDIENCE, MODULE_NAME, audienceId, JSON.stringify(audience.conditions)));
58-
var result = conditionTreeEvaluator.evaluate(audience.conditions, evaluateConditionWithUserAttributes);
59-
var resultText = result === null ? 'UNKNOWN' : result.toString().toUpperCase();
60-
logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.AUDIENCE_EVALUATION_RESULT, MODULE_NAME, audienceId, resultText));
61-
return result;
62-
}
62+
if (!userAttributes) {
63+
userAttributes = {};
64+
}
6365

64-
return null;
65-
};
66+
var evaluateAudience = function(audienceId) {
67+
var audience = audiencesById[audienceId];
68+
if (audience) {
69+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.EVALUATING_AUDIENCE, MODULE_NAME, audienceId, JSON.stringify(audience.conditions)));
70+
var result = conditionTreeEvaluator.evaluate(audience.conditions, this.evaluateConditionWithUserAttributes.bind(this, userAttributes));
71+
var resultText = result === null ? 'UNKNOWN' : result.toString().toUpperCase();
72+
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.AUDIENCE_EVALUATION_RESULT, MODULE_NAME, audienceId, resultText));
73+
return result;
74+
}
6675

67-
return conditionTreeEvaluator.evaluate(audienceConditions, evaluateAudience) || false;
68-
},
76+
return null;
77+
}.bind(this);
78+
79+
return conditionTreeEvaluator.evaluate(audienceConditions, evaluateAudience) || false;
80+
};
81+
82+
/**
83+
* Wrapper around evaluator.evaluate that is passed to the conditionTreeEvaluator.
84+
* Evaluates the condition provided given the user attributes if an evaluator has been defined for the condition type.
85+
* @param {Object} userAttributes A map of user attributes.
86+
* @param {Object} condition A single condition object to evaluate.
87+
* @return {Boolean|null} true if the condition is satisfied, null if a matcher is not found.
88+
*/
89+
AudienceEvaluator.prototype.evaluateConditionWithUserAttributes = function(userAttributes, condition) {
90+
var evaluator = this.typeToEvaluatorMap[condition.type];
91+
if (!evaluator) {
92+
this.logger.log(LOG_LEVEL.WARNING, sprintf(LOG_MESSAGES.UNKNOWN_CONDITION_TYPE, MODULE_NAME, JSON.stringify(condition)));
93+
return null;
94+
}
95+
try {
96+
return evaluator.evaluate(condition, userAttributes, this.logger);
97+
} catch (err) {
98+
this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.CONDITION_EVALUATOR_ERROR, MODULE_NAME, condition.type, err.message));
99+
}
100+
return null;
69101
};
102+
103+
module.exports = AudienceEvaluator;

packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js

+24-21
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
var audienceEvaluator = require('./');
16+
var AudienceEvaluator = require('./');
1717
var chai = require('chai');
18-
var sprintf = require('@optimizely/js-sdk-utils').sprintf;
1918
var conditionTreeEvaluator = require('../condition_tree_evaluator');
2019
var customAttributeConditionEvaluator = require('../custom_attribute_condition_evaluator');
2120
var sinon = require('sinon');
@@ -53,10 +52,14 @@ var audiencesById = {
5352
};
5453

5554
describe('lib/core/audience_evaluator', function() {
55+
var audienceEvaluator;
56+
var mockLogger = logger.createLogger({logLevel: LOG_LEVEL.INFO});
57+
beforeEach(function() {
58+
audienceEvaluator = new AudienceEvaluator(mockLogger);
59+
});
60+
5661
describe('APIs', function() {
5762
describe('evaluate', function() {
58-
var mockLogger = logger.createLogger({logLevel: LOG_LEVEL.INFO});
59-
6063
beforeEach(function () {
6164
sinon.stub(mockLogger, 'log');
6265
});
@@ -66,11 +69,11 @@ describe('lib/core/audience_evaluator', function() {
6669
});
6770

6871
it('should return true if there are no audiences', function() {
69-
assert.isTrue(audienceEvaluator.evaluate([], audiencesById, {}, mockLogger));
72+
assert.isTrue(audienceEvaluator.evaluate([], audiencesById, {}));
7073
});
7174

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

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

90-
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, iphoneUsers, mockLogger));
91-
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, chromeUsers, mockLogger));
92-
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, iphoneChromeUsers, mockLogger));
93+
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, iphoneUsers));
94+
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, chromeUsers));
95+
assert.isTrue(audienceEvaluator.evaluate(['0', '1'], audiencesById, iphoneChromeUsers));
9396
});
9497

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

109-
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, nexusUsers, mockLogger));
110-
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, safariUsers, mockLogger));
111-
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, nexusSafariUsers, mockLogger));
112+
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, nexusUsers));
113+
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, safariUsers));
114+
assert.isFalse(audienceEvaluator.evaluate(['0', '1'], audiencesById, nexusSafariUsers));
112115
});
113116

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

118121
describe('complex audience conditions', function() {
@@ -199,9 +202,9 @@ describe('lib/core/audience_evaluator', function() {
199202
});
200203
customAttributeConditionEvaluator.evaluate.returns(false);
201204
var userAttributes = { device_model: 'android' };
202-
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes, mockLogger);
205+
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
203206
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
204-
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
207+
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes);
205208
assert.isFalse(result);
206209
});
207210
});
@@ -224,9 +227,9 @@ describe('lib/core/audience_evaluator', function() {
224227
});
225228
customAttributeConditionEvaluator.evaluate.returns(null);
226229
var userAttributes = { device_model: 5.5 };
227-
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes, mockLogger);
230+
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
228231
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
229-
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
232+
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes);
230233
assert.isFalse(result);
231234
assert.strictEqual(2, mockLogger.log.callCount);
232235
assert.strictEqual(mockLogger.log.args[0][1], 'AUDIENCE_EVALUATOR: Starting to evaluate audience "1" with conditions: ["and",{"name":"device_model","value":"iphone","type":"custom_attribute"}].');
@@ -239,9 +242,9 @@ describe('lib/core/audience_evaluator', function() {
239242
});
240243
customAttributeConditionEvaluator.evaluate.returns(true);
241244
var userAttributes = { device_model: 'iphone' };
242-
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes, mockLogger);
245+
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
243246
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
244-
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
247+
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes);
245248
assert.isTrue(result);
246249
assert.strictEqual(2, mockLogger.log.callCount);
247250
assert.strictEqual(mockLogger.log.args[0][1], 'AUDIENCE_EVALUATOR: Starting to evaluate audience "1" with conditions: ["and",{"name":"device_model","value":"iphone","type":"custom_attribute"}].');
@@ -254,9 +257,9 @@ describe('lib/core/audience_evaluator', function() {
254257
});
255258
customAttributeConditionEvaluator.evaluate.returns(false);
256259
var userAttributes = { device_model: 'android' };
257-
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes, mockLogger);
260+
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
258261
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
259-
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
262+
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes);
260263
assert.isFalse(result);
261264
assert.strictEqual(2, mockLogger.log.callCount);
262265
assert.strictEqual(mockLogger.log.args[0][1], 'AUDIENCE_EVALUATOR: Starting to evaluate audience "1" with conditions: ["and",{"name":"device_model","value":"iphone","type":"custom_attribute"}].');

packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.js

-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ EVALUATORS_BY_MATCH_TYPE[SUBSTRING_MATCH_TYPE] = substringEvaluator;
5656
*/
5757
function evaluate(condition, userAttributes, logger) {
5858
if (condition.type !== CUSTOM_ATTRIBUTE_CONDITION_TYPE) {
59-
logger.log(LOG_LEVEL.WARNING, sprintf(LOG_MESSAGES.UNKNOWN_CONDITION_TYPE, MODULE_NAME, JSON.stringify(condition)));
6059
return null;
6160
}
6261

packages/optimizely-sdk/lib/core/decision_service/index.js

+5-4
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License. *
1515
***************************************************************************/
1616

17-
var audienceEvaluator = require('../audience_evaluator');
17+
var AudienceEvaluator = require('../audience_evaluator');
1818
var bucketer = require('../bucketer');
1919
var enums = require('../../utils/enums');
2020
var fns = require('../../utils/fns');
@@ -49,9 +49,10 @@ var DECISION_SOURCES = enums.DECISION_SOURCES;
4949
* @returns {Object}
5050
*/
5151
function DecisionService(options) {
52-
this.userProfileService = options.userProfileService || null;
53-
this.logger = options.logger;
52+
this.audienceEvaluator = new AudienceEvaluator(options.logger, options.__exploratoryConditionEvaluators);
5453
this.forcedVariationMap = {};
54+
this.logger = options.logger;
55+
this.userProfileService = options.userProfileService || null;
5556
}
5657

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

177178
if (!result) {

packages/optimizely-sdk/lib/core/decision_service/index.tests.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ var sprintf = require('@optimizely/js-sdk-utils').sprintf;
2828
var testData = require('../../tests/test_data').getTestProjectConfig();
2929
var testDataWithFeatures = require('../../tests/test_data').getTestProjectConfigWithFeatures();
3030
var jsonSchemaValidator = require('../../utils/json_schema_validator');
31-
var audienceEvaluator = require('../audience_evaluator');
31+
var AudienceEvaluator = require('../audience_evaluator');
3232

3333
var chai = require('chai');
3434
var sinon = require('sinon');
@@ -420,7 +420,7 @@ describe('lib/core/decision_service', function() {
420420
var __audienceEvaluateSpy;
421421

422422
beforeEach(function() {
423-
__audienceEvaluateSpy = sinon.spy(audienceEvaluator, 'evaluate');
423+
__audienceEvaluateSpy = sinon.spy(AudienceEvaluator.prototype, 'evaluate');
424424
});
425425

426426
afterEach(function() {

packages/optimizely-sdk/lib/optimizely/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ function Optimizely(config) {
100100
this.decisionService = decisionService.createDecisionService({
101101
userProfileService: userProfileService,
102102
logger: this.logger,
103+
__exploratoryConditionEvaluators: config.__exploratoryConditionEvaluators
103104
});
104105

105106
this.notificationCenter = notificationCenter.createNotificationCenter({

packages/optimizely-sdk/lib/optimizely/index.tests.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
***************************************************************************/
1616

1717
var Optimizely = require('./');
18-
var audienceEvaluator = require('../core/audience_evaluator');
18+
var AudienceEvaluator = require('../core/audience_evaluator');
1919
var bluebird = require('bluebird');
2020
var bucketer = require('../core/bucketer');
2121
var projectConfigManager = require('../core/project_config/project_config_manager');
@@ -167,6 +167,7 @@ describe('lib/optimizely', function() {
167167
sinon.assert.calledWith(decisionService.createDecisionService, {
168168
userProfileService: userProfileServiceInstance,
169169
logger: createdLogger,
170+
__exploratoryConditionEvaluators: undefined
170171
});
171172

172173
var logMessage = createdLogger.log.args[0][1];
@@ -189,6 +190,7 @@ describe('lib/optimizely', function() {
189190
sinon.assert.calledWith(decisionService.createDecisionService, {
190191
userProfileService: null,
191192
logger: createdLogger,
193+
__exploratoryConditionEvaluators: undefined
192194
});
193195

194196
var logMessage = createdLogger.log.args[0][1];
@@ -4363,6 +4365,7 @@ describe('lib/optimizely', function() {
43634365
logToConsole: false,
43644366
});
43654367
var optlyInstance;
4368+
var audienceEvaluator;
43664369
beforeEach(function() {
43674370
optlyInstance = new Optimizely({
43684371
clientEngine: 'node-sdk',
@@ -4374,6 +4377,7 @@ describe('lib/optimizely', function() {
43744377
logger: createdLogger,
43754378
isValidInstance: true,
43764379
});
4380+
audienceEvaluator = AudienceEvaluator.prototype;
43774381

43784382
sandbox.stub(eventDispatcher, 'dispatchEvent');
43794383
sandbox.stub(errorHandler, 'handleError');

0 commit comments

Comments
 (0)