Skip to content

Commit f4967e4

Browse files
[FSSDK-11185] Update: Send CMAB uuid in impression events (#370)
* update: Extend LRUCache with remove method and corresponding tests * update: Clean up whitespace in LRUCache implementation and tests * update: Extend copyright notice to include 2025 * update: Implement Default CMAB Service * update: Enable keyword initialization for CmabDecision and CmabCacheValue structs (otherwise breaks in ruby version change) * update: Refactor bucketing logic to handle empty traffic ranges and improve logging * update: Add support for CMAB traffic allocation in bucketing logic * update: Enhance DecisionService to support CMAB traffic allocation and decision retrieval * update: Integrate CMAB decision logic into DecisionService and update related tests * update: Refactor DecisionService to return DecisionResult struct instead of Decision struct * update: Integrate CMAB components into Project class and enhance decision handling * update: Refactor CMAB traffic allocation handling and enhance decision service error logging * update: Refactor OptimizelyDecision instantiation to use keyword arguments for clarity * update: Enhance send_impression method to include CMAB UUID and add tests for CMAB experiments * update: Refactor CMAB client initialization and enhance audience conditions parsing * update: Refactor attribute filtering logic and improve test attribute structure * update: Handle errors in decision result to prevent fallback to next experiment * update: Improve error message formatting for CMAB decision failures * update: fix error message for CMAB decision fetching * update: fix error message * update: fix key naming for variation_id in CMAB response handling * update: fix key naming for variation_id in CMAB response handling in spec file * update: fix argument order in CMAB traffic allocation method * update: refactor decision variation access in Optimizely project * update: handle nil variation_id in get_variation method
1 parent a3e6b06 commit f4967e4

10 files changed

+138
-17
lines changed

lib/optimizely.rb

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,9 @@ def initialize(
139139

140140
# Initialize CMAB components
141141
@cmab_client = DefaultCmabClient.new(
142-
retry_config: CmabRetryConfig.new,
143-
logger: @logger
142+
nil,
143+
CmabRetryConfig.new,
144+
@logger
144145
)
145146
@cmab_cache = LRUCache.new(DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT)
146147
@cmab_service = DefaultCmabService.new(
@@ -211,15 +212,15 @@ def create_optimizely_decision(user_context, flag_key, decision, reasons, decide
211212
experiment = decision.experiment
212213
rule_key = experiment ? experiment['key'] : nil
213214
experiment_id = experiment ? experiment['id'] : nil
214-
variation = decision['variation']
215+
variation = decision.variation
215216
variation_key = variation ? variation['key'] : nil
216217
variation_id = variation ? variation['id'] : nil
217218
feature_enabled = variation ? variation['featureEnabled'] : false
218219
decision_source = decision.source
219220
end
220221

221222
if !decide_options.include?(OptimizelyDecideOption::DISABLE_DECISION_EVENT) && (decision_source == Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] || config.send_flag_decisions)
222-
send_impression(config, experiment, variation_key || '', flag_key, rule_key || '', feature_enabled, decision_source, user_id, attributes)
223+
send_impression(config, experiment, variation_key || '', flag_key, rule_key || '', feature_enabled, decision_source, user_id, attributes, decision&.cmab_uuid)
223224
decision_event_dispatched = true
224225
end
225226

@@ -1244,7 +1245,7 @@ def validate_instantiation_options
12441245
raise InvalidInputError, 'event_dispatcher'
12451246
end
12461247

1247-
def send_impression(config, experiment, variation_key, flag_key, rule_key, enabled, rule_type, user_id, attributes = nil)
1248+
def send_impression(config, experiment, variation_key, flag_key, rule_key, enabled, rule_type, user_id, attributes = nil, cmab_uuid = nil)
12481249
if experiment.nil?
12491250
experiment = {
12501251
'id' => '',
@@ -1276,6 +1277,7 @@ def send_impression(config, experiment, variation_key, flag_key, rule_key, enabl
12761277
variation_key: variation_key,
12771278
enabled: enabled
12781279
}
1280+
metadata[:cmab_uuid] = cmab_uuid unless cmab_uuid.nil?
12791281

12801282
user_event = UserEventFactory.create_impression_event(config, experiment, variation_id, metadata, user_id, attributes)
12811283
@event_processor.process(user_event)

lib/optimizely/audience.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ def user_meets_audience_conditions?(config, experiment, user_context, logger, lo
7272
decide_reasons.push(message)
7373

7474
audience_conditions = JSON.parse(audience_conditions) if audience_conditions.is_a?(String)
75+
# Convert all symbol keys to string keys in the parsed conditions
76+
stringify_keys = lambda do |obj|
77+
case obj
78+
when Hash
79+
obj.transform_keys(&:to_s).transform_values { |v| stringify_keys.call(v) }
80+
when Array
81+
obj.map { |item| stringify_keys.call(item) }
82+
else
83+
obj
84+
end
85+
end
86+
87+
audience_conditions = stringify_keys.call(audience_conditions)
88+
7589
result = ConditionTreeEvaluator.evaluate(audience_conditions, evaluate_user_conditions)
7690
result_str = result.nil? ? 'UNKNOWN' : result.to_s.upcase
7791
message = format(logs_hash['AUDIENCE_EVALUATION_RESULT'], audience_id, result_str)

lib/optimizely/cmab/cmab_client.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def _do_fetch(url, request_body, timeout)
122122
raise CmabInvalidResponseError, error_message
123123
end
124124

125-
body['predictions'][0]['variationId']
125+
body['predictions'][0]['variation_id']
126126
end
127127

128128
def validate_response(body)
@@ -137,7 +137,7 @@ def validate_response(body)
137137
body['predictions'].is_a?(Array) &&
138138
!body['predictions'].empty? &&
139139
body['predictions'][0].is_a?(Hash) &&
140-
body['predictions'][0].key?('variationId')
140+
body['predictions'][0].key?('variation_id')
141141
end
142142

143143
def _do_fetch_with_retry(url, request_body, retry_config, timeout)

lib/optimizely/cmab/cmab_service.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,10 @@ def filter_attributes(project_config, user_context, rule_id)
119119
cmab_attribute_ids = experiment['cmab']['attributeIds']
120120
cmab_attribute_ids.each do |attribute_id|
121121
attribute = project_config.attribute_id_map[attribute_id]
122-
filtered_user_attributes[attribute.key] = user_attributes[attribute.key] if attribute && user_attributes.key?(attribute.key)
122+
next unless attribute
123+
124+
attribute_key = attribute['key']
125+
filtered_user_attributes[attribute_key] = user_attributes[attribute_key] if user_attributes.key?(attribute_key)
123126
end
124127

125128
filtered_user_attributes

lib/optimizely/decision_service.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ 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)
137+
variation = variation_id ? project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id) : nil
138138
else
139139
# Bucket normally
140140
variation, bucket_reasons = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
@@ -238,6 +238,10 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont
238238
variation_id = variation_result.variation_id
239239
cmab_uuid = variation_result.cmab_uuid
240240
decide_reasons.push(*reasons_received)
241+
242+
# If there's an error, return immediately instead of falling back to next experiment
243+
return DecisionResult.new(nil, error, decide_reasons) if error
244+
241245
next unless variation_id
242246

243247
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
@@ -509,7 +513,7 @@ def get_decision_for_cmab_experiment(project_config, experiment, user_context, b
509513

510514
# Check if user is in CMAB traffic allocation
511515
bucketed_entity_id, bucket_reasons = @bucketer.bucket_to_entity_id(
512-
project_config, experiment, user_id, bucketing_id
516+
project_config, experiment, bucketing_id, user_id
513517
)
514518
decide_reasons.push(*bucket_reasons)
515519
unless bucketed_entity_id
@@ -526,7 +530,7 @@ def get_decision_for_cmab_experiment(project_config, experiment, user_context, b
526530
)
527531
CmabDecisionResult.new(false, cmab_decision, decide_reasons)
528532
rescue StandardError => e
529-
error_message = "Failed to fetch CMAB decision for experiment '#{experiment['key']}'"
533+
error_message = "Failed to fetch CMAB data for experiment #{experiment['key']}."
530534
decide_reasons.push(error_message)
531535
@logger&.log(Logger::ERROR, "#{error_message} #{e}")
532536
CmabDecisionResult.new(true, nil, decide_reasons)

spec/cmab/cmab_client_spec.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
it 'should return the variation id on success' do
6161
WebMock.stub_request(:post, expected_url)
6262
.with(body: expected_body_for_webmock, headers: expected_headers)
63-
.to_return(status: 200, body: {'predictions' => [{'variationId' => 'abc123'}]}.to_json, headers: {'Content-Type' => 'application/json'})
63+
.to_return(status: 200, body: {'predictions' => [{'variation_id' => 'abc123'}]}.to_json, headers: {'Content-Type' => 'application/json'})
6464

6565
result = client.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
6666

@@ -137,7 +137,7 @@
137137
it 'should return the variation id on first try' do
138138
WebMock.stub_request(:post, expected_url)
139139
.with(body: expected_body_for_webmock, headers: expected_headers)
140-
.to_return(status: 200, body: {'predictions' => [{'variationId' => 'abc123'}]}.to_json, headers: {'Content-Type' => 'application/json'})
140+
.to_return(status: 200, body: {'predictions' => [{'variation_id' => 'abc123'}]}.to_json, headers: {'Content-Type' => 'application/json'})
141141

142142
result = client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
143143

@@ -152,7 +152,7 @@
152152
.with(body: expected_body_for_webmock, headers: expected_headers)
153153
.to_return({status: 500},
154154
{status: 500},
155-
{status: 200, body: {'predictions' => [{'variationId' => 'xyz456'}]}.to_json, headers: {'Content-Type' => 'application/json'}})
155+
{status: 200, body: {'predictions' => [{'variation_id' => 'xyz456'}]}.to_json, headers: {'Content-Type' => 'application/json'}})
156156

157157
result = client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
158158

spec/cmab/cmab_service_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
let(:user_attributes) { {'age' => 25, 'location' => 'USA'} }
2020

2121
let(:mock_experiment) { {'cmab' => {'attributeIds' => %w[66 77]}} }
22-
let(:mock_attr1) { double('attribute', key: 'age') }
23-
let(:mock_attr2) { double('attribute', key: 'location') }
22+
let(:mock_attr1) { {'key' => 'age'} }
23+
let(:mock_attr2) { {'key' => 'location'} }
2424

2525
before do
2626
allow(mock_user_context).to receive(:user_id).and_return(user_id)

spec/decision_service_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1078,7 +1078,7 @@
10781078
expect(variation_result.cmab_uuid).to be_nil
10791079
expect(variation_result.error).to eq(true)
10801080
expect(variation_result.reasons).to include(
1081-
"Failed to fetch CMAB decision for experiment 'cmab_experiment'"
1081+
'Failed to fetch CMAB data for experiment cmab_experiment.'
10821082
)
10831083

10841084
# Verify CMAB service was called but errored

spec/optimizely_user_context_spec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,7 @@
556556
decision = user_context_obj.decide(feature_key, [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS])
557557
expect(decision.variation_key).to eq('18257766532')
558558
expect(decision.rule_key).to eq('18322080788')
559+
# puts decision.reasons
559560
expect(decision.reasons).to include('Invalid variation is mapped to flag (feature_1), rule (exp_with_audience) and user (tester) in the forced decision map.')
560561

561562
# delivery-rule-to-decision

spec/project_spec.rb

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4308,6 +4308,103 @@ def callback(_args); end
43084308
expect(decision.reasons).to include('CMAB service failed to fetch decision')
43094309
end
43104310
end
4311+
describe 'CMAB experiments' do
4312+
it 'should include CMAB UUID in dispatched event when decision service returns CMAB result' do
4313+
# Use an existing feature flag from the test config
4314+
feature_flag_key = 'boolean_single_variable_feature'
4315+
4316+
# Get an existing experiment that actually exists in the datafile
4317+
# Looking at the test config, let's use experiment ID '122230' which exists
4318+
existing_experiment = project_config.get_experiment_from_id('122230')
4319+
4320+
# Modify the existing experiment to be a CMAB experiment
4321+
cmab_experiment = existing_experiment.dup
4322+
cmab_experiment['trafficAllocation'] = [] # Empty for CMAB
4323+
cmab_experiment['cmab'] = {'attributeIds' => %w[808797688 808797689], 'trafficAllocation' => 4000}
4324+
4325+
# Mock the config to return our modified CMAB experiment
4326+
allow(project_instance.config_manager.config).to receive(:get_experiment_from_id)
4327+
.with('122230')
4328+
.and_return(cmab_experiment)
4329+
4330+
allow(project_instance.config_manager.config).to receive(:experiment_running?)
4331+
.with(cmab_experiment)
4332+
.and_return(true)
4333+
4334+
# Get the feature flag and update it to reference our CMAB experiment
4335+
feature_flag = project_instance.config_manager.config.get_feature_flag_from_key(feature_flag_key)
4336+
feature_flag['experimentIds'] = ['122230']
4337+
4338+
# Use existing variations from the original experiment
4339+
variation_to_use = existing_experiment['variations'][0]
4340+
4341+
# Create a decision with CMAB UUID
4342+
expected_cmab_uuid = 'uuid-cmab'
4343+
decision_with_cmab = Optimizely::DecisionService::Decision.new(
4344+
cmab_experiment,
4345+
variation_to_use,
4346+
Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'],
4347+
expected_cmab_uuid
4348+
)
4349+
4350+
decision_result_with_cmab = Optimizely::DecisionService::DecisionResult.new(
4351+
decision_with_cmab,
4352+
false,
4353+
[]
4354+
)
4355+
4356+
# Mock get_variations_for_feature_list to return CMAB result
4357+
allow(project_instance.decision_service).to receive(:get_variations_for_feature_list)
4358+
.and_return([decision_result_with_cmab])
4359+
4360+
# Set up time and UUID mocks for consistent event data
4361+
allow(Time).to receive(:now).and_return(time_now)
4362+
allow(SecureRandom).to receive(:uuid).and_return('a68cf1ad-0393-4e18-af87-efe8f01a7c9c')
4363+
4364+
# Create array to capture dispatched events
4365+
dispatched_events = []
4366+
allow(project_instance.event_dispatcher).to receive(:dispatch_event) do |event|
4367+
dispatched_events << event
4368+
end
4369+
4370+
user_context = project_instance.create_user_context('test_user')
4371+
decision = user_context.decide(feature_flag_key)
4372+
4373+
# Wait for batch processing thread to send event
4374+
sleep 0.1 until project_instance.event_processor.event_queue.empty?
4375+
4376+
# Verify the decision contains expected information
4377+
expect(decision.enabled).to eq(true)
4378+
expect(decision.variation_key).to eq(variation_to_use['key'])
4379+
expect(decision.rule_key).to eq(existing_experiment['key'])
4380+
expect(decision.flag_key).to eq(feature_flag_key)
4381+
4382+
# Verify an event was dispatched
4383+
expect(dispatched_events.length).to eq(1)
4384+
4385+
dispatched_event = dispatched_events[0]
4386+
4387+
# Remove the puts statement and verify the event structure and CMAB UUID
4388+
expect(dispatched_event.params).to have_key(:visitors)
4389+
expect(dispatched_event.params[:visitors].length).to be > 0
4390+
expect(dispatched_event.params[:visitors][0]).to have_key(:snapshots)
4391+
expect(dispatched_event.params[:visitors][0][:snapshots].length).to be > 0
4392+
expect(dispatched_event.params[:visitors][0][:snapshots][0]).to have_key(:decisions)
4393+
expect(dispatched_event.params[:visitors][0][:snapshots][0][:decisions].length).to be > 0
4394+
4395+
# Get the metadata and assert CMAB UUID
4396+
metadata = dispatched_event.params[:visitors][0][:snapshots][0][:decisions][0][:metadata]
4397+
expect(metadata).to have_key(:cmab_uuid)
4398+
expect(metadata[:cmab_uuid]).to eq(expected_cmab_uuid)
4399+
4400+
# Also verify other expected metadata fields
4401+
expect(metadata[:flag_key]).to eq(feature_flag_key)
4402+
expect(metadata[:rule_key]).to eq('test_experiment_multivariate')
4403+
expect(metadata[:rule_type]).to eq('feature-test')
4404+
expect(metadata[:variation_key]).to eq('Fred')
4405+
expect(metadata[:enabled]).to eq(true)
4406+
end
4407+
end
43114408
end
43124409

43134410
describe '#decide_all' do

0 commit comments

Comments
 (0)