Skip to content

Commit 35cea84

Browse files
update: Refactor CMAB traffic allocation handling and enhance decision service error logging
1 parent 0e9e4f8 commit 35cea84

File tree

4 files changed

+277
-20
lines changed

4 files changed

+277
-20
lines changed

lib/optimizely/bucketer.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ def bucket_to_entity_id(project_config, experiment, bucketing_id, user_id)
106106
if experiment['cmab']
107107
traffic_allocations = [
108108
{
109-
entityId: '$',
110-
endOfRange: experiment['cmab']['trafficAllocation']
109+
'entityId' => '$',
110+
'endOfRange' => experiment['cmab']['trafficAllocation']
111111
}
112112
]
113113
end

lib/optimizely/decision_service.rb

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -134,23 +134,25 @@ def get_variation(project_config, experiment_id, user_context, user_profile_trac
134134
cmab_decision = cmab_decision_result.result
135135
variation_id = cmab_decision&.variation_id
136136
cmab_uuid = cmab_decision&.cmab_uuid
137+
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
137138
else
138139
# Bucket normally
139140
variation, bucket_reasons = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
140141
decide_reasons.push(*bucket_reasons)
141142
variation_id = variation ? variation['id'] : nil
142143
cmab_uuid = nil
143-
message = ''
144-
if variation_id
145-
variation_key = variation['key']
146-
message = "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_id}'."
147-
else
148-
message = "User '#{user_id}' is in no variation."
149-
end
150-
@logger.log(Logger::INFO, message)
151-
decide_reasons.push(message)
152144
end
153145

146+
variation_key = variation['key'] if variation
147+
message = if variation_id
148+
"User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_id}'."
149+
else
150+
"User '#{user_id}' is in no variation."
151+
end
152+
153+
@logger.log(Logger::INFO, message)
154+
decide_reasons.push(message) if message
155+
154156
# Persist bucketing decision
155157
user_profile_tracker.update_user_profile(experiment_id, variation_id) unless should_ignore_user_profile_service && user_profile_tracker
156158
VariationResult.new(cmab_uuid, false, decide_reasons, variation_id)
@@ -236,8 +238,6 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont
236238
variation_id = variation_result.variation_id
237239
cmab_uuid = variation_result.cmab_uuid
238240
decide_reasons.push(*reasons_received)
239-
puts 'final reasons'
240-
puts decide_reasons
241241
next unless variation_id
242242

243243
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
@@ -516,7 +516,7 @@ def get_decision_for_cmab_experiment(project_config, experiment, user_context, b
516516
message = "User \"#{user_context.user_id}\" not in CMAB experiment \"#{experiment['key']}\" due to traffic allocation."
517517
@logger.log(Logger::INFO, message)
518518
decide_reasons.push(message)
519-
CmabDecisionResult.new(false, nil, decide_reasons)
519+
return CmabDecisionResult.new(false, nil, decide_reasons)
520520
end
521521

522522
# User is in CMAB allocation, proceed to CMAB decision

spec/decision_service_spec.rb

Lines changed: 234 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -714,10 +714,10 @@
714714
decision_result = decision_service.get_variation_for_feature_rollout(config, feature_flag, user_context)
715715
expect(decision_result.decision).to eq(nil)
716716
expect(decision_result.reasons).to eq([
717-
"User 'user_1' does not meet the conditions for targeting rule '1'.",
718-
"User 'user_1' does not meet the conditions for targeting rule '2'.",
719-
"User 'user_1' does not meet the conditions for targeting rule 'Everyone Else'."
720-
])
717+
"User 'user_1' does not meet the conditions for targeting rule '1'.",
718+
"User 'user_1' does not meet the conditions for targeting rule '2'.",
719+
"User 'user_1' does not meet the conditions for targeting rule 'Everyone Else'."
720+
])
721721

