Skip to content

Commit 056feda

Browse files
committed
[FSSDK-11526] parse holdout from datafile into project config
also add getHoldoutsForFlag() function
1 parent a7b62d9 commit 056feda

File tree

5 files changed

+292
-6
lines changed

5 files changed

+292
-6
lines changed

lib/feature_toggle.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Copyright 2025, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* This module contains feature flags that control the availability of features under development.
19+
* Each flag represents a feature that is not yet ready for production release. These flags
20+
* serve multiple purposes in our development workflow:
21+
*
22+
* When a new feature is in development, it can be safely merged into the main branch
23+
* while remaining disabled in production. This allows continuous integration without
24+
* affecting the stability of production releases. The feature code will be automatically
25+
* removed in production builds through tree-shaking when the flag is disabled.
26+
*
27+
* During development and testing, these flags can be easily mocked to enable/disable
28+
* specific features. Once a feature is complete and ready for release, its corresponding
29+
* flag and all associated checks can be removed from the codebase.
30+
*/
31+
32+
export const holdout = () => false;

lib/index.browser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { getOptimizelyInstance } from './client_factory';
1919
import { EventDispatcher } from './event_processor/event_dispatcher/event_dispatcher';
2020
import { JAVASCRIPT_CLIENT_ENGINE } from './utils/enums';
2121
import { BrowserRequestHandler } from './utils/http_request_handler/request_handler.browser';
22+
import * as featureToggle from './feature_toggle';
2223

