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

feat: Add ODP Datafile Parsing and Audience Evaluation #765

Merged
merged 14 commits into from
Aug 14, 2022
Merged
1 change: 1 addition & 0 deletions packages/optimizely-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Add package.json script for running Karma tests locally using Chrome ([#651](https://github.com/optimizely/javascript-sdk/pull/651)).
- Replaced explicit typescript typings with auto generated ones ([#745](https://github.com/optimizely/javascript-sdk/pull/745)).
- Integrated code from `utils` package into `optimizely-sdk` ([#749](https://github.com/optimizely/javascript-sdk/pull/749)).
- Added ODP Segments support in Audience Evaluation ([#765](https://github.com/optimizely/javascript-sdk/pull/765)).

## [4.9.1] - January 18, 2022

Expand Down
307 changes: 276 additions & 31 deletions packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js

Large diffs are not rendered by default.

24 changes: 13 additions & 11 deletions packages/optimizely-sdk/lib/core/audience_evaluator/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Copyright 2016, 2018-2021, Optimizely
* Copyright 2016, 2018-2022, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -23,15 +23,16 @@ import {
} from '../../utils/enums';
import * as conditionTreeEvaluator from '../condition_tree_evaluator';
import * as customAttributeConditionEvaluator from '../custom_attribute_condition_evaluator';
import { UserAttributes, Audience, Condition } from '../../shared_types';
import * as odpSegmentsConditionEvaluator from './odp_segment_condition_evaluator';
import { Audience, Condition, OptimizelyUserContext } from '../../shared_types';

const logger = getLogger();
const MODULE_NAME = 'AUDIENCE_EVALUATOR';

export class AudienceEvaluator {
private typeToEvaluatorMap: {
[key: string]: {
[key: string]: (condition: Condition, userAttributes: UserAttributes) => boolean | null
[key: string]: (condition: Condition, user: OptimizelyUserContext) => boolean | null
};
};

Expand All @@ -45,6 +46,7 @@ export class AudienceEvaluator {
constructor(UNSTABLE_conditionEvaluators: unknown) {
this.typeToEvaluatorMap = fns.assign({}, UNSTABLE_conditionEvaluators, {
custom_attribute: customAttributeConditionEvaluator,
third_party_dimension: odpSegmentsConditionEvaluator,
});
}

Expand All @@ -56,15 +58,15 @@ export class AudienceEvaluator {
* @param {[id: string]: Audience} 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 {UserAttributes} userAttributes User attributes which will be used in determining if audience conditions
* are met. If not provided, defaults to an empty object
* @param {OptimizelyUserContext} userAttributes User context which contains the attributes and segments which will be used in
* determining if audience conditions are met.
* @return {boolean} true if the user attributes match the given audience conditions, false
* otherwise
*/
evaluate(
audienceConditions: Array<string | string[]>,
audiencesById: { [id: string]: Audience },
userAttributes: UserAttributes = {}
user: OptimizelyUserContext,
): boolean {
// if there are no audiences, return true because that means ALL users are included in the experiment
if (!audienceConditions || audienceConditions.length === 0) {
Expand All @@ -80,7 +82,7 @@ export class AudienceEvaluator {
);
const result = conditionTreeEvaluator.evaluate(
audience.conditions as unknown[] ,
this.evaluateConditionWithUserAttributes.bind(this, userAttributes)
this.evaluateConditionWithUserAttributes.bind(this, user)
);
const resultText = result === null ? 'UNKNOWN' : result.toString().toUpperCase();
logger.log(LOG_LEVEL.DEBUG, LOG_MESSAGES.AUDIENCE_EVALUATION_RESULT, MODULE_NAME, audienceId, resultText);
Expand All @@ -95,18 +97,18 @@ export class AudienceEvaluator {
/**
* 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 {UserAttributes} userAttributes A map of user attributes.
* @param {Condition} condition A single condition object to evaluate.
* @param {OptimizelyUserContext} user Optimizely user context containing attributes and segments
* @param {Condition} condition A single condition object to evaluate.
* @return {boolean|null} true if the condition is satisfied, null if a matcher is not found.
*/
evaluateConditionWithUserAttributes(userAttributes: UserAttributes, condition: Condition): boolean | null {
evaluateConditionWithUserAttributes(user: OptimizelyUserContext, condition: Condition): boolean | null {
const evaluator = this.typeToEvaluatorMap[condition.type];
if (!evaluator) {
logger.log(LOG_LEVEL.WARNING, LOG_MESSAGES.UNKNOWN_CONDITION_TYPE, MODULE_NAME, JSON.stringify(condition));
return null;
}
try {
return evaluator.evaluate(condition, userAttributes);
return evaluator.evaluate(condition, user);
} catch (err) {
logger.log(
LOG_LEVEL.ERROR,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/****************************************************************************
* Copyright 2022, Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
* You may obtain a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
***************************************************************************/
import sinon from 'sinon';
import { assert } from 'chai';
import { sprintf } from '@optimizely/js-sdk-utils';

import {
LOG_LEVEL,
LOG_MESSAGES,
} from '../../../utils/enums';
import * as logging from '@optimizely/js-sdk-logging';
import * as odpSegmentEvalutor from './';

var odpSegment1Condition = {
"value": "odp-segment-1",
"type": "third_party_dimension",
"name": "odp.audiences",
"match": "qualified"
};

var getMockUserContext = (attributes, segments) => ({
getAttributes: () => ({ ... (attributes || {})}),
isQualifiedFor: segment => segments.indexOf(segment) > -1
});

describe('lib/core/audience_evaluator/odp_segment_condition_evaluator', function() {
var stubLogHandler;

beforeEach(function() {
stubLogHandler = {
log: sinon.stub(),
};
logging.setLogLevel('notset');
logging.setLogHandler(stubLogHandler);
});

afterEach(function() {
logging.resetLogger();
});

it('should return true when segment qualifies and known match type is provided', () => {
assert.isTrue(odpSegmentEvalutor.evaluate(odpSegment1Condition, getMockUserContext({}, ['odp-segment-1'])));
});

it('should return false when segment does not qualify and known match type is provided', () => {
assert.isFalse(odpSegmentEvalutor.evaluate(odpSegment1Condition, getMockUserContext({}, ['odp-segment-2'])));
})

it('should return null when segment qualifies but unknown match type is provided', () => {
const invalidOdpMatchCondition = {
... odpSegment1Condition,
"match": 'unknown',
};
assert.isNull(odpSegmentEvalutor.evaluate(invalidOdpMatchCondition, getMockUserContext({}, ['odp-segment-1'])));
sinon.assert.calledOnce(stubLogHandler.log);
assert.strictEqual(stubLogHandler.log.args[0][0], LOG_LEVEL.WARNING);
var logMessage = stubLogHandler.log.args[0][1];
assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, 'ODP_SEGMENT_CONDITION_EVALUATOR', JSON.stringify(invalidOdpMatchCondition)));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/****************************************************************************
* Copyright 2022 Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
* You may obtain a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
***************************************************************************/
import { getLogger } from '@optimizely/js-sdk-logging';
import { Condition, OptimizelyUserContext } from '../../../shared_types';

import { LOG_MESSAGES } from '../../../utils/enums';

const MODULE_NAME = 'ODP_SEGMENT_CONDITION_EVALUATOR';

const logger = getLogger();

const QUALIFIED_MATCH_TYPE = 'qualified';

const MATCH_TYPES = [
QUALIFIED_MATCH_TYPE,
];

type ConditionEvaluator = (condition: Condition, user: OptimizelyUserContext) => boolean | null;

const EVALUATORS_BY_MATCH_TYPE: { [conditionType: string]: ConditionEvaluator | undefined } = {};
EVALUATORS_BY_MATCH_TYPE[QUALIFIED_MATCH_TYPE] = qualifiedEvaluator;

/**
* Given a custom attribute audience condition and user attributes, evaluate the
* condition against the attributes.
* @param {Condition} condition
* @param {OptimizelyUserContext} user
* @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
*/
export function evaluate(condition: Condition, user: OptimizelyUserContext): boolean | null {
const conditionMatch = condition.match;
if (typeof conditionMatch !== 'undefined' && MATCH_TYPES.indexOf(conditionMatch) === -1) {
logger.warn(LOG_MESSAGES.UNKNOWN_MATCH_TYPE, MODULE_NAME, JSON.stringify(condition));
return null;
}

let evaluator;
if (!conditionMatch) {
evaluator = qualifiedEvaluator;
} else {
evaluator = EVALUATORS_BY_MATCH_TYPE[conditionMatch] || qualifiedEvaluator;
}

return evaluator(condition, user);
}

function qualifiedEvaluator(condition: Condition, user: OptimizelyUserContext): boolean {
return user.isQualifiedFor(condition.value as string);
}