Skip to content

Commit 6214c11

Browse files
authored
[FSSDK-11399] support traffic allocation for cmab (#1029)
1 parent 85c0220 commit 6214c11

File tree

6 files changed

+170
-18
lines changed

6 files changed

+170
-18
lines changed

lib/core/decision_service/index.spec.ts

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616
import { describe, it, expect, vi, MockInstance, beforeEach } from 'vitest';
17-
import { CMAB_FETCH_FAILED, DecisionService } from '.';
17+
import { CMAB_DUMMY_ENTITY_ID, CMAB_FETCH_FAILED, DecisionService } from '.';
1818
import { getMockLogger } from '../../tests/mock/mock_logger';
1919
import OptimizelyUserContext from '../../optimizely_user_context';
2020
import { bucket } from '../bucketer';
@@ -140,10 +140,18 @@ const verifyBucketCall = (
140140
variationIdMap,
141141
bucketingId,
142142
} = mockBucket.mock.calls[call][0];
143+
let expectedTrafficAllocation = experiment.trafficAllocation;
144+
if (experiment.cmab) {
145+
expectedTrafficAllocation = [{
146+
endOfRange: experiment.cmab.trafficAllocation,
147+
entityId: CMAB_DUMMY_ENTITY_ID,
148+
}];
149+
}
150+
143151
expect(experimentId).toBe(experiment.id);
144152
expect(experimentKey).toBe(experiment.key);
145153
expect(userId).toBe(user.getUserId());
146-
expect(trafficAllocationConfig).toBe(experiment.trafficAllocation);
154+
expect(trafficAllocationConfig).toEqual(expectedTrafficAllocation);
147155
expect(experimentKeyMap).toBe(projectConfig.experimentKeyMap);
148156
expect(experimentIdMap).toBe(projectConfig.experimentIdMap);
149157
expect(groupIdMap).toBe(projectConfig.groupIdMap);
@@ -1327,7 +1335,8 @@ describe('DecisionService', () => {
13271335
});
13281336
});
13291337

