Skip to content

Commit

Permalink
feat: Add ODP Datafile Parsing and Audience Evaluation (#765)
Browse files Browse the repository at this point in the history
  • Loading branch information
zashraf1985 committed Aug 14, 2022
1 parent fff21e0 commit e4b5dc9
Show file tree
Hide file tree
Showing 17 changed files with 1,429 additions and 543 deletions.
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.

22 changes: 12 additions & 10 deletions packages/optimizely-sdk/lib/core/audience_evaluator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '../../../utils/fns';

import {
LOG_LEVEL,
LOG_MESSAGES,
} from '../../../utils/enums';
import * as logging from '../../../modules/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 '../../../modules/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);
}

0 comments on commit e4b5dc9

Please sign in to comment.