Skip to content

Commit 41d2580

Browse files
[AI-FSSDK] [FSSDK-12337] Add Feature Rollout support (#1140)
## Summary Adds Feature Rollout support to the JavaScript SDK. Feature Rollouts are a new experiment rule type that combines Targeted Delivery simplicity with A/B test measurement capabilities. During project config parsing, the "everyone else" variation from the flag's rollout is injected into any experiment with type "feature_rollout", enabling correct evaluation without changes to decision logic. ## Changes - Added optional `type` string field to the Experiment interface - Added config parsing logic to inject the "everyone else" rollout variation into feature_rollout experiments - Added traffic allocation entry (endOfRange=10000) for the injected variation - Added `getEveryoneElseVariation` helper function to extract the last rollout rule's first variation - Updated variation lookup maps (variationKeyMap, variationIdMap, variationVariableUsageMap) with injected variation - Added 6 unit tests covering feature rollout injection, edge cases, and backward compatibility --------- Co-authored-by: Raju Ahmed <raju.ahmed@optimizely.com>
1 parent cde46e9 commit 41d2580

File tree

4 files changed

+214
-1
lines changed

4 files changed

+214
-1
lines changed

lib/project_config/project_config.spec.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,3 +1324,163 @@ describe('tryCreatingProjectConfig', () => {
13241324
expect(logger.error).not.toHaveBeenCalled();
13251325
});
13261326
});
1327+
1328+
describe('Feature Rollout support', () => {
1329+
const makeDatafile = (overrides: Record<string, any> = {}) => {
1330+
const base: Record<string, any> = {
1331+
version: '4',
1332+
revision: '1',
1333+
projectId: 'rollout_test',
1334+
accountId: '12345',
1335+
sdkKey: 'test-key',
1336+
environmentKey: 'production',
1337+
events: [],
1338+
audiences: [],
1339+
typedAudiences: [],
1340+
attributes: [],
1341+
groups: [],
1342+
integrations: [],
1343+
holdouts: [],
1344+
experiments: [
1345+
{
1346+
id: 'exp_ab',
1347+
key: 'ab_experiment',
1348+
layerId: 'layer_ab',
1349+
status: 'Running',
1350+
variations: [{ id: 'var_ab_1', key: 'variation_ab_1', variables: [] }],
1351+
trafficAllocation: [{ entityId: 'var_ab_1', endOfRange: 10000 }],
1352+
audienceIds: [],
1353+
audienceConditions: [],
1354+
forcedVariations: {},
1355+
},
1356+
{
1357+
id: 'exp_rollout',
1358+
key: 'rollout_experiment',
1359+
layerId: 'layer_rollout',
1360+
status: 'Running',
1361+
type: 'fr',
1362+
variations: [{ id: 'var_rollout_1', key: 'variation_rollout_1', variables: [] }],
1363+
trafficAllocation: [{ entityId: 'var_rollout_1', endOfRange: 5000 }],
1364+
audienceIds: [],
1365+
audienceConditions: [],
1366+
forcedVariations: {},
1367+
},
1368+
],
1369+
rollouts: [
1370+
{
1371+
id: 'rollout_1',
1372+
experiments: [
1373+
{
1374+
id: 'rollout_rule_1',
1375+
key: 'rollout_rule_1_key',
1376+
layerId: 'rollout_layer_1',
1377+
status: 'Running',
1378+
variations: [{ id: 'var_rr1', key: 'variation_rr1', variables: [] }],
1379+
trafficAllocation: [{ entityId: 'var_rr1', endOfRange: 10000 }],
1380+
audienceIds: [],
1381+
audienceConditions: [],
1382+
forcedVariations: {},
1383+
},
1384+
{
1385+
id: 'rollout_everyone_else',
1386+
key: 'rollout_everyone_else_key',
1387+
layerId: 'rollout_layer_ee',
1388+
status: 'Running',
1389+
variations: [{ id: 'var_ee', key: 'variation_everyone_else', variables: [] }],
1390+
trafficAllocation: [{ entityId: 'var_ee', endOfRange: 10000 }],
1391+
audienceIds: [],
1392+
audienceConditions: [],
1393+
forcedVariations: {},
1394+
},
1395+
],
1396+
},
1397+
],
1398+
featureFlags: [
1399+
{
1400+
id: 'feature_1',
1401+
key: 'feature_rollout_flag',
1402+
rolloutId: 'rollout_1',
1403+
experimentIds: ['exp_ab', 'exp_rollout'],
1404+
variables: [],
1405+
},
1406+
],
1407+
...overrides,
1408+
};
1409+
return base;
1410+
};
1411+
1412+
it('should preserve type=undefined for experiments without type field (backward compatibility)', () => {
1413+
const datafile = makeDatafile();
1414+
const config = projectConfig.createProjectConfig(datafile as any);
1415+
const abExperiment = config.experimentIdMap['exp_ab'];
1416+
expect(abExperiment.type).toBeUndefined();
1417+
});
1418+
1419+
it('should inject everyone else variation into fr (feature rollout) experiments', () => {
1420+
const datafile = makeDatafile();
1421+
const config = projectConfig.createProjectConfig(datafile as any);
1422+
const rolloutExperiment = config.experimentIdMap['exp_rollout'];
1423+
1424+
// Should have 2 variations: original + injected everyone else
1425+
expect(rolloutExperiment.variations).toHaveLength(2);
1426+
expect(rolloutExperiment.variations[1].id).toBe('var_ee');
1427+
expect(rolloutExperiment.variations[1].key).toBe('variation_everyone_else');
1428+
1429+
// Should have injected traffic allocation entry
1430+
const lastAllocation = rolloutExperiment.trafficAllocation[rolloutExperiment.trafficAllocation.length - 1];
1431+
expect(lastAllocation.entityId).toBe('var_ee');
1432+
expect(lastAllocation.endOfRange).toBe(10000);
1433+
});
1434+
1435+
it('should update variation lookup maps with injected variation', () => {
1436+
const datafile = makeDatafile();
1437+
const config = projectConfig.createProjectConfig(datafile as any);
1438+
const rolloutExperiment = config.experimentIdMap['exp_rollout'];
1439+
1440+
// variationKeyMap on the experiment should contain the injected variation
1441+
expect(rolloutExperiment.variationKeyMap['variation_everyone_else']).toBeDefined();
1442+
expect(rolloutExperiment.variationKeyMap['variation_everyone_else'].id).toBe('var_ee');
1443+
1444+
// Global variationIdMap should contain the injected variation
1445+
expect(config.variationIdMap['var_ee']).toBeDefined();
1446+
expect(config.variationIdMap['var_ee'].key).toBe('variation_everyone_else');
1447+
});
1448+
1449+
it('should not modify non-rollout experiments (A/B, MAB, CMAB)', () => {
1450+
const datafile = makeDatafile();
1451+
const config = projectConfig.createProjectConfig(datafile as any);
1452+
const abExperiment = config.experimentIdMap['exp_ab'];
1453+
1454+
// A/B experiment should still have only 1 variation
1455+
expect(abExperiment.variations).toHaveLength(1);
1456+
expect(abExperiment.variations[0].id).toBe('var_ab_1');
1457+
expect(abExperiment.trafficAllocation).toHaveLength(1);
1458+
});
1459+
1460+
it('should silently skip injection when feature has no rolloutId', () => {
1461+
const datafile = makeDatafile({
1462+
featureFlags: [
1463+
{
1464+
id: 'feature_no_rollout',
1465+
key: 'feature_no_rollout',
1466+
rolloutId: '',
1467+
experimentIds: ['exp_rollout'],
1468+
variables: [],
1469+
},
1470+
],
1471+
});
1472+
const config = projectConfig.createProjectConfig(datafile as any);
1473+
const rolloutExperiment = config.experimentIdMap['exp_rollout'];
1474+
1475+
// Should still have only 1 variation (no injection)
1476+
expect(rolloutExperiment.variations).toHaveLength(1);
1477+
expect(rolloutExperiment.variations[0].id).toBe('var_rollout_1');
1478+
});
1479+
1480+
it('should correctly preserve experiment type field from datafile', () => {
1481+
const datafile = makeDatafile();
1482+
const config = projectConfig.createProjectConfig(datafile as any);
1483+
const rolloutExperiment = config.experimentIdMap['exp_rollout'];
1484+
expect(rolloutExperiment.type).toBe('fr');
1485+
});
1486+
});

