Skip to content

Commit e4b5dc9

Browse files
authored
feat: Add ODP Datafile Parsing and Audience Evaluation (#765)
1 parent fff21e0 commit e4b5dc9

File tree

17 files changed

+1429
-543
lines changed

17 files changed

+1429
-543
lines changed

packages/optimizely-sdk/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
2424
- Add package.json script for running Karma tests locally using Chrome ([#651](https://github.com/optimizely/javascript-sdk/pull/651)).
2525
- Replaced explicit typescript typings with auto generated ones ([#745](https://github.com/optimizely/javascript-sdk/pull/745)).
2626
- Integrated code from `utils` package into `optimizely-sdk` ([#749](https://github.com/optimizely/javascript-sdk/pull/749)).
27+
- Added ODP Segments support in Audience Evaluation ([#765](https://github.com/optimizely/javascript-sdk/pull/765)).
2728

2829
## [4.9.1] - January 18, 2022
2930

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

Lines changed: 276 additions & 31 deletions
Large diffs are not rendered by default.

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

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,16 @@ import {
2323
} from '../../utils/enums';
2424
import * as conditionTreeEvaluator from '../condition_tree_evaluator';
2525
import * as customAttributeConditionEvaluator from '../custom_attribute_condition_evaluator';
26-
import { UserAttributes, Audience, Condition } from '../../shared_types';
26+
import * as odpSegmentsConditionEvaluator from './odp_segment_condition_evaluator';
27+
import { Audience, Condition, OptimizelyUserContext } from '../../shared_types';
2728

2829
const logger = getLogger();
2930
const MODULE_NAME = 'AUDIENCE_EVALUATOR';
3031

3132
export class AudienceEvaluator {
3233
private typeToEvaluatorMap: {
3334
[key: string]: {
34-
[key: string]: (condition: Condition, userAttributes: UserAttributes) => boolean | null
35+
[key: string]: (condition: Condition, user: OptimizelyUserContext) => boolean | null
3536
};
3637
};
3738

@@ -45,6 +46,7 @@ export class AudienceEvaluator {
4546
constructor(UNSTABLE_conditionEvaluators: unknown) {
4647
this.typeToEvaluatorMap = fns.assign({}, UNSTABLE_conditionEvaluators, {
4748
custom_attribute: customAttributeConditionEvaluator,
49+
third_party_dimension: odpSegmentsConditionEvaluator,
4850
});
4951
}
5052

@@ -56,15 +58,15 @@ export class AudienceEvaluator {
5658
* @param {[id: string]: Audience} audiencesById Object providing access to full audience objects for audience IDs
5759
* contained in audienceConditions. Keys should be audience IDs, values
5860
* should be full audience objects with conditions properties
59-
* @param {UserAttributes} userAttributes User attributes which will be used in determining if audience conditions
60-
* are met. If not provided, defaults to an empty object
61+
* @param {OptimizelyUserContext} userAttributes User context which contains the attributes and segments which will be used in
62+
* determining if audience conditions are met.
6163
* @return {boolean} true if the user attributes match the given audience conditions, false
6264
* otherwise
6365
*/
6466
evaluate(
6567
audienceConditions: Array<string | string[]>,
6668
audiencesById: { [id: string]: Audience },
67-
userAttributes: UserAttributes = {}
69+
user: OptimizelyUserContext,
6870
): boolean {
6971
// if there are no audiences, return true because that means ALL users are included in the experiment
7072
if (!audienceConditions || audienceConditions.length === 0) {
@@ -80,7 +82,7 @@ export class AudienceEvaluator {
8082
);
8183
const result = conditionTreeEvaluator.evaluate(
8284
audience.conditions as unknown[] ,
83-
this.evaluateConditionWithUserAttributes.bind(this, userAttributes)
85+
this.evaluateConditionWithUserAttributes.bind(this, user)
8486
);
8587
const resultText = result === null ? 'UNKNOWN' : result.toString().toUpperCase();
8688
logger.log(LOG_LEVEL.DEBUG, LOG_MESSAGES.AUDIENCE_EVALUATION_RESULT, MODULE_NAME, audienceId, resultText);
@@ -95,18 +97,18 @@ export class AudienceEvaluator {
9597
/**
9698
* Wrapper around evaluator.evaluate that is passed to the conditionTreeEvaluator.
9799
* Evaluates the condition provided given the user attributes if an evaluator has been defined for the condition type.
98-
* @param {UserAttributes} userAttributes A map of user attributes.
99-
* @param {Condition} condition A single condition object to evaluate.
100+
* @param {OptimizelyUserContext} user Optimizely user context containing attributes and segments
101+
* @param {Condition} condition A single condition object to evaluate.
100102
* @return {boolean|null} true if the condition is satisfied, null if a matcher is not found.
101103
*/
102-
evaluateConditionWithUserAttributes(userAttributes: UserAttributes, condition: Condition): boolean | null {
104+
evaluateConditionWithUserAttributes(user: OptimizelyUserContext, condition: Condition): boolean | null {
103105
const evaluator = this.typeToEvaluatorMap[condition.type];
104106
if (!evaluator) {
105107
logger.log(LOG_LEVEL.WARNING, LOG_MESSAGES.UNKNOWN_CONDITION_TYPE, MODULE_NAME, JSON.stringify(condition));
106108
return null;
107109
}
108110
try {
109-
return evaluator.evaluate(condition, userAttributes);
111+
return evaluator.evaluate(condition, user);
110112
} catch (err) {
111113
logger.log(
112114
LOG_LEVEL.ERROR,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/****************************************************************************
2+
* Copyright 2022, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
import sinon from 'sinon';
17+
import { assert } from 'chai';
18+
import { sprintf } from '../../../utils/fns';
19+
20+
import {
21+
LOG_LEVEL,
22+
LOG_MESSAGES,
23+
} from '../../../utils/enums';
24+
import * as logging from '../../../modules/logging';
25+
import * as odpSegmentEvalutor from './';
26+
27+
var odpSegment1Condition = {
28+
"value": "odp-segment-1",
29+
"type": "third_party_dimension",
30+
"name": "odp.audiences",
31+
"match": "qualified"
32+
};
33+
34+
var getMockUserContext = (attributes, segments) => ({
35+
getAttributes: () => ({ ... (attributes || {})}),
36+
isQualifiedFor: segment => segments.indexOf(segment) > -1
37+
});
38+
39+
describe('lib/core/audience_evaluator/odp_segment_condition_evaluator', function() {
40+
var stubLogHandler;
41+
42+
beforeEach(function() {
43+
stubLogHandler = {
44+
log: sinon.stub(),
45+
};
46+
logging.setLogLevel('notset');
47+
logging.setLogHandler(stubLogHandler);
48+
});
49+
50+
afterEach(function() {
51+
logging.resetLogger();
52+
});
53+
54+
it('should return true when segment qualifies and known match type is provided', () => {
55+
assert.isTrue(odpSegmentEvalutor.evaluate(odpSegment1Condition, getMockUserContext({}, ['odp-segment-1'])));
56+
});
57+
58+
it('should return false when segment does not qualify and known match type is provided', () => {
59+
assert.isFalse(odpSegmentEvalutor.evaluate(odpSegment1Condition, getMockUserContext({}, ['odp-segment-2'])));
60+
})
61+
62+
it('should return null when segment qualifies but unknown match type is provided', () => {
63+
const invalidOdpMatchCondition = {
64+
... odpSegment1Condition,
65+
"match": 'unknown',
66+
};
67+
assert.isNull(odpSegmentEvalutor.evaluate(invalidOdpMatchCondition, getMockUserContext({}, ['odp-segment-1'])));
68+
sinon.assert.calledOnce(stubLogHandler.log);
69+
assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
70+
var logMessage = stubLogHandler.log.args[0][1];
71+
assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, 'ODP_SEGMENT_CONDITION_EVALUATOR', JSON.stringify(invalidOdpMatchCondition)));
72+
});
73+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/****************************************************************************
2+
* Copyright 2022 Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
import { getLogger } from '../../../modules/logging';
17+
import { Condition, OptimizelyUserContext } from '../../../shared_types';
18+
19+
import { LOG_MESSAGES } from '../../../utils/enums';
20+
21+
const MODULE_NAME = 'ODP_SEGMENT_CONDITION_EVALUATOR';
22+
23+
const logger = getLogger();
24+
25+
const QUALIFIED_MATCH_TYPE = 'qualified';
26+
27+
const MATCH_TYPES = [
28+
QUALIFIED_MATCH_TYPE,
29+
];
30+
31+
type ConditionEvaluator = (condition: Condition, user: OptimizelyUserContext) => boolean | null;
32+
33+
const EVALUATORS_BY_MATCH_TYPE: { [conditionType: string]: ConditionEvaluator | undefined } = {};
34+
EVALUATORS_BY_MATCH_TYPE[QUALIFIED_MATCH_TYPE] = qualifiedEvaluator;
35+
36+
/**
37+
* Given a custom attribute audience condition and user attributes, evaluate the
38+
* condition against the attributes.
39+
* @param {Condition} condition
40+
* @param {OptimizelyUserContext} user
41+
* @return {?boolean} true/false if the given user attributes match/don't match the given condition,
42+
* null if the given user attributes and condition can't be evaluated
43+
* TODO: Change to accept and object with named properties
44+
*/
45+
export function evaluate(condition: Condition, user: OptimizelyUserContext): boolean | null {
46+
const conditionMatch = condition.match;
47+
if (typeof conditionMatch !== 'undefined' && MATCH_TYPES.indexOf(conditionMatch) === -1) {
48+
logger.warn(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, MODULE_NAME, JSON.stringify(condition));
49+
return null;
50+
}
51+
52+
let evaluator;
53+
if (!conditionMatch) {
54+
evaluator = qualifiedEvaluator;
55+
} else {
56+
evaluator = EVALUATORS_BY_MATCH_TYPE[conditionMatch] || qualifiedEvaluator;
57+
}
58+
59+
return evaluator(condition, user);
60+
}
61+
62+
function qualifiedEvaluator(condition: Condition, user: OptimizelyUserContext): boolean {
63+
return user.isQualifiedFor(condition.value as string);
64+
}

0 commit comments

Comments
 (0)