722722
# verify we tried to bucket in all targeting rules and the everyone else rule
723723
expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?).once
@@ -937,4 +937,234 @@
937937
expect(reasons).to eq(["Variation 'control' is mapped to experiment '111127' and user 'test_user_2' in the forced variation map"])
938938
end
939939
end
940+
describe 'CMAB experiments' do
941+
describe 'when user is in traffic allocation' do
942+
it 'should return correct variation and CMAB UUID from CMAB service' do
943+
# Create a CMAB experiment configuration
944+
cmab_experiment = {
945+
'id' => '111150',
946+
'key' => 'cmab_experiment',
947+
'status' => 'Running',
948+
'layerId' => '111150',
949+
'audienceIds' => [],
950+
'forcedVariations' => {},
951+
'variations' => [
952+
{'id' => '111151', 'key' => 'variation_1'},
953+
{'id' => '111152', 'key' => 'variation_2'}
954+
],
955+
'trafficAllocation' => [
956+
{'entityId' => '111151', 'endOfRange' => 5000},
957+
{'entityId' => '111152', 'endOfRange' => 10_000}
958+
],
959+
'cmab' => {'trafficAllocation' => 5000}
960+
}
961+
user_context = project_instance.create_user_context('test_user', {})
962+
963+
# Mock experiment lookup to return our CMAB experiment
964+
allow(config).to receive(:get_experiment_from_id).with('111150').and_return(cmab_experiment)
965+
allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true)
966+
967+
# Mock audience evaluation to pass
968+
allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []])
969+
970+
# Mock bucketer to return a valid entity ID (user is in traffic allocation)
971+
allow(decision_service.bucketer).to receive(:bucket_to_entity_id)
972+
.with(config, cmab_experiment, 'test_user', 'test_user')
973+
.and_return(['$', []])
974+
975+
# Mock CMAB service to return a decision
976+
allow(spy_cmab_service).to receive(:get_decision)
977+
.with(config, user_context, '111150', [])
978+
.and_return(Optimizely::CmabDecision.new(variation_id: '111151', cmab_uuid: 'test-cmab-uuid-123'))
979+
980+
# Mock variation lookup
981+
allow(config).to receive(:get_variation_from_id_by_experiment_id)
982+
.with('111150', '111151')
983+
.and_return({'id' => '111151', 'key' => 'variation_1'})
984+
985+
variation_result = decision_service.get_variation(config, '111150', user_context)
986+
987+
expect(variation_result.variation_id).to eq('111151')
988+
expect(variation_result.cmab_uuid).to eq('test-cmab-uuid-123')
989+
expect(variation_result.error).to eq(false)
990+
expect(variation_result.reasons).to include(
991+
"User 'test_user' is in variation 'variation_1' of experiment '111150'."
992+
)
993+
994+
# Verify CMAB service was called
995+
expect(spy_cmab_service).to have_received(:get_decision).once
996+
end
997+
end
998+
999+
describe 'when user is not in traffic allocation' do
1000+
it 'should return nil variation and log traffic allocation message' do
1001+
cmab_experiment = {
1002+
'id' => '111150',
1003+
'key' => 'cmab_experiment',
1004+
'status' => 'Running',
1005+
'layerId' => '111150',
1006+
'audienceIds' => [],
1007+
'forcedVariations' => {},
1008+
'variations' => [
1009+
{'id' => '111151', 'key' => 'variation_1'}
1010+
],
1011+
'trafficAllocation' => [
1012+
{'entityId' => '111151', 'endOfRange' => 10_000}
1013+
],
1014+
'cmab' => {'trafficAllocation' => 1000}
1015+
}
1016+
user_context = project_instance.create_user_context('test_user', {})
1017+
1018+
# Mock experiment lookup to return our CMAB experiment
1019+
allow(config).to receive(:get_experiment_from_id).with('111150').and_return(cmab_experiment)
1020+
allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true)
1021+
1022+
# Mock audience evaluation to pass
1023+
allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []])
1024+
1025+
variation_result = decision_service.get_variation(config, '111150', user_context)
1026+
1027+
expect(variation_result.variation_id).to eq(nil)
1028+
expect(variation_result.cmab_uuid).to eq(nil)
1029+
expect(variation_result.error).to eq(false)
1030+
expect(variation_result.reasons).to include(
1031+
'User "test_user" not in CMAB experiment "cmab_experiment" due to traffic allocation.'
1032+
)
1033+
1034+
# Verify CMAB service was not called since user is not in traffic allocation
1035+
expect(spy_cmab_service).not_to have_received(:get_decision)
1036+
end
1037+
end
1038+
1039+
describe 'when CMAB service returns an error' do
1040+
it 'should return nil variation and include error in reasons' do
1041+
cmab_experiment = {
1042+
'id' => '111150',
1043+
'key' => 'cmab_experiment',
1044+
'status' => 'Running',
1045+
'layerId' => '111150',
1046+
'audienceIds' => [],
1047+
'forcedVariations' => {},
1048+
'variations' => [
1049+
{'id' => '111151', 'key' => 'variation_1'}
1050+
],
1051+
'trafficAllocation' => [
1052+
{'entityId' => '111151', 'endOfRange' => 10_000}
1053+
],
1054+
'cmab' => {'trafficAllocation' => 5000}
1055+
}
1056+
user_context = project_instance.create_user_context('test_user', {})
1057+
1058+
# Mock experiment lookup to return our CMAB experiment
1059+
allow(config).to receive(:get_experiment_from_id).with('111150').and_return(cmab_experiment)
1060+
allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true)
1061+
1062+
# Mock audience evaluation to pass
1063+
allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []])
1064+
1065+
# Mock bucketer to return a valid entity ID (user is in traffic allocation)
1066+
allow(decision_service.bucketer).to receive(:bucket_to_entity_id)
1067+
.with(config, cmab_experiment, 'test_user', 'test_user')
1068+
.and_return(['$', []])
1069+
1070+
# Mock CMAB service to return an error
1071+
allow(spy_cmab_service).to receive(:get_decision)
1072+
.with(config, user_context, '111150', [])
1073+
.and_raise(StandardError.new('CMAB service error'))
1074+
1075+
variation_result = decision_service.get_variation(config, '111150', user_context)
1076+
1077+
expect(variation_result.variation_id).to be_nil
1078+
expect(variation_result.cmab_uuid).to be_nil
1079+
expect(variation_result.error).to eq(true)
1080+
expect(variation_result.reasons).to include(
1081+
"Failed to fetch CMAB decision for experiment 'cmab_experiment'"
1082+
)
1083+
1084+
# Verify CMAB service was called but errored
1085+
expect(spy_cmab_service).to have_received(:get_decision).once
1086+
end
1087+
end
1088+
1089+
describe 'when user has forced variation' do
1090+
it 'should return forced variation and skip CMAB service call' do
1091+
# Use a real experiment from the datafile and modify it to be a CMAB experiment
1092+
real_experiment = config.get_experiment_from_key('test_experiment')
1093+
cmab_experiment = real_experiment.dup
1094+
cmab_experiment['cmab'] = {'trafficAllocation' => 5000}
1095+
1096+
user_context = project_instance.create_user_context('test_user', {})
1097+
1098+
# Set up forced variation first (using real experiment that exists in datafile)
1099+
decision_service.set_forced_variation(config, 'test_experiment', 'test_user', 'variation')
1100+
1101+
# Mock the experiment to be a CMAB experiment after setting forced variation
1102+
allow(config).to receive(:get_experiment_from_id).with('111127').and_return(cmab_experiment)
1103+
allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true)
1104+
1105+
# Add spy for bucket_to_entity_id method
1106+
allow(decision_service.bucketer).to receive(:bucket_to_entity_id).and_call_original
1107+
1108+
variation_result = decision_service.get_variation(config, '111127', user_context)
1109+
1110+
expect(variation_result.variation_id).to eq('111129')
1111+
expect(variation_result.cmab_uuid).to be_nil
1112+
expect(variation_result.error).to eq(false)
1113+
expect(variation_result.reasons).to include(
1114+
"Variation 'variation' is mapped to experiment '111127' and user 'test_user' in the forced variation map"
1115+
)
1116+
1117+
# Verify CMAB service was not called since user has forced variation
1118+
expect(spy_cmab_service).not_to have_received(:get_decision)
1119+
# Verify bucketer was not called since forced variations short-circuit bucketing
1120+
expect(decision_service.bucketer).not_to have_received(:bucket_to_entity_id)
1121+
end
1122+
end
1123+
1124+
describe 'when user has whitelisted variation' do
1125+
it 'should return whitelisted variation and skip CMAB service call' do
1126+
# Create a CMAB experiment with whitelisted users
1127+
cmab_experiment = {
1128+
'id' => '111150',
1129+
'key' => 'cmab_experiment',
1130+
'status' => 'Running',
1131+
'layerId' => '111150',
1132+
'audienceIds' => [],
1133+
'forcedVariations' => {
1134+
'whitelisted_user' => '111151' # User is whitelisted to variation_1
1135+
},
1136+
'variations' => [
1137+
{'id' => '111151', 'key' => 'variation_1'},
1138+
{'id' => '111152', 'key' => 'variation_2'}
1139+
],
1140+
'trafficAllocation' => [
1141+
{'entityId' => '111151', 'endOfRange' => 5000},
1142+
{'entityId' => '111152', 'endOfRange' => 10_000}
1143+
],
1144+
'cmab' => {'trafficAllocation' => 5000}
1145+
}
1146+
user_context = project_instance.create_user_context('whitelisted_user', {})
1147+
1148+
# Mock experiment lookup to return our CMAB experiment
1149+
allow(config).to receive(:get_experiment_from_id).with('111150').and_return(cmab_experiment)
1150+
allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true)
1151+
1152+
# Mock the get_whitelisted_variation_id method directly
1153+
allow(decision_service).to receive(:get_whitelisted_variation_id)
1154+
.with(config, '111150', 'whitelisted_user')
1155+
.and_return(['111151', "User 'whitelisted_user' is whitelisted into variation 'variation_1' of experiment '111150'."])
1156+
1157+
variation_result = decision_service.get_variation(config, '111150', user_context)
1158+
1159+
expect(variation_result.variation_id).to eq('111151')
1160+
expect(variation_result.cmab_uuid).to be_nil
1161+
expect(variation_result.error).to eq(false)
1162+
expect(variation_result.reasons).to include(
1163+
"User 'whitelisted_user' is whitelisted into variation 'variation_1' of experiment '111150'."
1164+
)
1165+
# Verify CMAB service was not called since user is whitelisted
1166+
expect(spy_cmab_service).not_to have_received(:get_decision)
1167+
end
1168+
end
1169+
end
9401170
end

