@@ -46,7 +46,8 @@ class DecisionService
4646 DECISION_SOURCES = {
4747 'EXPERIMENT' => 'experiment' ,
4848 'FEATURE_TEST' => 'feature-test' ,
49- 'ROLLOUT' => 'rollout'
49+ 'ROLLOUT' => 'rollout' ,
50+ 'HOLDOUT' => 'holdout'
5051 } . freeze
5152
5253 def initialize ( logger , cmab_service , user_profile_service = nil )
@@ -166,7 +167,127 @@ def get_variation_for_feature(project_config, feature_flag, user_context, decide
166167 # user_context - Optimizely user context instance
167168 #
168169 # Returns DecisionResult struct.
169- get_variations_for_feature_list ( project_config , [ feature_flag ] , user_context , decide_options ) . first
170+ holdouts = project_config . get_holdouts_for_flag ( feature_flag [ 'key' ] )
171+
172+ if holdouts && !holdouts . empty?
173+ # Has holdouts - use get_decision_for_flag which checks holdouts first
174+ get_decision_for_flag ( feature_flag , user_context , project_config , decide_options )
175+ else
176+ get_variations_for_feature_list ( project_config , [ feature_flag ] , user_context , decide_options ) . first
177+ end
178+ end
179+
180+ def get_decision_for_flag ( feature_flag , user_context , project_config , decide_options = [ ] , user_profile_tracker = nil , decide_reasons = nil )
181+ # Get the decision for a single feature flag.
182+ # Processes holdouts, experiments, and rollouts in that order.
183+ #
184+ # feature_flag - The feature flag to get a decision for
185+ # user_context - The user context
186+ # project_config - The project config
187+ # decide_options - Array of decide options
188+ # user_profile_tracker - The user profile tracker
189+ # decide_reasons - Array of decision reasons to merge
190+ #
191+ # Returns a DecisionResult for the feature flag
192+
193+ reasons = decide_reasons ? decide_reasons . dup : [ ]
194+ user_id = user_context . user_id
195+
196+ # Check holdouts
197+ holdouts = project_config . get_holdouts_for_flag ( feature_flag [ 'key' ] )
198+ holdouts . each do |holdout |
199+ holdout_decision = get_variation_for_holdout ( holdout , user_context , project_config )
200+ reasons . push ( *holdout_decision . reasons )
201+
202+ next unless holdout_decision . decision
203+
204+ message = "The user '#{ user_id } ' is bucketed into holdout '#{ holdout [ 'key' ] } ' for feature flag '#{ feature_flag [ 'key' ] } '."
205+ @logger . log ( Logger ::INFO , message )
206+ reasons . push ( message )
207+ return DecisionResult . new ( holdout_decision . decision , false , reasons )
208+ end
209+
210+ # Check if the feature flag has an experiment and the user is bucketed into that experiment
211+ experiment_decision = get_variation_for_feature_experiment ( project_config , feature_flag , user_context , user_profile_tracker , decide_options )
212+ reasons . push ( *experiment_decision . reasons )
213+
214+ return DecisionResult . new ( experiment_decision . decision , experiment_decision . error , reasons ) if experiment_decision . decision
215+
216+ # Check if the feature flag has a rollout and the user is bucketed into that rollout
217+ rollout_decision = get_variation_for_feature_rollout ( project_config , feature_flag , user_context )
218+ reasons . push ( *rollout_decision . reasons )
219+
220+ if rollout_decision . decision
221+ # Check if this was a forced decision (last reason contains "forced decision map")
222+ is_forced_decision = reasons . last &.include? ( 'forced decision map' )
223+
224+ unless is_forced_decision
225+ # Only add the "bucketed into rollout" message for normal bucketing
226+ message = "The user '#{ user_id } ' is bucketed into a rollout for feature flag '#{ feature_flag [ 'key' ] } '."
227+ @logger . log ( Logger ::INFO , message )
228+ reasons . push ( message )
229+ end
230+
231+ DecisionResult . new ( rollout_decision . decision , rollout_decision . error , reasons )
232+ else
233+ message = "The user '#{ user_id } ' is not bucketed into a rollout for feature flag '#{ feature_flag [ 'key' ] } '."
234+ @logger . log ( Logger ::INFO , message )
235+ DecisionResult . new ( nil , false , reasons )
236+ end
237+ end
238+
239+ def get_variation_for_holdout ( holdout , user_context , project_config )
240+ # Get the variation for holdout
241+ #
242+ # holdout - The holdout configuration
243+ # user_context - The user context
244+ # project_config - The project config
245+ #
246+ # Returns a DecisionResult for the holdout
247+
248+ decide_reasons = [ ]
249+ user_id = user_context . user_id
250+ attributes = user_context . user_attributes
251+
252+ if holdout . nil? || holdout [ 'status' ] . nil? || holdout [ 'status' ] != 'Running'
253+ key = holdout && holdout [ 'key' ] ? holdout [ 'key' ] : 'unknown'
254+ message = "Holdout '#{ key } ' is not running."
255+ @logger . log ( Logger ::INFO , message )
256+ decide_reasons . push ( message )
257+ return DecisionResult . new ( nil , false , decide_reasons )
258+ end
259+
260+ bucketing_id , bucketing_id_reasons = get_bucketing_id ( user_id , attributes )
261+ decide_reasons . push ( *bucketing_id_reasons )
262+
263+ # Check audience conditions
264+ user_meets_audience_conditions , reasons_received = Audience . user_meets_audience_conditions? ( project_config , holdout , user_context , @logger )
265+ decide_reasons . push ( *reasons_received )
266+
267+ unless user_meets_audience_conditions
268+ message = "User '#{ user_id } ' does not meet the conditions for holdout '#{ holdout [ 'key' ] } '."
269+ @logger . log ( Logger ::DEBUG , message )
270+ decide_reasons . push ( message )
271+ return DecisionResult . new ( nil , false , decide_reasons )
272+ end
273+
274+ # Bucket user into holdout variation
275+ variation , bucket_reasons = @bucketer . bucket ( project_config , holdout , bucketing_id , user_id )
276+ decide_reasons . push ( *bucket_reasons )
277+
278+ if variation
279+ message = "The user '#{ user_id } ' is bucketed into variation '#{ variation [ 'key' ] } ' of holdout '#{ holdout [ 'key' ] } '."
280+ @logger . log ( Logger ::INFO , message )
281+ decide_reasons . push ( message )
282+
283+ holdout_decision = Decision . new ( holdout , variation , DECISION_SOURCES [ 'HOLDOUT' ] , nil )
284+ DecisionResult . new ( holdout_decision , false , decide_reasons )
285+ else
286+ message = "The user '#{ user_id } ' is not bucketed into holdout '#{ holdout [ 'key' ] } '."
287+ @logger . log ( Logger ::DEBUG , message )
288+ decide_reasons . push ( message )
289+ DecisionResult . new ( nil , false , decide_reasons )
290+ end
170291 end
171292
172293 def get_variations_for_feature_list ( project_config , feature_flags , user_context , decide_options = [ ] )
@@ -183,9 +304,11 @@ def get_variations_for_feature_list(project_config, feature_flags, user_context,
183304 ignore_ups = decide_options . include? Optimizely ::Decide ::OptimizelyDecideOption ::IGNORE_USER_PROFILE_SERVICE
184305 user_profile_tracker = nil
185306 unless ignore_ups && @user_profile_service
186- user_profile_tracker = UserProfileTracker . new ( user_context . user_id , @user_profile_service , @logger )
307+ user_id = user_context . user_id
308+ user_profile_tracker = UserProfileTracker . new ( user_id , @user_profile_service , @logger )
187309 user_profile_tracker . load_user_profile
188310 end
311+
189312 decisions = [ ]
190313 feature_flags . each do |feature_flag |
191314 # check if the feature is being experiment on and whether the user is bucketed into the experiment
0 commit comments