lib/project_config/project_config.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616
import { find, objectEntries, objectValues, keyBy, assignBy } from '../utils/fns';
1717

18-
import { FEATURE_VARIABLE_TYPES } from '../utils/enums';
18+
import { EXPERIMENT_TYPES, FEATURE_VARIABLE_TYPES } from '../utils/enums';
1919
import configValidator from '../utils/config_validator';
2020

2121
import { LoggerFacade } from '../logging/logger';
@@ -301,6 +301,28 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str
301301
});
302302
});
303303

304+
// Inject "everyone else" variation into feature rollout (FR) experiments
305+
(projectConfig.featureFlags || []).forEach(featureFlag => {
306+
const everyoneElseVariation = getEveryoneElseVariation(projectConfig, featureFlag);
307+
if (!everyoneElseVariation) {
308+
return;
309+
}
310+
311+
(featureFlag.experimentIds || []).forEach(experimentId => {
312+
const experiment = projectConfig.experimentIdMap[experimentId];
313+
if (experiment && experiment.type === EXPERIMENT_TYPES.FR) {
314+
experiment.variations.push(everyoneElseVariation);
315+
experiment.trafficAllocation.push({
316+
entityId: everyoneElseVariation.id,
317+
endOfRange: 10000,
318+
});
319+
320+
// Update variation lookup map
321+
experiment.variationKeyMap[everyoneElseVariation.key] = everyoneElseVariation;
322+
}
323+
});
324+
});
325+
304326
// all rules (experiment rules and delivery rules) for each flag
305327
projectConfig.flagRulesMap = {};
306328