spec/project_spec.rb

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1992,9 +1992,9 @@ def callback(_args); end
19921992
)
19931993
# Ensure featureEnabled is false for this test
19941994
expect(variation_to_return['featureEnabled']).to be false
1995-
1995+
19961996
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(Optimizely::DecisionService::DecisionResult.new(decision_to_return, false, []))
1997-
1997+
19981998
expect(project_instance.notification_center).to receive(:send_notifications).once.with(
19991999
Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args
20002000
).ordered
@@ -4281,6 +4281,33 @@ def callback(_args); end
42814281
])
42824282
end
42834283
end
4284+
describe 'when decision service fails with CMAB error' do
4285+
it 'should return error decision when CMAB decision service fails' do
4286+
# Add the HTTP stub to prevent real requests
4287+
stub_request(:post, 'https://logx.optimizely.com/v1/events')
4288+
.to_return(status: 200, body: '', headers: {})
4289+
4290+
feature_flag_key = 'boolean_single_variable_feature'
4291+
4292+
# Mock the decision service to return an error result
4293+
error_decision_result = double('DecisionResult')
4294+
allow(error_decision_result).to receive(:decision).and_return(nil)
4295+
allow(error_decision_result).to receive(:error).and_return(true)
4296+
allow(error_decision_result).to receive(:reasons).and_return(['CMAB service failed to fetch decision'])
4297+
4298+
# Mock get_variations_for_feature_list instead of get_variation_for_feature
4299+
allow(project_instance.decision_service).to receive(:get_variations_for_feature_list)
4300+
.and_return([error_decision_result])
4301+
4302+
user_context = project_instance.create_user_context('test_user')
4303+
decision = user_context.decide(feature_flag_key)
4304+
4305+
expect(decision.enabled).to eq(false)
4306+
expect(decision.variation_key).to be_nil
4307+
expect(decision.flag_key).to eq(feature_flag_key)
4308+
expect(decision.reasons).to include('CMAB service failed to fetch decision')
4309+
end
4310+
end
42844311
end
42854312

42864313
describe '#decide_all' do

0 commit comments

Comments
 (0)