Skip to content

Commit bb41c0b

Browse files
committed
[FSSDK-11399] support traffic allocation for cmab
1 parent 85c0220 commit bb41c0b

File tree

6 files changed

+166
-48
lines changed

6 files changed

+166
-48
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,7 +27,6 @@ import {
2727
getExperimentFromId,
2828
getExperimentFromKey,
2929
getFlagVariationByKey,
30-
getTrafficAllocation,
3130
getVariationIdFromExperimentAndVariationKey,
3231
getVariationFromId,
3332
getVariationKeyFromId,
@@ -44,6 +43,7 @@ import {
4443
FeatureFlag,
4544
OptimizelyDecideOption,
4645
OptimizelyUserContext,
46+
TrafficAllocation,
4747
UserAttributes,
4848
UserProfile,
4949
UserProfileService,
@@ -148,6 +148,9 @@ type VariationIdWithCmabParams = {
148148
cmabUuid?: string;
149149
};
150150
export type DecideOptionsMap = Partial<Record<OptimizelyDecideOption, boolean>>;
151+
152+
export const CMAB_DUMMY_ENTITY_ID= '$'
153+
151154
/**
152155
* Optimizely's decision service that determines which variation of an experiment the user will be allocated to.
153156
*
@@ -355,6 +358,24 @@ export class DecisionService {
355358
reasons: [[CMAB_NOT_SUPPORTED_IN_SYNC]],
356359
});
357360
}
361+
362+
const userId = user.getUserId();
363+
const attributes = user.getAttributes();
364+
365+
// by default, the bucketing ID should be the user ID
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[] = experiment.trafficAllocation;
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: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -250,16 +250,19 @@ describe('createProjectConfig - cmab experiments', () => {
250250
const datafile = testDatafile.getTestProjectConfig();
251251
datafile.experiments[0].cmab = {
252252
attributes: ['808797688', '808797689'],
253+
trafficAllocation: 3141,
253254
};
254255

255256
datafile.experiments[2].cmab = {
256257
attributes: ['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
});
@@ -453,32 +457,6 @@ describe('getVariationKeyFromId', () => {
453457
});
454458
});
455459

456-
describe('getTrafficAllocation', () => {
457-
let testData: Record<string, any>;
458-
let configObj: ProjectConfig;
459-
460-
beforeEach(function() {
461-
testData = cloneDeep(testDatafile.getTestProjectConfig());
462-
configObj = projectConfig.createProjectConfig(cloneDeep(testData) as JSON);
463-
});
464-
465-
it('should retrieve traffic allocation given valid experiment key in getTrafficAllocation', function() {
466-
expect(projectConfig.getTrafficAllocation(configObj, testData.experiments[0].id)).toEqual(
467-
testData.experiments[0].trafficAllocation
468-
);
469-
});
470-
471-
it('should throw error for invalid experient key in getTrafficAllocation', function() {
472-
expect(() => {
473-
projectConfig.getTrafficAllocation(configObj, 'invalidExperimentId');
474-
}).toThrowError(
475-
expect.objectContaining({
476-
baseMessage: INVALID_EXPERIMENT_ID,
477-
params: ['invalidExperimentId'],
478-
})
479-
);
480-
});
481-
});
482460

483461
describe('getVariationIdFromExperimentAndVariationKey', () => {
484462
let testData: Record<string, any>;

lib/project_config/project_config.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -568,21 +568,6 @@ export const getExperimentFromKey = function(projectConfig: ProjectConfig, exper
568568
throw new OptimizelyError(EXPERIMENT_KEY_NOT_IN_DATAFILE, experimentKey);
569569
};
570570

571-
/**
572-
* Given an experiment id, returns the traffic allocation within that experiment
573-
* @param {ProjectConfig} projectConfig Object representing project configuration
574-
* @param {string} experimentId Id representing the experiment
575-
* @return {TrafficAllocation[]} Traffic allocation for the experiment
576-
* @throws If experiment key is not in datafile
577-
*/
578-
export const getTrafficAllocation = function(projectConfig: ProjectConfig, experimentId: string): TrafficAllocation[] {
579-
const experiment = projectConfig.experimentIdMap[experimentId];
580-
if (!experiment) {
581-
throw new OptimizelyError(INVALID_EXPERIMENT_ID, experimentId);
582-
}
583-
return experiment.trafficAllocation;
584-
};
585-
586571
/**
587572
* Get experiment from provided experiment id. Log an error if no experiment
588573
* exists in the project config with the given ID.
@@ -890,7 +875,6 @@ export default {
890875
getVariationKeyFromId,
891876
getVariationIdFromExperimentAndVariationKey,
892877
getExperimentFromKey,
893-
getTrafficAllocation,
894878
getExperimentFromId,
895879
getFlagVariationByKey,
896880
getFeatureFromKey,

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
}

vitest.config.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default defineConfig({
2626
test: {
2727
onConsoleLog: () => true,
2828
environment: 'happy-dom',
29-
include: ['**/*.spec.ts'],
29+
include: ['**/decision_service/index.spec.ts'],
3030
typecheck: {
3131
enabled: true,
3232
tsconfig: 'tsconfig.spec.json',

0 commit comments

Comments
 (0)