@@ -33,6 +33,7 @@ import {
33
33
isActive ,
34
34
ProjectConfig ,
35
35
getTrafficAllocation ,
36
+ getHoldoutsForFlag ,
36
37
} from '../../project_config/project_config' ;
37
38
import { AudienceEvaluator , createAudienceEvaluator } from '../audience_evaluator' ;
38
39
import * as stringValidator from '../../utils/string_value_validator' ;
@@ -41,7 +42,9 @@ import {
41
42
DecisionResponse ,
42
43
Experiment ,
43
44
ExperimentBucketMap ,
45
+ ExperimentCore ,
44
46
FeatureFlag ,
47
+ Holdout ,
45
48
OptimizelyDecideOption ,
46
49
OptimizelyUserContext ,
47
50
TrafficAllocation ,
@@ -75,6 +78,7 @@ import { OptimizelyError } from '../../error/optimizly_error';
75
78
import { CmabService } from './cmab/cmab_service' ;
76
79
import { Maybe , OpType , OpValue } from '../../utils/type' ;
77
80
import { Value } from '../../utils/promise/operation_value' ;
81
+ import { holdout } from '../../feature_toggle' ;
78
82
79
83
export const EXPERIMENT_NOT_RUNNING = 'Experiment %s is not running.' ;
80
84
export const RETURNING_STORED_VARIATION =
@@ -112,9 +116,14 @@ export const USER_HAS_FORCED_DECISION_WITH_NO_RULE_SPECIFIED_BUT_INVALID =
112
116
export const CMAB_NOT_SUPPORTED_IN_SYNC = 'CMAB is not supported in sync mode.' ;
113
117
export const CMAB_FETCH_FAILED = 'Failed to fetch CMAB data for experiment %s.' ;
114
118
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.' ;
115
124
116
125
export interface DecisionObj {
117
- experiment : Experiment | null ;
126
+ experiment : ExperimentCore | null ;
118
127
variation : Variation | null ;
119
128
decisionSource : DecisionSource ;
120
129
cmabUuid ?: string ;
@@ -540,7 +549,7 @@ export class DecisionService {
540
549
*/
541
550
private checkIfUserIsInAudience (
542
551
configObj : ProjectConfig ,
543
- experiment : Experiment ,
552
+ experiment : ExperimentCore ,
544
553
evaluationAttribute : string ,
545
554
user : OptimizelyUserContext ,
546
555
loggingKey ?: string | number ,
@@ -590,14 +599,14 @@ export class DecisionService {
590
599
*/
591
600
private buildBucketerParams (
592
601
configObj : ProjectConfig ,
593
- experiment : Experiment ,
602
+ experiment : Experiment | Holdout ,
594
603
bucketingId : string ,
595
604
userId : string
596
605
) : BucketerParams {
597
606
let validateEntity = true ;
598
607
599
608
let trafficAllocationConfig : TrafficAllocation [ ] = getTrafficAllocation ( configObj , experiment . id ) ;
600
- if ( experiment . cmab ) {
609
+ if ( 'cmab' in experiment && experiment . cmab ) {
601
610
trafficAllocationConfig = [ {
602
611
entityId : CMAB_DUMMY_ENTITY_ID ,
603
612
endOfRange : experiment . cmab . trafficAllocation
@@ -621,6 +630,99 @@ export class DecisionService {
621
630
}
622
631
}
623
632
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
+
624
726
/**
625
727
* Pull the stored variation out of the experimentBucketMap for an experiment/userId
626
728
* @param {ProjectConfig } configObj The parsed project configuration object
@@ -836,6 +938,21 @@ export class DecisionService {
836
938
} ) ;
837
939
}
838
940
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
+
839
956
return this . getVariationForFeatureExperiment ( op , configObj , feature , user , decideOptions , userProfileTracker ) . then ( ( experimentDecision ) => {
840
957
if ( experimentDecision . error || experimentDecision . result . variation !== null ) {
841
958
return Value . of ( op , {
0 commit comments