2324
/**
2425
* Creates an instance of the Optimizely class

lib/project_config/project_config.spec.ts

Lines changed: 184 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
import { describe, it, expect, beforeEach, afterEach, vi, assert, Mock } from 'vitest';
16+
import { describe, it, expect, beforeEach, afterEach, vi, assert, Mock, beforeAll, afterAll } from 'vitest';
1717
import { sprintf } from '../utils/fns';
1818
import { keyBy } from '../utils/fns';
19-
import projectConfig, { ProjectConfig, Region } from './project_config';
19+
import projectConfig, { ProjectConfig, getHoldoutsForFlag } from './project_config';
2020
import { FEATURE_VARIABLE_TYPES, LOG_LEVEL } from '../utils/enums';
2121
import testDatafile from '../tests/test_data';
2222
import configValidator from '../utils/config_validator';
@@ -32,11 +32,20 @@ import {
3232
import { getMockLogger } from '../tests/mock/mock_logger';
3333
import { VariableType } from '../shared_types';
3434
import { OptimizelyError } from '../error/optimizly_error';
35+
import { mock } from 'node:test';
3536

3637
const buildLogMessageFromArgs = (args: any[]) => sprintf(args[1], ...args.splice(2));
3738
const cloneDeep = (obj: any) => JSON.parse(JSON.stringify(obj));
3839
const logger = getMockLogger();
3940

41+
const mockHoldoutToggle = vi.hoisted(() => vi.fn());
42+
43+
vi.mock('../feature_toggle', () => {
44+
return {
45+
holdout: mockHoldoutToggle,
46+
};
47+
});
48+
4049
describe('createProjectConfig', () => {
4150
let configObj: ProjectConfig;
4251

@@ -298,6 +307,179 @@ describe('createProjectConfig - cmab experiments', () => {
298307
});
299308
});
300309

310+
const getHoldoutDatafile = () => {
311+
const datafile = testDatafile.getTestDecideProjectConfig();
312+
313+
// Add holdouts to the datafile
314+
datafile.holdouts = [
315+
{
316+
id: 'holdout_id_1',
317+
key: 'holdout_1',
318+
status: 'Running',
319+
includeFlags: [],
320+
excludeFlags: [],
321+
audienceIds: ['13389130056'],
322+
audienceConditions: ['or', '13389130056'],
323+
variations: [
324+
{
325+
id: 'var_id_1',
326+
key: 'holdout_variation_1',
327+
variables: []
328+
}
329+
],
330+
trafficAllocation: [
331+
{
332+
entityId: 'var_id_1',
333+
endOfRange: 5000
334+
}
335+
]
336+
},
337+
{
338+
id: 'holdout_id_2',
339+
key: 'holdout_2',
340+
status: 'Running',
341+
includeFlags: [],
342+
excludeFlags: ['feature_3'],
343+
audienceIds: [],
344+
audienceConditions: [],
345+
variations: [
346+
{
347+
id: 'var_id_2',
348+
key: 'holdout_variation_2',
349+
variables: []
350+
}
351+
],
352+
trafficAllocation: [
353+
{
354+
entityId: 'var_id_2',
355+
endOfRange: 1000
356+
}
357+
]
358+
},
359+
{
360+
id: 'holdout_id_3',
361+
key: 'holdout_3',
362+
status: 'Draft',
363+
includeFlags: ['feature_1'],
364+
excludeFlags: [],
365+
audienceIds: [],
366+
audienceConditions: [],
367+
variations: [
368+
{
369+
id: 'var_id_2',
370+
key: 'holdout_variation_2',
371+
variables: []
372+
}
373+
],
374+
trafficAllocation: [
375+
{
376+
entityId: 'var_id_2',
377+
endOfRange: 1000
378+
}
379+
]
380+
}
381+
];
382+
383+
return datafile;
384+
}
385+
386+
describe('createProjectConfig - holdouts, feature toggle is on', () => {
387+
beforeAll(() => {
388+
mockHoldoutToggle.mockReturnValue(true);
389+
});
390+
391+
afterAll(() => {
392+
mockHoldoutToggle.mockReset();
393+
});
394+
395+
it('should populate holdouts fields correctly', function() {
396+
const datafile = getHoldoutDatafile();
397+
398+
mockHoldoutToggle.mockReturnValue(true);
399+
400+
const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile)));
401+
402+
expect(configObj.holdouts).toHaveLength(3);
403+
configObj.holdouts.forEach((holdout, i) => {
404+
expect(holdout).toEqual(expect.objectContaining(datafile.holdouts[i]));
405+
expect(holdout.variationKeyMap).toEqual(
406+
keyBy(datafile.holdouts[i].variations, 'key')
407+
);
408+
});
409+
410+
expect(configObj.holdoutIdMap).toEqual({
411+
holdout_id_1: configObj.holdouts[0],
412+
holdout_id_2: configObj.holdouts[1],
413+
holdout_id_3: configObj.holdouts[2],
414+
});
415+
416+
expect(configObj.globalHoldouts).toHaveLength(2);
417+
expect(configObj.globalHoldouts).toEqual([
418+
configObj.holdouts[0], // holdout_1 has empty includeFlags
419+
configObj.holdouts[1] // holdout_2 has empty includeFlags
420+
]);
421+
422+
expect(configObj.includedHoldouts).toEqual({
423+
feature_1: [configObj.holdouts[2]], // holdout_3 includes feature_1
424+
});
425+
426+
expect(configObj.excludedHoldouts).toEqual({
427+
feature_3: [configObj.holdouts[1]] // holdout_2 excludes feature_3
428+
});
429+
430+
expect(configObj.flagHoldoutsMap).toEqual({});
431+
});
432+
433+
it('should handle empty holdouts array', function() {
434+
const datafile = testDatafile.getTestProjectConfig();
435+
436+
const configObj = projectConfig.createProjectConfig(datafile);
437+
438+
expect(configObj.holdouts).toEqual([]);
439+
expect(configObj.holdoutIdMap).toEqual({});
440+
expect(configObj.globalHoldouts).toEqual([]);
441+
expect(configObj.includedHoldouts).toEqual({});
442+
expect(configObj.excludedHoldouts).toEqual({});
443+
expect(configObj.flagHoldoutsMap).toEqual({});
444+
});
445+
});
446+
447+
describe('getHoldoutsForFlag: feature toggle is on', () => {
448+
beforeAll(() => {
449+
mockHoldoutToggle.mockReturnValue(true);
450+
});
451+
452+
afterAll(() => {
453+
mockHoldoutToggle.mockReset();
454+
});
455+
456+
it('should return all applicable holdouts for a flag', () => {
457+
const datafile = getHoldoutDatafile();
458+
const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile)));
459+
460+
const feature1Holdouts = getHoldoutsForFlag(configObj, 'feature_1');
461+
expect(feature1Holdouts).toHaveLength(3);
462+
expect(feature1Holdouts).toEqual([
463+
configObj.holdouts[0],
464+
configObj.holdouts[1],
465+
configObj.holdouts[2],
466+
]);
467+
468+
const feature2Holdouts = getHoldoutsForFlag(configObj, 'feature_2');
469+
expect(feature2Holdouts).toHaveLength(2);
470+
expect(feature2Holdouts).toEqual([
471+
configObj.holdouts[0],
472+
configObj.holdouts[1],
473+
]);
474+
475+
const feature3Holdouts = getHoldoutsForFlag(configObj, 'feature_3');
476+
expect(feature3Holdouts).toHaveLength(1);
477+
expect(feature3Holdouts).toEqual([
478+
configObj.holdouts[0],
479+
]);
480+
});
481+
});
482+
301483
describe('getExperimentId', () => {
302484
let testData: Record<string, any>;
303485
let configObj: ProjectConfig;

lib/project_config/project_config.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
VariationVariable,
3535
Integration,
3636
FeatureVariableValue,
37+
Holdout,
3738
} from '../shared_types';
3839
import { OdpConfig, OdpIntegrationConfig } from '../odp/odp_config';
3940
import { Transformer } from '../utils/type';
@@ -51,6 +52,7 @@ import {
5152
} from 'error_message';
5253
import { SKIPPING_JSON_VALIDATION, VALID_DATAFILE } from 'log_message';
5354
import { OptimizelyError } from '../error/optimizly_error';
55+
import * as featureToggle from '../feature_toggle';
5456

5557
interface TryCreatingProjectConfigConfig {
5658
// TODO[OASIS-6649]: Don't use object type
@@ -110,6 +112,12 @@ export interface ProjectConfig {
110112
integrations: Integration[];
111113
integrationKeyMap?: { [key: string]: Integration };
112114
odpIntegrationConfig: OdpIntegrationConfig;
115+
holdouts: Holdout[];
116+
holdoutIdMap?: { [id: string]: Holdout };
117+
globalHoldouts: Holdout[];
118+
includedHoldouts: { [key: string]: Holdout[]; }
119+
excludedHoldouts: { [key: string]: Holdout[]; }
120+
flagHoldoutsMap: { [key: string]: Holdout[]; }
113121
}
114122

115123
const EXPERIMENT_RUNNING_STATUS = 'Running';
@@ -335,9 +343,61 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str
335343
projectConfig.flagVariationsMap[flagKey] = variations;
336344
});
337345

346+
parseHoldoutsConfig(projectConfig);
347+
338348
return projectConfig;
339349
};
340350

351+
const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => {
352+
if (!featureToggle.holdout()) {
353+
return;
354+
}
355+
356+
projectConfig.holdouts = projectConfig.holdouts || [];
357+
projectConfig.holdoutIdMap = keyBy(projectConfig.holdouts, 'id');
358+
projectConfig.globalHoldouts = [];
359+
projectConfig.includedHoldouts = {};
360+
projectConfig.excludedHoldouts = {};
361+
projectConfig.flagHoldoutsMap = {};
362+
363+
projectConfig.holdouts.forEach((holdout) => {
364+
holdout.variationKeyMap = keyBy(holdout.variations, 'key');
365+
if (holdout.includeFlags.length === 0) {
366+
projectConfig.globalHoldouts.push(holdout);
367+
368+
holdout.excludeFlags.forEach((flagKey) => {
369+
if (!projectConfig.excludedHoldouts[flagKey]) {
370+
projectConfig.excludedHoldouts[flagKey] = [];
371+
}
372+
projectConfig.excludedHoldouts[flagKey].push(holdout);
373+
});
374+
} else {
375+
holdout.includeFlags.forEach((flagKey) => {
376+
if (!projectConfig.includedHoldouts[flagKey]) {
377+
projectConfig.includedHoldouts[flagKey] = [];
378+
}
379+
projectConfig.includedHoldouts[flagKey].push(holdout);
380+
});
381+
}
382+
});
383+
}
384+
385+
export const getHoldoutsForFlag = (projectConfig: ProjectConfig, flagKey: string): Holdout[] => {
386+
if (projectConfig.flagHoldoutsMap[flagKey]) {
387+
return projectConfig.flagHoldoutsMap[flagKey];
388+
}
389+
390+
const flagHoldouts: Holdout[] = [
391+
...projectConfig.globalHoldouts.filter((holdout) => {
392+
return !(projectConfig.excludedHoldouts[flagKey] || []).includes(holdout);
393+
}),
394+
...(projectConfig.includedHoldouts[flagKey] || []),
395+
];
396+
397+
projectConfig.flagHoldoutsMap[flagKey] = flagHoldouts;
398+
return flagHoldouts;
399+
}
400+
341401
/**
342402
* Extract all audience segments used in this audience's conditions
343403
* @param {Audience} audience Object representing the audience being parsed

lib/shared_types.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,17 +151,20 @@ export interface Variation {
151151
variables?: VariationVariable[];
152152
}
153153

154-
export interface Experiment {
154+
export interface ExperimentCore {
155155
id: string;
156156
key: string;
157157
variations: Variation[];
158158
variationKeyMap: { [key: string]: Variation };
159-
groupId?: string;
160-
layerId: string;
161-
status: string;
162159
audienceConditions: Array<string | string[]>;
163160
audienceIds: string[];
164161
trafficAllocation: TrafficAllocation[];
162+
}
163+
164+
export interface Experiment extends ExperimentCore {
165+
layerId: string;
166+
groupId?: string;
167+
status: string;
165168
forcedVariations?: { [key: string]: string };
166169
isRollout?: boolean;
167170
cmab?: {
@@ -170,6 +173,14 @@ export interface Experiment {
170173
};
171174
}
172175

176+
export type HoldoutStatus = 'Draft' | 'Running' | 'Concluded' | 'Archived';
177+
178+
export interface Holdout extends ExperimentCore {
179+
status: HoldoutStatus;
180+
includeFlags: string[];
181+
excludeFlags: string[];
182+
}
183+
173184
export enum VariableType {
174185
BOOLEAN = 'boolean',
175186
DOUBLE = 'double',

0 commit comments

Comments
 (0)