Skip to content

Commit ed884fb

Browse files
James FoxMichael Ng
James Fox
authored and
Michael Ng
committed
feat(AudienceEvaluator): Add the ability to provide custom condition evaluators (#288)
1 parent bd49de0 commit ed884fb

File tree

10 files changed

+144
-138
lines changed

10 files changed

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

24+
var ERROR_MESSAGES = enums.ERROR_MESSAGES;
2125
var LOG_LEVEL = enums.LOG_LEVEL;
2226
var LOG_MESSAGES = enums.LOG_MESSAGES;
2327
var MODULE_NAME = 'AUDIENCE_EVALUATOR';
2428

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-
}
4529

46-
if (!userAttributes) {
47-
userAttributes = {};
48-
}
30+
/**
31+
* Construct an instance of AudienceEvaluator with given options
32+
* @param {Object=} UNSTABLE_conditionEvaluators A map of condition evaluators provided by the consumer. This enables matching
33+
* condition types which are not supported natively by the SDK. Note that built in
34+
* Optimizely evaluators cannot be overridden.
35+
* @constructor
36+
*/
37+
function AudienceEvaluator(UNSTABLE_conditionEvaluators) {
38+
this.typeToEvaluatorMap = fns.assignIn({}, UNSTABLE_conditionEvaluators, {
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+
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+
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+
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, logger);
97+
} catch (err) {
98+
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

+30-31
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@
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');
2221
var assert = chai.assert;
23-
var logger = require('../../plugins/logger');
22+
var logging = require('@optimizely/js-sdk-logging');
23+
var mockLogger = logging.getLogger();
2424
var enums = require('../../utils/enums');
2525
var LOG_LEVEL = enums.LOG_LEVEL;
2626

@@ -53,11 +53,14 @@ var audiencesById = {
5353
};
5454

5555
describe('lib/core/audience_evaluator', function() {
56+
var audienceEvaluator;
57+
beforeEach(function() {
58+
audienceEvaluator = new AudienceEvaluator();
59+
});
60+
5661
describe('APIs', function() {
5762
describe('evaluate', function() {
58-
var mockLogger = logger.createLogger({logLevel: LOG_LEVEL.INFO});
59-
60-
beforeEach(function () {
63+
beforeEach(function() {
6164
sinon.stub(mockLogger, 'log');
6265
});
6366

@@ -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,22 +109,21 @@ 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() {
119122
it('should return true if any of the audiences in an "OR" condition pass', function() {
120123
var result = audienceEvaluator.evaluate(
121124
['or', '0', '1'],
122125
audiencesById,
123-
{ browser_type: 'chrome' },
124-
mockLogger
126+
{ browser_type: 'chrome' }
125127
);
126128
assert.isTrue(result);
127129
});
@@ -130,8 +132,7 @@ describe('lib/core/audience_evaluator', function() {
130132
var result = audienceEvaluator.evaluate(
131133
['and', '0', '1'],
132134
audiencesById,
133-
{ browser_type: 'chrome', device_model: 'iphone' },
134-
mockLogger
135+
{ browser_type: 'chrome', device_model: 'iphone' }
135136
);
136137
assert.isTrue(result);
137138
});
@@ -140,8 +141,7 @@ describe('lib/core/audience_evaluator', function() {
140141
var result = audienceEvaluator.evaluate(
141142
['not', '1'],
142143
audiencesById,
143-
{ device_model: 'android' },
144-
mockLogger
144+
{ device_model: 'android' }
145145
);
146146
assert.isTrue(result);
147147
});
@@ -165,8 +165,7 @@ describe('lib/core/audience_evaluator', function() {
165165
var result = audienceEvaluator.evaluate(
166166
['or', '0', '1'],
167167
audiencesById,
168-
{ browser_type: 'chrome' },
169-
mockLogger
168+
{ browser_type: 'chrome' }
170169
);
171170
assert.isTrue(result);
172171
});
@@ -176,8 +175,7 @@ describe('lib/core/audience_evaluator', function() {
176175
var result = audienceEvaluator.evaluate(
177176
['or', '0', '1'],
178177
audiencesById,
179-
{ browser_type: 'safari' },
180-
mockLogger
178+
{ browser_type: 'safari' }
181179
);
182180
assert.isFalse(result);
183181
});
@@ -187,8 +185,7 @@ describe('lib/core/audience_evaluator', function() {
187185
var result = audienceEvaluator.evaluate(
188186
['or', '0', '1'],
189187
audiencesById,
190-
{ state: 'California' },
191-
mockLogger
188+
{ state: 'California' }
192189
);
193190
assert.isFalse(result);
194191
});
@@ -199,8 +196,9 @@ describe('lib/core/audience_evaluator', function() {
199196
});
200197
customAttributeConditionEvaluator.evaluate.returns(false);
201198
var userAttributes = { device_model: 'android' };
202-
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes, mockLogger);
199+
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
203200
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
201+
console.log('args: ', customAttributeConditionEvaluator.evaluate.firstCall.args)
204202
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
205203
assert.isFalse(result);
206204
});
@@ -224,7 +222,7 @@ describe('lib/core/audience_evaluator', function() {
224222
});
225223
customAttributeConditionEvaluator.evaluate.returns(null);
226224
var userAttributes = { device_model: 5.5 };
227-
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes, mockLogger);
225+
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
228226
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
229227
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
230228
assert.isFalse(result);
@@ -239,7 +237,7 @@ describe('lib/core/audience_evaluator', function() {
239237
});
240238
customAttributeConditionEvaluator.evaluate.returns(true);
241239
var userAttributes = { device_model: 'iphone' };
242-
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes, mockLogger);
240+
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
243241
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
244242
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
245243
assert.isTrue(result);
@@ -254,8 +252,9 @@ describe('lib/core/audience_evaluator', function() {
254252
});
255253
customAttributeConditionEvaluator.evaluate.returns(false);
256254
var userAttributes = { device_model: 'android' };
257-
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes, mockLogger);
255+
var result = audienceEvaluator.evaluate(['or', '1'], audiencesById, userAttributes);
258256
sinon.assert.calledOnce(customAttributeConditionEvaluator.evaluate);
257+
console.log('args: ', customAttributeConditionEvaluator.evaluate.firstCall.args)
259258
sinon.assert.calledWithExactly(customAttributeConditionEvaluator.evaluate, iphoneUserAudience.conditions[1], userAttributes, mockLogger);
260259
assert.isFalse(result);
261260
assert.strictEqual(2, mockLogger.log.callCount);

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

+1-5
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,9 @@ EVALUATORS_BY_MATCH_TYPE[SUBSTRING_MATCH_TYPE] = substringEvaluator;
5353
* @param {Object} logger
5454
* @return {?Boolean} true/false if the given user attributes match/don't match the given condition,
5555
* null if the given user attributes and condition can't be evaluated
56+
* TODO: Change to accept and object with named properties
5657
*/
5758
function evaluate(condition, userAttributes, logger) {
58-
if (condition.type !== CUSTOM_ATTRIBUTE_CONDITION_TYPE) {
59-
logger.log(LOG_LEVEL.WARNING, sprintf(LOG_MESSAGES.UNKNOWN_CONDITION_TYPE, MODULE_NAME, JSON.stringify(condition)));
60-
return null;
61-
}
62-
6359
var conditionMatch = condition.match;
6460
if (typeof conditionMatch !== 'undefined' && MATCH_TYPES.indexOf(conditionMatch) === -1) {
6561
logger.log(LOG_LEVEL.WARNING, sprintf(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, MODULE_NAME, JSON.stringify(condition)));

0 commit comments

Comments
 (0)