@@ -33,6 +33,7 @@ import {
3333 isActive ,
3434 ProjectConfig ,
3535 getTrafficAllocation ,
36+ getHoldoutsForFlag ,
3637} from '../../project_config/project_config' ;
3738import { AudienceEvaluator , createAudienceEvaluator } from '../audience_evaluator' ;
3839import * 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';
7578import { CmabService } from './cmab/cmab_service' ;
7679import { Maybe , OpType , OpValue } from '../../utils/type' ;
7780import { Value } from '../../utils/promise/operation_value' ;
81+ import { holdout } from '../../feature_toggle' ;
7882
7983export const EXPERIMENT_NOT_RUNNING = 'Experiment %s is not running.' ;
8084export const RETURNING_STORED_VARIATION =
@@ -112,9 +116,14 @@ export const USER_HAS_FORCED_DECISION_WITH_NO_RULE_SPECIFIED_BUT_INVALID =
112116export const CMAB_NOT_SUPPORTED_IN_SYNC = 'CMAB is not supported in sync mode.' ;
113117export const CMAB_FETCH_FAILED = 'Failed to fetch CMAB data for experiment %s.' ;
114118export 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
116125export 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 , {
0 commit comments