Skip to content

Commit caef965

Browse files
jaeoptclaude
andcommitted
[FSSDK-12262] Exclude CMAB from UserProfileService
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4829b04 commit caef965

2 files changed

Lines changed: 154 additions & 0 deletions

File tree

lib/optimizely/decision_service.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ def get_variation(project_config, experiment_id, user_context, user_profile_trac
9999
return VariationResult.new(nil, false, decide_reasons, whitelisted_variation_id) if whitelisted_variation_id
100100

101101
should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
102+
# CMAB experiments should not use UPS for sticky bucketing because
103+
# CMAB decisions are dynamic and should not be persisted.
104+
is_cmab_experiment = experiment.key?('cmab')
105+
if is_cmab_experiment
106+
should_ignore_user_profile_service = true
107+
message = "Skipping user profile service for CMAB experiment '#{experiment_key}'. CMAB decisions are dynamic and not stored for sticky bucketing."
108+
@logger.log(Logger::DEBUG, message)
109+
decide_reasons.push(message)
110+
end
111+
102112
# Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService
103113
unless should_ignore_user_profile_service && user_profile_tracker
104114
saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile_tracker.user_profile)

spec/decision_service_spec.rb

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,5 +1166,149 @@
11661166
expect(spy_cmab_service).not_to have_received(:get_decision)
11671167
end
11681168
end
1169+
1170+
describe 'UPS exclusion for CMAB experiments' do
1171+
it 'should not save user profile for CMAB experiment decisions' do
1172+
cmab_experiment = {
1173+
'id' => '111150',
1174+
'key' => 'cmab_experiment',
1175+
'status' => 'Running',
1176+
'layerId' => '111150',
1177+
'audienceIds' => [],
1178+
'forcedVariations' => {},
1179+
'variations' => [
1180+
{'id' => '111151', 'key' => 'variation_1'},
1181+
{'id' => '111152', 'key' => 'variation_2'}
1182+
],
1183+
'trafficAllocation' => [
1184+
{'entityId' => '111151', 'endOfRange' => 5000},
1185+
{'entityId' => '111152', 'endOfRange' => 10_000}
1186+
],
1187+
'cmab' => {'trafficAllocation' => 5000}
1188+
}
1189+
user_context = project_instance.create_user_context('test_user', {})
1190+
user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger)
1191+
1192+
allow(config).to receive(:get_experiment_from_id).with('111150').and_return(cmab_experiment)
1193+
allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true)
1194+
allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []])
1195+
allow(decision_service.bucketer).to receive(:bucket_to_entity_id)
1196+
.with(config, cmab_experiment, 'test_user', 'test_user')
1197+
.and_return(['$', []])
1198+
allow(spy_cmab_service).to receive(:get_decision)
1199+
.with(config, user_context, '111150', [])
1200+
.and_return(Optimizely::CmabDecision.new(variation_id: '111151', cmab_uuid: 'test-cmab-uuid-123'))
1201+
allow(config).to receive(:get_variation_from_id_by_experiment_id)
1202+
.with('111150', '111151')
1203+
.and_return({'id' => '111151', 'key' => 'variation_1'})
1204+
1205+
variation_result = decision_service.get_variation(config, '111150', user_context, user_profile_tracker)
1206+
1207+
expect(variation_result.variation_id).to eq('111151')
1208+
expect(variation_result.cmab_uuid).to eq('test-cmab-uuid-123')
1209+
expect(variation_result.error).to eq(false)
1210+
1211+
# Verify user profile was NOT updated for CMAB experiment
1212+
expect(spy_user_profile_service).not_to have_received(:save)
1213+
1214+
# Verify debug log was called to explain CMAB UPS exclusion
1215+
expect(spy_logger).to have_received(:log).with(
1216+
Logger::DEBUG,
1217+
"Skipping user profile service for CMAB experiment 'cmab_experiment'. CMAB decisions are dynamic and not stored for sticky bucketing."
1218+
)
1219+
1220+
# Verify the decision reason includes UPS exclusion message
1221+
expect(variation_result.reasons).to include(
1222+
"Skipping user profile service for CMAB experiment 'cmab_experiment'. CMAB decisions are dynamic and not stored for sticky bucketing."
1223+
)
1224+
end
1225+
1226+
it 'should not look up saved user profile decisions for CMAB experiments' do
1227+
saved_user_profile = {
1228+
user_id: 'test_user',
1229+
experiment_bucket_map: {
1230+
'111150' => {
1231+
variation_id: '111152'
1232+
}
1233+
}
1234+
}
1235+
allow(spy_user_profile_service).to receive(:lookup)
1236+
.with('test_user').and_return(saved_user_profile)
1237+
1238+
cmab_experiment = {
1239+
'id' => '111150',
1240+
'key' => 'cmab_experiment',
1241+
'status' => 'Running',
1242+
'layerId' => '111150',
1243+
'audienceIds' => [],
1244+
'forcedVariations' => {},
1245+
'variations' => [
1246+
{'id' => '111151', 'key' => 'variation_1'},
1247+
{'id' => '111152', 'key' => 'variation_2'}
1248+
],
1249+
'trafficAllocation' => [
1250+
{'entityId' => '111151', 'endOfRange' => 5000},
1251+
{'entityId' => '111152', 'endOfRange' => 10_000}
1252+
],
1253+
'cmab' => {'trafficAllocation' => 5000}
1254+
}
1255+
user_context = project_instance.create_user_context('test_user', {})
1256+
user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger)
1257+
user_profile_tracker.load_user_profile
1258+
1259+
allow(config).to receive(:get_experiment_from_id).with('111150').and_return(cmab_experiment)
1260+
allow(config).to receive(:experiment_running?).with(cmab_experiment).and_return(true)
1261+
allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return([true, []])
1262+
allow(decision_service.bucketer).to receive(:bucket_to_entity_id)
1263+
.with(config, cmab_experiment, 'test_user', 'test_user')
1264+
.and_return(['$', []])
1265+
allow(spy_cmab_service).to receive(:get_decision)
1266+
.with(config, user_context, '111150', [])
1267+
.and_return(Optimizely::CmabDecision.new(variation_id: '111151', cmab_uuid: 'test-cmab-uuid-456'))
1268+
allow(config).to receive(:get_variation_from_id_by_experiment_id)
1269+
.with('111150', '111151')
1270+
.and_return({'id' => '111151', 'key' => 'variation_1'})
1271+
1272+
variation_result = decision_service.get_variation(config, '111150', user_context, user_profile_tracker)
1273+
1274+
# Should return CMAB decision (variation_1), NOT the saved UPS decision (variation_2)
1275+
expect(variation_result.variation_id).to eq('111151')
1276+
expect(variation_result.cmab_uuid).to eq('test-cmab-uuid-456')
1277+
expect(variation_result.error).to eq(false)
1278+
1279+
# Verify CMAB service was called (not short-circuited by UPS lookup)
1280+
expect(spy_cmab_service).to have_received(:get_decision).once
1281+
1282+
# Verify the reasons do NOT include a "Returning previously activated" message
1283+
expect(variation_result.reasons).not_to include(
1284+
a_string_matching(/Returning previously activated/)
1285+
)
1286+
1287+
# Verify the reasons DO include the CMAB UPS exclusion message
1288+
expect(variation_result.reasons).to include(
1289+
"Skipping user profile service for CMAB experiment 'cmab_experiment'. CMAB decisions are dynamic and not stored for sticky bucketing."
1290+
)
1291+
end
1292+
1293+
it 'should still save user profile for standard (non-CMAB) experiments' do
1294+
# Use a standard experiment (no cmab key)
1295+
user_context = project_instance.create_user_context('test_user')
1296+
user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger)
1297+
allow(spy_user_profile_service).to receive(:lookup).and_return(nil)
1298+
1299+
allow(decision_service.bucketer).to receive(:bucket).and_call_original
1300+
1301+
variation_result = decision_service.get_variation(config, '111127', user_context, user_profile_tracker)
1302+
expect(variation_result.variation_id).to eq('111128')
1303+
1304+
# Verify user profile WAS updated for standard experiment
1305+
expect(user_profile_tracker.user_profile[:experiment_bucket_map]['111127']).to eq({variation_id: '111128'})
1306+
1307+
# Verify the reasons do NOT include the CMAB UPS exclusion message
1308+
expect(variation_result.reasons).not_to include(
1309+
a_string_matching(/Skipping user profile service for CMAB experiment/)
1310+
)
1311+
end
1312+
end
11691313
end
11701314
end

0 commit comments

Comments
 (0)