1330-
it('should get decision from the cmab service if the experiment is a cmab experiment', async () => {
1338+
it('should not return variation and should not call cmab service \
1339+
for cmab experiment if user is not bucketed into it', async () => {
13311340
const { decisionService, cmabService } = getDecisionService();
13321341
const config = createProjectConfig(getDecisionTestDatafile());
13331342

@@ -1340,6 +1349,57 @@ describe('DecisionService', () => {
13401349
},
13411350
});
13421351

1352+
mockBucket.mockImplementation((param: BucketerParams) => {
1353+
const ruleKey = param.experimentKey;
1354+
if (ruleKey == 'default-rollout-key') {
1355+
return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
1356+
}
1357+
return {
1358+
result: null,
1359+
reasons: [],
1360+
}
1361+
});
1362+
1363+
const feature = config.featureKeyMap['flag_1'];
1364+
const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
1365+
expect(value).toBeInstanceOf(Promise);
1366+
1367+
const variation = (await value)[0];
1368+
expect(variation.result).toEqual({
1369+
experiment: config.experimentKeyMap['default-rollout-key'],
1370+
variation: config.variationIdMap['5007'],
1371+
decisionSource: DECISION_SOURCES.ROLLOUT,
1372+
});
1373+
1374+
verifyBucketCall(0, config, config.experimentKeyMap['exp_3'], user);
1375+
expect(cmabService.getDecision).not.toHaveBeenCalled();
1376+
});
1377+
1378+
it('should get decision from the cmab service if the experiment is a cmab experiment \
1379+
and user is bucketed into it', async () => {
1380+
const { decisionService, cmabService } = getDecisionService();
1381+
const config = createProjectConfig(getDecisionTestDatafile());
1382+
1383+
const user = new OptimizelyUserContext({
1384+
optimizely: {} as any,
1385+
userId: 'tester',
1386+
attributes: {
1387+
country: 'BD',
1388+
age: 80, // should satisfy audience condition for exp_3 which is cmab and not others
1389+
},
1390+
});
1391+
1392+
mockBucket.mockImplementation((param: BucketerParams) => {
1393+
const ruleKey = param.experimentKey;
1394+
if (ruleKey == 'exp_3') {
1395+
return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
1396+
}
1397+
return {
1398+
result: null,
1399+
reasons: [],
1400+
}
1401+
});
1402+
13431403
cmabService.getDecision.mockResolvedValue({
13441404
variationId: '5003',
13451405
cmabUuid: 'uuid-test',
@@ -1357,6 +1417,8 @@ describe('DecisionService', () => {
13571417
decisionSource: DECISION_SOURCES.FEATURE_TEST,
13581418
});
13591419

1420+
verifyBucketCall(0, config, config.experimentKeyMap['exp_3'], user);
1421+
13601422
expect(cmabService.getDecision).toHaveBeenCalledTimes(1);
13611423
expect(cmabService.getDecision).toHaveBeenCalledWith(
13621424
config,
@@ -1379,6 +1441,17 @@ describe('DecisionService', () => {
13791441
},
13801442
});
13811443

1444+
mockBucket.mockImplementation((param: BucketerParams) => {
1445+
const ruleKey = param.experimentKey;
1446+
if (ruleKey == 'exp_3') {
1447+
return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
1448+
}
1449+
return {
1450+
result: null,
1451+
reasons: [],
1452+
}
1453+
});
1454+
13821455
cmabService.getDecision.mockResolvedValue({
13831456
variationId: '5003',
13841457
cmabUuid: 'uuid-test',
@@ -1424,6 +1497,17 @@ describe('DecisionService', () => {
14241497
},
14251498
});
14261499

1500+
mockBucket.mockImplementation((param: BucketerParams) => {
1501+
const ruleKey = param.experimentKey;
1502+
if (ruleKey == 'exp_3') {
1503+
return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
1504+
}
1505+
return {
1506+
result: null,
1507+
reasons: [],
1508+
}
1509+
});
1510+
14271511
cmabService.getDecision.mockRejectedValue(new Error('I am an error'));
14281512

14291513
const feature = config.featureKeyMap['flag_1'];
@@ -1474,6 +1558,17 @@ describe('DecisionService', () => {
14741558

14751559
userProfileServiceAsync?.save.mockImplementation(() => Promise.resolve());
14761560

1561+
mockBucket.mockImplementation((param: BucketerParams) => {
1562+
const ruleKey = param.experimentKey;
1563+
if (ruleKey == 'exp_3') {
1564+
return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
1565+
}
1566+
return {
1567+
result: null,
1568+
reasons: [],
1569+
}
1570+
});
1571+
14771572
cmabService.getDecision.mockResolvedValue({
14781573
variationId: '5003',
14791574
cmabUuid: 'uuid-test',
@@ -1552,6 +1647,17 @@ describe('DecisionService', () => {
15521647

15531648
userProfileServiceAsync?.save.mockImplementation(() => Promise.resolve());
15541649

1650+
mockBucket.mockImplementation((param: BucketerParams) => {
1651+
const ruleKey = param.experimentKey;
1652+
if (ruleKey == 'exp_3') {
1653+
return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
1654+
}
1655+
return {
1656+
result: null,
1657+
reasons: [],
1658+
}
1659+
});
1660+
15551661
cmabService.getDecision.mockResolvedValue({
15561662
variationId: '5003',
15571663
cmabUuid: 'uuid-test',
@@ -1605,6 +1711,16 @@ describe('DecisionService', () => {
16051711

16061712
userProfileServiceAsync?.save.mockRejectedValue(new Error('I am an error'));
16071713

1714+
mockBucket.mockImplementation((param: BucketerParams) => {
1715+
const ruleKey = param.experimentKey;
1716+
if (ruleKey == 'exp_3') {
1717+
return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
1718+
}
1719+
return {
1720+
result: null,
1721+
reasons: [],
1722+
}
1723+
});
16081724

16091725
cmabService.getDecision.mockResolvedValue({
16101726
variationId: '5003',
@@ -1669,6 +1785,16 @@ describe('DecisionService', () => {
16691785
userProfileServiceAsync?.lookup.mockResolvedValue(null);
16701786
userProfileServiceAsync?.save.mockResolvedValue(null);
16711787

1788+
mockBucket.mockImplementation((param: BucketerParams) => {
1789+
const ruleKey = param.experimentKey;
1790+
if (ruleKey == 'exp_3') {
1791+
return { result: param.trafficAllocationConfig[0].entityId, reasons: [] }
1792+
}
1793+
return {
1794+
result: null,
1795+
reasons: [],
1796+
}
1797+
});
16721798

16731799
cmabService.getDecision.mockResolvedValue({
16741800
variationId: '5003',

lib/core/decision_service/index.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ import {
2727
getExperimentFromId,
2828
getExperimentFromKey,
2929
getFlagVariationByKey,
30-
getTrafficAllocation,
3130
getVariationIdFromExperimentAndVariationKey,
3231
getVariationFromId,
3332
getVariationKeyFromId,
3433
isActive,
3534
ProjectConfig,
35+
getTrafficAllocation,
3636
} from '../../project_config/project_config';
3737
import { AudienceEvaluator, createAudienceEvaluator } from '../audience_evaluator';
3838
import * as stringValidator from '../../utils/string_value_validator';
@@ -44,6 +44,7 @@ import {
4444
FeatureFlag,
4545
OptimizelyDecideOption,
4646
OptimizelyUserContext,
47+
TrafficAllocation,
4748
UserAttributes,
4849
UserProfile,
4950
UserProfileService,
@@ -148,6 +149,9 @@ type VariationIdWithCmabParams = {
148149
cmabUuid?: string;
149150
};
150151
export type DecideOptionsMap = Partial<Record<OptimizelyDecideOption, boolean>>;
152+
153+
export const CMAB_DUMMY_ENTITY_ID= '$'
154+
151155
/**
152156
* Optimizely's decision service that determines which variation of an experiment the user will be allocated to.
153157
*
@@ -355,6 +359,23 @@ export class DecisionService {
355359
reasons: [[CMAB_NOT_SUPPORTED_IN_SYNC]],
356360
});
357361
}
362+
363+
const userId = user.getUserId();
364+
const attributes = user.getAttributes();
365+
366+
const bucketingId = this.getBucketingId(userId, attributes);
367+
const bucketerParams = this.buildBucketerParams(configObj, experiment, bucketingId, userId);
368+
369+
const bucketerResult = bucket(bucketerParams);
370+
371+
// this means the user is not in the cmab experiment
372+
if (bucketerResult.result !== CMAB_DUMMY_ENTITY_ID) {
373+
return Value.of(op, {
374+
error: false,
375+
result: {},
376+
reasons: bucketerResult.reasons,
377+
});
378+
}
358379

359380
const cmabPromise = this.cmabService.getDecision(configObj, user, experiment.id, decideOptions).then(
360381
(cmabDecision) => {
@@ -573,6 +594,14 @@ export class DecisionService {
573594
bucketingId: string,
574595
userId: string
575596
): BucketerParams {
597+
let trafficAllocationConfig: TrafficAllocation[] = getTrafficAllocation(configObj, experiment.id);
598+
if (experiment.cmab) {
599+
trafficAllocationConfig = [{
600+
entityId: CMAB_DUMMY_ENTITY_ID,
601+
endOfRange: experiment.cmab.trafficAllocation
602+
}];
603+
}
604+
576605
return {
577606
bucketingId,
578607
experimentId: experiment.id,
@@ -581,7 +610,7 @@ export class DecisionService {
581610
experimentKeyMap: configObj.experimentKeyMap,
582611
groupIdMap: configObj.groupIdMap,
583612
logger: this.logger,
584-
trafficAllocationConfig: getTrafficAllocation(configObj, experiment.id),
613+
trafficAllocationConfig,
585614
userId,
586615
variationIdMap: configObj.variationIdMap,
587616
}

lib/project_config/project_config.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,17 +249,20 @@ describe('createProjectConfig - cmab experiments', () => {
249249
it('should populate cmab field correctly', function() {
250250
const datafile = testDatafile.getTestProjectConfig();
251251
datafile.experiments[0].cmab = {
252-
attributes: ['808797688', '808797689'],
252+
attributeIds: ['808797688', '808797689'],
253+
trafficAllocation: 3141,
253254
};
254255

255256
datafile.experiments[2].cmab = {
256-
attributes: ['808797689'],
257+
attributeIds: ['808797689'],
258+
trafficAllocation: 1414,
257259
};
258260

259261
const configObj = projectConfig.createProjectConfig(datafile);
260262

261263
const experiment0 = configObj.experiments[0];
262264
expect(experiment0.cmab).toEqual({
265+
trafficAllocation: 3141,
263266
attributeIds: ['808797688', '808797689'],
264267
});
265268

@@ -268,6 +271,7 @@ describe('createProjectConfig - cmab experiments', () => {
268271

269272
const experiment2 = configObj.experiments[2];
270273
expect(experiment2.cmab).toEqual({
274+
trafficAllocation: 1414,
271275
attributeIds: ['808797689'],
272276
});
273277
});

lib/project_config/project_config.tests.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ describe('lib/core/project_config', function() {
416416
assert.equal(ex.baseMessage, INVALID_EXPERIMENT_ID);
417417
assert.deepEqual(ex.params, ['invalidExperimentId']);
418418
});
419-
419+
420420
describe('#getVariationIdFromExperimentAndVariationKey', function() {
421421
it('should return the variation id for the given experiment key and variation key', function() {
422422
assert.strictEqual(

lib/project_config/project_config.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -157,15 +157,6 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str
157157

158158
projectConfig.__datafileStr = datafileStr === null ? JSON.stringify(datafileObj) : datafileStr;
159159

160-
/** rename cmab.attributes field from the datafile to cmab.attributeIds for each experiment */
161-
projectConfig.experiments.forEach(experiment => {
162-
if (experiment.cmab) {
163-
const attributes = (experiment.cmab as any).attributes;
164-
delete (experiment.cmab as any).attributes;
165-
experiment.cmab.attributeIds = attributes;
166-
}
167-
});
168-
169160
/*
170161
* Conditions of audiences in projectConfig.typedAudiences are not
171162
* expected to be string-encoded as they are here in projectConfig.audiences.
@@ -568,6 +559,7 @@ export const getExperimentFromKey = function(projectConfig: ProjectConfig, exper
568559
throw new OptimizelyError(EXPERIMENT_KEY_NOT_IN_DATAFILE, experimentKey);
569560
};
570561

562+
571563
/**
572564
* Given an experiment id, returns the traffic allocation within that experiment
573565
* @param {ProjectConfig} projectConfig Object representing project configuration
@@ -890,7 +882,6 @@ export default {
890882
getVariationKeyFromId,
891883
getVariationIdFromExperimentAndVariationKey,
892884
getExperimentFromKey,
893-
getTrafficAllocation,
894885
getExperimentFromId,
895886
getFlagVariationByKey,
896887
getFeatureFromKey,
@@ -904,4 +895,5 @@ export default {
904895
isFeatureExperiment,
905896
toDatafile,
906897
tryCreatingProjectConfig,
898+
getTrafficAllocation,
907899
};

lib/shared_types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export interface Experiment {
159159
forcedVariations?: { [key: string]: string };
160160
isRollout?: boolean;
161161
cmab?: {
162+
trafficAllocation: number;
162163
attributeIds: string[];
163164
};
164165
}

0 commit comments

Comments
 (0)