Skip to content

Commit 6412e89

Browse files
decision service holdout implementation
1 parent 51438cf commit 6412e89

File tree

4 files changed

+125
-7
lines changed

4 files changed

+125
-7
lines changed

lib/core/decision_service/index.ts

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
isActive,
3434
ProjectConfig,
3535
getTrafficAllocation,
36+
getHoldoutsForFlag,
3637
} from '../../project_config/project_config';
3738
import { AudienceEvaluator, createAudienceEvaluator } from '../audience_evaluator';
3839
import * as stringValidator from '../../utils/string_value_validator';
@@ -41,7 +42,9 @@ import {
4142
DecisionResponse,
4243
Experiment,
4344
ExperimentBucketMap,
45+
ExperimentCore,
4446
FeatureFlag,
47+
Holdout,
4548
OptimizelyDecideOption,
4649
OptimizelyUserContext,
4750
TrafficAllocation,
@@ -75,6 +78,7 @@ import { OptimizelyError } from '../../error/optimizly_error';
7578
import { CmabService } from './cmab/cmab_service';
7679
import { Maybe, OpType, OpValue } from '../../utils/type';
7780
import { Value } from '../../utils/promise/operation_value';
81+
import { holdout } from '../../feature_toggle';
7882

7983
export const EXPERIMENT_NOT_RUNNING = 'Experiment %s is not running.';
8084
export const RETURNING_STORED_VARIATION =
@@ -112,9 +116,14 @@ export const USER_HAS_FORCED_DECISION_WITH_NO_RULE_SPECIFIED_BUT_INVALID =
112116
export const CMAB_NOT_SUPPORTED_IN_SYNC = 'CMAB is not supported in sync mode.';
113117
export const CMAB_FETCH_FAILED = 'Failed to fetch CMAB data for experiment %s.';
114118
export const CMAB_FETCHED_VARIATION_INVALID = 'Fetched variation %s for cmab experiment %s is invalid.';
119+
export const HOLDOUT_NOT_RUNNING = 'Holdout %s is not running.';
120+
export const USER_MEETS_CONDITIONS_FOR_HOLDOUT = 'User %s meets conditions for holdout %s.';
121+
export const USER_DOESNT_MEET_CONDITIONS_FOR_HOLDOUT = 'User %s does not meet conditions for holdout %s.';
122+
export const USER_BUCKETED_INTO_HOLDOUT_VARIATION = 'User %s is in variation %s of holdout %s.';
123+
export const USER_NOT_BUCKETED_INTO_HOLDOUT_VARIATION = 'User %s is in no holdout variation.';
115124

116125
export interface DecisionObj {
117-
experiment: Experiment | null;
126+
experiment: ExperimentCore | null;
118127
variation: Variation | null;
119128
decisionSource: DecisionSource;
120129
cmabUuid?: string;
@@ -540,7 +549,7 @@ export class DecisionService {
540549
*/
541550
private checkIfUserIsInAudience(
542551
configObj: ProjectConfig,
543-
experiment: Experiment,
552+
experiment: ExperimentCore,
544553
evaluationAttribute: string,
545554
user: OptimizelyUserContext,
546555
loggingKey?: string | number,
@@ -590,14 +599,14 @@ export class DecisionService {
590599
*/
591600
private buildBucketerParams(
592601
configObj: ProjectConfig,
593-
experiment: Experiment,
602+
experiment: Experiment | Holdout,
594603
bucketingId: string,
595604
userId: string
596605
): BucketerParams {
597606
let validateEntity = true;
598607

599608
let trafficAllocationConfig: TrafficAllocation[] = getTrafficAllocation(configObj, experiment.id);
600-
if (experiment.cmab) {
609+
if ('cmab' in experiment && experiment.cmab) {
601610
trafficAllocationConfig = [{
602611
entityId: CMAB_DUMMY_ENTITY_ID,
603612
endOfRange: experiment.cmab.trafficAllocation
@@ -621,6 +630,99 @@ export class DecisionService {
621630
}
622631
}
623632

633+
/**
634+
* Determines if a user should be bucketed into a holdout variation.
635+
* @param {ProjectConfig} configObj - The parsed project configuration object.
636+
* @param {Holdout} holdout - The holdout to evaluate.
637+
* @param {OptimizelyUserContext} user - The user context.
638+
* @returns {DecisionResponse<DecisionObj>} - DecisionResponse containing holdout decision and reasons.
639+
*/
640+
private getVariationForHoldout(
641+
configObj: ProjectConfig,
642+
holdout: Holdout,
643+
user: OptimizelyUserContext,
644+
): DecisionResponse<DecisionObj> {
645+
const userId = user.getUserId();
646+
const decideReasons: DecisionReason[] = [];
647+
648+
if (holdout.status !== 'Running') {
649+
const reason: DecisionReason = [HOLDOUT_NOT_RUNNING, holdout.key];
650+
decideReasons.push(reason);
651+
this.logger?.info(HOLDOUT_NOT_RUNNING, holdout.key);
652+
return {
653+
result: {
654+
experiment: null,
655+
variation: null,
656+
decisionSource: DECISION_SOURCES.HOLDOUT
657+
},
658+
reasons: decideReasons
659+
};
660+
}
661+
662+
const audienceResult = this.checkIfUserIsInAudience(
663+
configObj,
664+
holdout,
665+
AUDIENCE_EVALUATION_TYPES.EXPERIMENT,
666+
user
667+
);
668+
decideReasons.push(...audienceResult.reasons);
669+
670+
if (!audienceResult.result) {
671+
const reason: DecisionReason = [USER_DOESNT_MEET_CONDITIONS_FOR_HOLDOUT, userId, holdout.key];
672+
decideReasons.push(reason);
673+
this.logger?.info(USER_DOESNT_MEET_CONDITIONS_FOR_HOLDOUT, userId, holdout.key);
674+
return {
675+
result: {
676+
experiment: null,
677+
variation: null,
678+
decisionSource: DECISION_SOURCES.HOLDOUT
679+
},
680+
reasons: decideReasons
681+
};
682+
}
683+
684+
const reason: DecisionReason = [USER_MEETS_CONDITIONS_FOR_HOLDOUT, userId, holdout.key];
685+
decideReasons.push(reason);
686+
this.logger?.info(USER_MEETS_CONDITIONS_FOR_HOLDOUT, userId, holdout.key);
687+
688+
const attributes = user.getAttributes();
689+
const bucketingId = this.getBucketingId(userId, attributes);
690+
const bucketerParams = this.buildBucketerParams(configObj, holdout, bucketingId, userId);
691+
const bucketResult = bucket(bucketerParams);
692+
693+
decideReasons.push(...bucketResult.reasons);
694+
695+
if (bucketResult.result) {
696+
const variation = configObj.variationIdMap[bucketResult.result];
697+
if (variation) {
698+
const bucketReason: DecisionReason = [USER_BUCKETED_INTO_HOLDOUT_VARIATION, userId, holdout.key, variation.key];
699+
decideReasons.push(bucketReason);
700+
this.logger?.info(USER_BUCKETED_INTO_HOLDOUT_VARIATION, userId, holdout.key, variation.key);
701+
702+
return {
703+
result: {
704+
experiment: holdout,
705+
variation: variation,
706+
decisionSource: DECISION_SOURCES.HOLDOUT
707+
},
708+
reasons: decideReasons
709+
};
710+
}
711+
}
712+
713+
const noBucketReason: DecisionReason = [USER_NOT_BUCKETED_INTO_HOLDOUT_VARIATION, userId];
714+
decideReasons.push(noBucketReason);
715+
this.logger?.info(USER_NOT_BUCKETED_INTO_HOLDOUT_VARIATION, userId);
716+
return {
717+
result: {
718+
experiment: null,
719+
variation: null,
720+
decisionSource: DECISION_SOURCES.HOLDOUT
721+
},
722+
reasons: decideReasons
723+
};
724+
}
725+
624726
/**
625727
* Pull the stored variation out of the experimentBucketMap for an experiment/userId
626728
* @param {ProjectConfig} configObj The parsed project configuration object
@@ -836,6 +938,21 @@ export class DecisionService {
836938
});
837939
}
838940

941+
if (holdout()) {
942+
const holdouts = getHoldoutsForFlag(configObj, feature.id);
943+
for (const holdout of holdouts) {
944+
const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user);
945+
decideReasons.push(...holdoutDecision.reasons);
946+
947+
if (holdoutDecision.result.variation) {
948+
return Value.of(op, {
949+
result: holdoutDecision.result,
950+
reasons: decideReasons,
951+
});
952+
}
953+
}
954+
}
955+
839956
return this.getVariationForFeatureExperiment(op, configObj, feature, user, decideOptions, userProfileTracker).then((experimentDecision) => {
840957
if (experimentDecision.error || experimentDecision.result.variation !== null) {
841958
return Value.of(op, {

lib/notification_center/type.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616

1717
import { LogEvent } from '../event_processor/event_dispatcher/event_dispatcher';
18-
import { EventTags, Experiment, FeatureVariableValue, UserAttributes, VariableType, Variation } from '../shared_types';
18+
import { EventTags, ExperimentCore, FeatureVariableValue, UserAttributes, VariableType, Variation } from '../shared_types';
1919
import { DecisionSource } from '../utils/enums';
2020
import { Nullable } from '../utils/type';
2121

@@ -25,7 +25,7 @@ export type UserEventListenerPayload = {
2525
}
2626

2727
export type ActivateListenerPayload = UserEventListenerPayload & {
28-
experiment: Experiment | null;
28+
experiment: ExperimentCore | null;
2929
variation: Variation | null;
3030
logEvent: LogEvent;
3131
}

lib/shared_types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ export interface Client {
358358
}
359359

360360
export interface ActivateListenerPayload extends ListenerPayload {
361-
experiment: import('./shared_types').Experiment;
361+
experiment: import('./shared_types').ExperimentCore;
362362
variation: import('./shared_types').Variation;
363363
logEvent: Event;
364364
}

lib/utils/enums/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export const DECISION_SOURCES = {
5353
FEATURE_TEST: 'feature-test',
5454
ROLLOUT: 'rollout',
5555
EXPERIMENT: 'experiment',
56+
HOLDOUT: 'holdout',
5657
} as const;
5758

5859
export type DecisionSource = typeof DECISION_SOURCES[keyof typeof DECISION_SOURCES];

0 commit comments

Comments
 (0)