@@ -343,6 +365,28 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str
343365
return projectConfig;
344366
};
345367

368+
/**
369+
* Get the "everyone else" variation from the last rule in the flag's rollout.
370+
* Returns null if the rollout cannot be resolved or has no variations.
371+
*/
372+
const getEveryoneElseVariation = function(
373+
projectConfig: ProjectConfig,
374+
featureFlag: FeatureFlag,
375+
): Variation | null {
376+
if (!featureFlag.rolloutId) {
377+
return null;
378+
}
379+
const rollout = projectConfig.rolloutIdMap[featureFlag.rolloutId];
380+
if (!rollout || !rollout.experiments || rollout.experiments.length === 0) {
381+
return null;
382+
}
383+
const everyoneElseRule = rollout.experiments[rollout.experiments.length - 1];
384+
if (!everyoneElseRule.variations || everyoneElseRule.variations.length === 0) {
385+
return null;
386+
}
387+
return everyoneElseRule.variations[0];
388+
};
389+
346390
const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => {
347391
projectConfig.holdouts = projectConfig.holdouts || [];
348392
projectConfig.holdoutIdMap = keyBy(projectConfig.holdouts, 'id');

lib/shared_types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export interface Experiment extends ExperimentCore {
163163
status: string;
164164
forcedVariations?: { [key: string]: string };
165165
isRollout?: boolean;
166+
type?: string;
166167
cmab?: {
167168
trafficAllocation: number;
168169
attributeIds: string[];

lib/utils/enums/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ export const DECISION_SOURCES = {
6161

6262
export type DecisionSource = typeof DECISION_SOURCES[keyof typeof DECISION_SOURCES];
6363

64+
export const EXPERIMENT_TYPES = {
65+
AB: 'ab',
66+
MAB: 'mab',
67+
CMAB: 'cmab',
68+
TD: 'td',
69+
FR: 'fr',
70+
} as const;
71+
6472
export const AUDIENCE_EVALUATION_TYPES = {
6573
RULE: 'rule',
6674
EXPERIMENT: 'experiment',

0 commit comments

Comments
 (0)