Skip to content

[FSSDK-11185] Update: Send CMAB uuid in impression events #370

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
de61931
update: Extend LRUCache with remove method and corresponding tests
FarhanAnjum-opti Jul 1, 2025
01e3a9f
update: Clean up whitespace in LRUCache implementation and tests
FarhanAnjum-opti Jul 1, 2025
40d0571
update: Extend copyright notice to include 2025
FarhanAnjum-opti Jul 1, 2025
2282eb1
update: Implement Default CMAB Service
FarhanAnjum-opti Jul 9, 2025
8ca3aee
update: Enable keyword initialization for CmabDecision and CmabCacheV…
FarhanAnjum-opti Jul 9, 2025
761bc43
update: Refactor bucketing logic to handle empty traffic ranges and i…
FarhanAnjum-opti Jul 14, 2025
ebb1b7d
update: Add support for CMAB traffic allocation in bucketing logic
FarhanAnjum-opti Jul 14, 2025
d6dd3aa
update: Enhance DecisionService to support CMAB traffic allocation an…
FarhanAnjum-opti Jul 14, 2025
75ee816
update: Integrate CMAB decision logic into DecisionService and update…
FarhanAnjum-opti Jul 16, 2025
f48dbc2
update: Refactor DecisionService to return DecisionResult struct inst…
FarhanAnjum-opti Jul 18, 2025
0e9e4f8
update: Integrate CMAB components into Project class and enhance deci…
FarhanAnjum-opti Jul 23, 2025
35cea84
update: Refactor CMAB traffic allocation handling and enhance decisio…
FarhanAnjum-opti Jul 23, 2025
20ecb66
Merge branch 'master' into farhan-anjum/FSSDK-11176-update-decision-s…
FarhanAnjum-opti Jul 23, 2025
9c31bb8
update: Refactor OptimizelyDecision instantiation to use keyword argu…
FarhanAnjum-opti Jul 23, 2025
d35161b
update: Enhance send_impression method to include CMAB UUID and add t…
FarhanAnjum-opti Jul 23, 2025
7851796
Merge branch 'master' into farhan-anjum/FSSDK-11185-update-impression…
FarhanAnjum-opti Jul 29, 2025
351e055
update: Refactor CMAB client initialization and enhance audience cond…
FarhanAnjum-opti Aug 4, 2025
2fc8372
update: Refactor attribute filtering logic and improve test attribute…
FarhanAnjum-opti Aug 4, 2025
f830c3c
update: Handle errors in decision result to prevent fallback to next …
FarhanAnjum-opti Aug 4, 2025
43a9af9
update: Improve error message formatting for CMAB decision failures
FarhanAnjum-opti Aug 4, 2025
442e493
update: fix error message for CMAB decision fetching
FarhanAnjum-opti Aug 4, 2025
758f44b
update: fix error message
FarhanAnjum-opti Aug 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions lib/optimizely.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,9 @@ def initialize(

# Initialize CMAB components
@cmab_client = DefaultCmabClient.new(
retry_config: CmabRetryConfig.new,
logger: @logger
nil,
CmabRetryConfig.new,
@logger
)
@cmab_cache = LRUCache.new(DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT)
@cmab_service = DefaultCmabService.new(
Expand Down Expand Up @@ -219,7 +220,7 @@ def create_optimizely_decision(user_context, flag_key, decision, reasons, decide
end

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

Expand Down Expand Up @@ -1244,7 +1245,7 @@ def validate_instantiation_options
raise InvalidInputError, 'event_dispatcher'
end

def send_impression(config, experiment, variation_key, flag_key, rule_key, enabled, rule_type, user_id, attributes = nil)
def send_impression(config, experiment, variation_key, flag_key, rule_key, enabled, rule_type, user_id, attributes = nil, cmab_uuid = nil)
if experiment.nil?
experiment = {
'id' => '',
Expand Down Expand Up @@ -1276,6 +1277,7 @@ def send_impression(config, experiment, variation_key, flag_key, rule_key, enabl
variation_key: variation_key,
enabled: enabled
}
metadata[:cmab_uuid] = cmab_uuid unless cmab_uuid.nil?

user_event = UserEventFactory.create_impression_event(config, experiment, variation_id, metadata, user_id, attributes)
@event_processor.process(user_event)
Expand Down
14 changes: 14 additions & 0 deletions lib/optimizely/audience.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ def user_meets_audience_conditions?(config, experiment, user_context, logger, lo
decide_reasons.push(message)

audience_conditions = JSON.parse(audience_conditions) if audience_conditions.is_a?(String)
# Convert all symbol keys to string keys in the parsed conditions
stringify_keys = lambda do |obj|
case obj
when Hash
obj.transform_keys(&:to_s).transform_values { |v| stringify_keys.call(v) }
when Array
obj.map { |item| stringify_keys.call(item) }
else
obj
end
end

audience_conditions = stringify_keys.call(audience_conditions)

result = ConditionTreeEvaluator.evaluate(audience_conditions, evaluate_user_conditions)
result_str = result.nil? ? 'UNKNOWN' : result.to_s.upcase
message = format(logs_hash['AUDIENCE_EVALUATION_RESULT'], audience_id, result_str)
Expand Down
5 changes: 4 additions & 1 deletion lib/optimizely/cmab/cmab_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,10 @@ def filter_attributes(project_config, user_context, rule_id)
cmab_attribute_ids = experiment['cmab']['attributeIds']
cmab_attribute_ids.each do |attribute_id|
attribute = project_config.attribute_id_map[attribute_id]
filtered_user_attributes[attribute.key] = user_attributes[attribute.key] if attribute && user_attributes.key?(attribute.key)
next unless attribute

attribute_key = attribute['key']
filtered_user_attributes[attribute_key] = user_attributes[attribute_key] if user_attributes.key?(attribute_key)
end

filtered_user_attributes
Expand Down
6 changes: 5 additions & 1 deletion lib/optimizely/decision_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont
variation_id = variation_result.variation_id
cmab_uuid = variation_result.cmab_uuid
decide_reasons.push(*reasons_received)

# If there's an error, return immediately instead of falling back to next experiment
return DecisionResult.new(nil, error, decide_reasons) if error

next unless variation_id

variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
Expand Down Expand Up @@ -526,7 +530,7 @@ def get_decision_for_cmab_experiment(project_config, experiment, user_context, b
)
CmabDecisionResult.new(false, cmab_decision, decide_reasons)
rescue StandardError => e
error_message = "Failed to fetch CMAB decision for experiment '#{experiment['key']}'"
error_message = "Failed to fetch CMAB data for experiment #{experiment['key']}."
decide_reasons.push(error_message)
@logger&.log(Logger::ERROR, "#{error_message} #{e}")
CmabDecisionResult.new(true, nil, decide_reasons)
Expand Down
4 changes: 2 additions & 2 deletions spec/cmab/cmab_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
let(:user_attributes) { {'age' => 25, 'location' => 'USA'} }

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

before do
allow(mock_user_context).to receive(:user_id).and_return(user_id)
Expand Down
2 changes: 1 addition & 1 deletion spec/decision_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,7 @@
expect(variation_result.cmab_uuid).to be_nil
expect(variation_result.error).to eq(true)
expect(variation_result.reasons).to include(
"Failed to fetch CMAB decision for experiment 'cmab_experiment'"
'Failed to fetch CMAB data for experiment cmab_experiment.'
)

# Verify CMAB service was called but errored
Expand Down
1 change: 1 addition & 0 deletions spec/optimizely_user_context_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,7 @@
decision = user_context_obj.decide(feature_key, [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS])
expect(decision.variation_key).to eq('18257766532')
expect(decision.rule_key).to eq('18322080788')
# puts decision.reasons
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.')

# delivery-rule-to-decision
Expand Down
97 changes: 97 additions & 0 deletions spec/project_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4308,6 +4308,103 @@ def callback(_args); end
expect(decision.reasons).to include('CMAB service failed to fetch decision')
end
end
describe 'CMAB experiments' do
it 'should include CMAB UUID in dispatched event when decision service returns CMAB result' do
# Use an existing feature flag from the test config
feature_flag_key = 'boolean_single_variable_feature'

# Get an existing experiment that actually exists in the datafile
# Looking at the test config, let's use experiment ID '122230' which exists
existing_experiment = project_config.get_experiment_from_id('122230')

# Modify the existing experiment to be a CMAB experiment
cmab_experiment = existing_experiment.dup
cmab_experiment['trafficAllocation'] = [] # Empty for CMAB
cmab_experiment['cmab'] = {'attributeIds' => %w[808797688 808797689], 'trafficAllocation' => 4000}

# Mock the config to return our modified CMAB experiment
allow(project_instance.config_manager.config).to receive(:get_experiment_from_id)
.with('122230')
.and_return(cmab_experiment)

allow(project_instance.config_manager.config).to receive(:experiment_running?)
.with(cmab_experiment)
.and_return(true)

# Get the feature flag and update it to reference our CMAB experiment
feature_flag = project_instance.config_manager.config.get_feature_flag_from_key(feature_flag_key)
feature_flag['experimentIds'] = ['122230']

# Use existing variations from the original experiment
variation_to_use = existing_experiment['variations'][0]

# Create a decision with CMAB UUID
expected_cmab_uuid = 'uuid-cmab'
decision_with_cmab = Optimizely::DecisionService::Decision.new(
cmab_experiment,
variation_to_use,
Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'],
expected_cmab_uuid
)

decision_result_with_cmab = Optimizely::DecisionService::DecisionResult.new(
decision_with_cmab,
false,
[]
)

# Mock get_variations_for_feature_list to return CMAB result
allow(project_instance.decision_service).to receive(:get_variations_for_feature_list)
.and_return([decision_result_with_cmab])

# Set up time and UUID mocks for consistent event data
allow(Time).to receive(:now).and_return(time_now)
allow(SecureRandom).to receive(:uuid).and_return('a68cf1ad-0393-4e18-af87-efe8f01a7c9c')

# Create array to capture dispatched events
dispatched_events = []
allow(project_instance.event_dispatcher).to receive(:dispatch_event) do |event|
dispatched_events << event
end

user_context = project_instance.create_user_context('test_user')
decision = user_context.decide(feature_flag_key)

# Wait for batch processing thread to send event
sleep 0.1 until project_instance.event_processor.event_queue.empty?

# Verify the decision contains expected information
expect(decision.enabled).to eq(true)
expect(decision.variation_key).to eq(variation_to_use['key'])
expect(decision.rule_key).to eq(existing_experiment['key'])
expect(decision.flag_key).to eq(feature_flag_key)

# Verify an event was dispatched
expect(dispatched_events.length).to eq(1)

dispatched_event = dispatched_events[0]

# Remove the puts statement and verify the event structure and CMAB UUID
expect(dispatched_event.params).to have_key(:visitors)
expect(dispatched_event.params[:visitors].length).to be > 0
expect(dispatched_event.params[:visitors][0]).to have_key(:snapshots)
expect(dispatched_event.params[:visitors][0][:snapshots].length).to be > 0
expect(dispatched_event.params[:visitors][0][:snapshots][0]).to have_key(:decisions)
expect(dispatched_event.params[:visitors][0][:snapshots][0][:decisions].length).to be > 0

# Get the metadata and assert CMAB UUID
metadata = dispatched_event.params[:visitors][0][:snapshots][0][:decisions][0][:metadata]
expect(metadata).to have_key(:cmab_uuid)
expect(metadata[:cmab_uuid]).to eq(expected_cmab_uuid)

# Also verify other expected metadata fields
expect(metadata[:flag_key]).to eq(feature_flag_key)
expect(metadata[:rule_key]).to eq('test_experiment_multivariate')
expect(metadata[:rule_type]).to eq('feature-test')
expect(metadata[:variation_key]).to eq('Fred')
expect(metadata[:enabled]).to eq(true)
end
end
end

describe '#decide_all' do
Expand Down