diff --git a/lib/optimizely.rb b/lib/optimizely.rb index 7c5571b3..f2e1dd82 100644 --- a/lib/optimizely.rb +++ b/lib/optimizely.rb @@ -185,12 +185,17 @@ def create_optimizely_decision(user_context, flag_key, decision, reasons, decide feature_flag = config.get_feature_flag_from_key(flag_key) experiment = nil decision_source = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] + experiment_id = nil + variation_id = nil + # Send impression event if Decision came from a feature test and decide options doesn't include disableDecisionEvent if decision.is_a?(Optimizely::DecisionService::Decision) experiment = decision.experiment rule_key = experiment ? experiment['key'] : nil + experiment_id = experiment ? experiment['id'] : nil variation = decision['variation'] variation_key = variation ? variation['key'] : nil + variation_id = variation ? variation['id'] : nil feature_enabled = variation ? variation['featureEnabled'] : false decision_source = decision.source end @@ -214,14 +219,16 @@ def create_optimizely_decision(user_context, flag_key, decision, reasons, decide @notification_center.send_notifications( NotificationCenter::NOTIFICATION_TYPES[:DECISION], Helpers::Constants::DECISION_NOTIFICATION_TYPES['FLAG'], - user_id, (attributes || {}), + user_id, attributes || {}, flag_key: flag_key, enabled: feature_enabled, variables: all_variables, variation_key: variation_key, rule_key: rule_key, reasons: should_include_reasons ? reasons : [], - decision_event_dispatched: decision_event_dispatched + decision_event_dispatched: decision_event_dispatched, + experiment_id: experiment_id, + variation_id: variation_id ) OptimizelyDecision.new( @@ -625,7 +632,7 @@ def is_feature_enabled(feature_flag_key, user_id, attributes = nil) @notification_center.send_notifications( NotificationCenter::NOTIFICATION_TYPES[:DECISION], Helpers::Constants::DECISION_NOTIFICATION_TYPES['FEATURE'], - user_id, (attributes || {}), + user_id, attributes || {}, feature_key: feature_flag_key, feature_enabled: feature_enabled, source: source_string, @@ -853,7 +860,7 @@ def get_all_feature_variables(feature_flag_key, user_id, attributes = nil) @notification_center.send_notifications( NotificationCenter::NOTIFICATION_TYPES[:DECISION], - Helpers::Constants::DECISION_NOTIFICATION_TYPES['ALL_FEATURE_VARIABLES'], user_id, (attributes || {}), + Helpers::Constants::DECISION_NOTIFICATION_TYPES['ALL_FEATURE_VARIABLES'], user_id, attributes || {}, feature_key: feature_flag_key, feature_enabled: feature_enabled, source: source_string, @@ -1033,7 +1040,7 @@ def get_variation_with_config(experiment_key, user_id, attributes, config) end @notification_center.send_notifications( NotificationCenter::NOTIFICATION_TYPES[:DECISION], - decision_notification_type, user_id, (attributes || {}), + decision_notification_type, user_id, attributes || {}, experiment_key: experiment_key, variation_key: variation_key ) @@ -1108,7 +1115,7 @@ def get_feature_variable_for_type(feature_flag_key, variable_key, variable_type, @notification_center.send_notifications( NotificationCenter::NOTIFICATION_TYPES[:DECISION], - Helpers::Constants::DECISION_NOTIFICATION_TYPES['FEATURE_VARIABLE'], user_id, (attributes || {}), + Helpers::Constants::DECISION_NOTIFICATION_TYPES['FEATURE_VARIABLE'], user_id, attributes || {}, feature_key: feature_flag_key, feature_enabled: feature_enabled, source: source_string, diff --git a/lib/optimizely/cmab/cmab_client.rb b/lib/optimizely/cmab/cmab_client.rb new file mode 100644 index 00000000..113f1d4a --- /dev/null +++ b/lib/optimizely/cmab/cmab_client.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +# +# Copyright 2025 Optimizely and contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +require 'optimizely/helpers/http_utils' +require 'optimizely/helpers/constants' + +module Optimizely + # Default constants for CMAB requests + DEFAULT_MAX_RETRIES = 3 + DEFAULT_INITIAL_BACKOFF = 0.1 # in seconds (100 ms) + DEFAULT_MAX_BACKOFF = 10 # in seconds + DEFAULT_BACKOFF_MULTIPLIER = 2.0 + MAX_WAIT_TIME = 10 + + class CmabRetryConfig + # Configuration for retrying CMAB requests. + # Contains parameters for maximum retries, backoff intervals, and multipliers. + attr_reader :max_retries, :initial_backoff, :max_backoff, :backoff_multiplier + + def initialize(max_retries: DEFAULT_MAX_RETRIES, initial_backoff: DEFAULT_INITIAL_BACKOFF, max_backoff: DEFAULT_MAX_BACKOFF, backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER) + @max_retries = max_retries + @initial_backoff = initial_backoff + @max_backoff = max_backoff + @backoff_multiplier = backoff_multiplier + end + end + + class DefaultCmabClient + # Client for interacting with the CMAB service. + # Provides methods to fetch decisions with optional retry logic. + + def initialize(http_client = nil, retry_config = nil, logger = nil) + # Initialize the CMAB client. + # Args: + # http_client: HTTP client for making requests. + # retry_config: Configuration for retry settings. + # logger: Logger for logging errors and info. + @http_client = http_client || DefaultHttpClient.new + @retry_config = retry_config || CmabRetryConfig.new + @logger = logger || NoOpLogger.new + end + + def fetch_decision(rule_id, user_id, attributes, cmab_uuid, timeout: MAX_WAIT_TIME) + # Fetches a decision from the CMAB service. + # Args: + # rule_id: The rule ID for the experiment. + # user_id: The user ID for the request. + # attributes: User attributes for the request. + # cmab_uuid: Unique identifier for the CMAB request. + # timeout: Maximum wait time for the request to respond in seconds. (default is 10 seconds). + # Returns: + # The variation ID. + url = "https://prediction.cmab.optimizely.com/predict/#{rule_id}" + cmab_attributes = attributes.map { |key, value| {'id' => key.to_s, 'value' => value, 'type' => 'custom_attribute'} } + + request_body = { + instances: [{ + visitorId: user_id, + experimentId: rule_id, + attributes: cmab_attributes, + cmabUUID: cmab_uuid + }] + } + + if @retry_config && @retry_config.max_retries.to_i.positive? + _do_fetch_with_retry(url, request_body, @retry_config, timeout) + else + _do_fetch(url, request_body, timeout) + end + end + + def _do_fetch(url, request_body, timeout) + # Perform a single fetch request to the CMAB prediction service. + + # Args: + # url: The endpoint URL. + # request_body: The request payload. + # timeout: Maximum wait time for the request to respond in seconds. + # Returns: + # The variation ID from the response. + + headers = {'Content-Type' => 'application/json'} + begin + response = @http_client.post(url, json: request_body, headers: headers, timeout: timeout.to_i) + rescue StandardError => e + error_message = Optimizely::Helpers::Constants::CMAB_FETCH_FAILED % e.message + @logger.log(Logger::ERROR, error_message) + raise CmabFetchError, error_message + end + + unless (200..299).include?(response.status_code) + error_message = Optimizely::Helpers::Constants::CMAB_FETCH_FAILED % response.status_code + @logger.log(Logger::ERROR, error_message) + raise CmabFetchError, error_message + end + + begin + body = response.json + rescue JSON::ParserError, Optimizely::CmabInvalidResponseError + error_message = Optimizely::Helpers::Constants::INVALID_CMAB_FETCH_RESPONSE + @logger.log(Logger::ERROR, error_message) + raise CmabInvalidResponseError, error_message + end + + unless validate_response(body) + error_message = Optimizely::Helpers::Constants::INVALID_CMAB_FETCH_RESPONSE + @logger.log(Logger::ERROR, error_message) + raise CmabInvalidResponseError, error_message + end + + body['predictions'][0]['variationId'] + end + + def validate_response(body) + # Validate the response structure from the CMAB service. + # Args: + # body: The JSON response body to validate. + # Returns: + # true if valid, false otherwise. + + body.is_a?(Hash) && + body.key?('predictions') && + body['predictions'].is_a?(Array) && + !body['predictions'].empty? && + body['predictions'][0].is_a?(Hash) && + body['predictions'][0].key?('variationId') + end + + def _do_fetch_with_retry(url, request_body, retry_config, timeout) + # Perform a fetch request with retry logic. + # Args: + # url: The endpoint URL. + # request_body: The request payload. + # retry_config: Configuration for retry settings. + # timeout: Maximum wait time for the request to respond in seconds. + # Returns: + # The variation ID from the response. + + backoff = retry_config.initial_backoff + + (0..retry_config.max_retries).each do |attempt| + variation_id = _do_fetch(url, request_body, timeout) + return variation_id + rescue StandardError => e + if attempt < retry_config.max_retries + @logger.log(Logger::INFO, "Retrying CMAB request (attempt #{attempt + 1}) after #{backoff} seconds...") + Kernel.sleep(backoff) + + backoff = [ + backoff * retry_config.backoff_multiplier, + retry_config.max_backoff + ].min + else + @logger.log(Logger::ERROR, "Max retries exceeded for CMAB request: #{e.message}") + raise Optimizely::CmabFetchError, "CMAB decision fetch failed (#{e.message})." + end + end + end + end + + class DefaultHttpClient + # Default HTTP client for making requests. + # Uses Optimizely::Helpers::HttpUtils to make requests. + + def post(url, json: nil, headers: {}, timeout: nil) + # Makes a POST request to the specified URL with JSON body and headers. + # Args: + # url: The endpoint URL. + # json: The JSON payload to send in the request body. + # headers: Additional headers for the request. + # timeout: Maximum wait time for the request to respond in seconds. + # Returns: + # The response object. + + response = Optimizely::Helpers::HttpUtils.make_request(url, :post, json.to_json, headers, timeout) + + HttpResponseAdapter.new(response) + end + + class HttpResponseAdapter + # Adapter for HTTP response to provide a consistent interface. + # Args: + # response: The raw HTTP response object. + + def initialize(response) + @response = response + end + + def status_code + @response.code.to_i + end + + def json + JSON.parse(@response.body) + rescue JSON::ParserError + raise Optimizely::CmabInvalidResponseError, Optimizely::Helpers::Constants::INVALID_CMAB_FETCH_RESPONSE + end + + def body + @response.body + end + end + end + + class NoOpLogger + # A no-operation logger that does nothing. + def log(_level, _message); end + end +end diff --git a/lib/optimizely/config/datafile_project_config.rb b/lib/optimizely/config/datafile_project_config.rb index 25357133..1f03171d 100644 --- a/lib/optimizely/config/datafile_project_config.rb +++ b/lib/optimizely/config/datafile_project_config.rb @@ -27,7 +27,7 @@ class DatafileProjectConfig < ProjectConfig attr_reader :datafile, :account_id, :attributes, :audiences, :typed_audiences, :events, :experiments, :feature_flags, :groups, :project_id, :bot_filtering, :revision, :sdk_key, :environment_key, :rollouts, :version, :send_flag_decisions, - :attribute_key_map, :audience_id_map, :event_key_map, :experiment_feature_map, + :attribute_key_map, :attribute_id_to_key_map, :audience_id_map, :event_key_map, :experiment_feature_map, :experiment_id_map, :experiment_key_map, :feature_flag_key_map, :feature_variable_key_map, :group_id_map, :rollout_id_map, :rollout_experiment_id_map, :variation_id_map, :variation_id_to_variable_usage_map, :variation_key_map, :variation_id_map_by_experiment_id, @@ -82,6 +82,10 @@ def initialize(datafile, logger, error_handler) # Utility maps for quick lookup @attribute_key_map = generate_key_map(@attributes, 'key') + @attribute_id_to_key_map = {} + @attributes.each do |attribute| + @attribute_id_to_key_map[attribute['id']] = attribute['key'] + end @event_key_map = generate_key_map(@events, 'key') @group_id_map = generate_key_map(@groups, 'id') @group_id_map.each do |key, group| @@ -440,6 +444,40 @@ def get_attribute_id(attribute_key) nil end + def get_attribute_by_key(attribute_key) + # Get attribute for the provided attribute key. + # + # Args: + # Attribute key for which attribute is to be fetched. + # + # Returns: + # Attribute corresponding to the provided attribute key. + attribute = @attribute_key_map[attribute_key] + return attribute if attribute + + invalid_attribute_error = InvalidAttributeError.new(attribute_key) + @logger.log Logger::ERROR, invalid_attribute_error.message + @error_handler.handle_error invalid_attribute_error + nil + end + + def get_attribute_key_by_id(attribute_id) + # Get attribute key for the provided attribute ID. + # + # Args: + # Attribute ID for which attribute is to be fetched. + # + # Returns: + # Attribute key corresponding to the provided attribute ID. + attribute = @attribute_id_to_key_map[attribute_id] + return attribute if attribute + + invalid_attribute_error = InvalidAttributeError.new(attribute_id) + @logger.log Logger::ERROR, invalid_attribute_error.message + @error_handler.handle_error invalid_attribute_error + nil + end + def variation_id_exists?(experiment_id, variation_id) # Determines if a given experiment ID / variation ID pair exists in the datafile # diff --git a/lib/optimizely/exceptions.rb b/lib/optimizely/exceptions.rb index 5d608b2f..073433af 100644 --- a/lib/optimizely/exceptions.rb +++ b/lib/optimizely/exceptions.rb @@ -190,4 +190,28 @@ def initialize(msg = 'Provided semantic version is invalid.') super end end + + class CmabError < Error + # Base exception for CMAB errors + + def initialize(msg = 'CMAB error occurred.') + super + end + end + + class CmabFetchError < CmabError + # Exception raised when CMAB fetch fails + + def initialize(msg = 'CMAB decision fetch failed with status:') + super + end + end + + class CmabInvalidResponseError < CmabError + # Exception raised when CMAB fetch returns an invalid response + + def initialize(msg = 'Invalid CMAB fetch response') + super + end + end end diff --git a/lib/optimizely/helpers/constants.rb b/lib/optimizely/helpers/constants.rb index 02b815ae..af3e5a08 100644 --- a/lib/optimizely/helpers/constants.rb +++ b/lib/optimizely/helpers/constants.rb @@ -201,6 +201,9 @@ module Constants }, 'forcedVariations' => { 'type' => 'object' + }, + 'cmab' => { + 'type' => 'object' } }, 'required' => %w[ @@ -303,6 +306,18 @@ module Constants }, 'required' => %w[key] } + }, + 'cmab' => { + 'type' => 'object', + 'properties' => { + 'attributeIds' => { + 'type' => 'array', + 'items' => {'type' => 'string'} + }, + 'trafficAllocation' => { + 'type' => 'integer' + } + } } }, 'required' => %w[ @@ -454,6 +469,9 @@ module Constants 'IF_MODIFIED_SINCE' => 'If-Modified-Since', 'LAST_MODIFIED' => 'Last-Modified' }.freeze + + CMAB_FETCH_FAILED = 'CMAB decision fetch failed (%s).' + INVALID_CMAB_FETCH_RESPONSE = 'Invalid CMAB fetch response' end end end diff --git a/lib/optimizely/helpers/validator.rb b/lib/optimizely/helpers/validator.rb index 4d975483..d3baa447 100644 --- a/lib/optimizely/helpers/validator.rb +++ b/lib/optimizely/helpers/validator.rb @@ -122,11 +122,11 @@ def inputs_valid?(variables, logger = NoOpLogger.new, level = Logger::ERROR) return false unless variables.respond_to?(:each) && !variables.empty? - is_valid = true # rubocop:disable Lint/UselessAssignment + is_valid = true if variables.include? :user_id # Empty str is a valid user ID. unless variables[:user_id].is_a?(String) - is_valid = false # rubocop:disable Lint/UselessAssignment + is_valid = false logger.log(level, "#{Constants::INPUT_VARIABLES['USER_ID']} is invalid") end variables.delete :user_id diff --git a/lib/optimizely/project_config.rb b/lib/optimizely/project_config.rb index b0d43aa3..43e86441 100644 --- a/lib/optimizely/project_config.rb +++ b/lib/optimizely/project_config.rb @@ -86,6 +86,10 @@ def get_whitelisted_variations(experiment_id); end def get_attribute_id(attribute_key); end + def get_attribute_by_key(attribute_key); end + + def get_attribute_key_by_id(attribute_id); end + def variation_id_exists?(experiment_id, variation_id); end def get_feature_flag_from_key(feature_flag_key); end diff --git a/spec/cmab_client_spec.rb b/spec/cmab_client_spec.rb new file mode 100644 index 00000000..f25c78fa --- /dev/null +++ b/spec/cmab_client_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +# +# Copyright 2025 Optimizely and contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +require 'spec_helper' +require 'optimizely/logger' +require 'optimizely/cmab/cmab_client' +require 'webmock/rspec' + +describe Optimizely::DefaultCmabClient do + let(:spy_logger) { spy('logger') } + let(:retry_config) { Optimizely::CmabRetryConfig.new(max_retries: 3, initial_backoff: 0.01, max_backoff: 1, backoff_multiplier: 2) } + let(:rule_id) { 'test_rule' } + let(:user_id) { 'user123' } + let(:attributes) { {'attr1': 'value1', 'attr2': 'value2'} } + let(:cmab_uuid) { 'uuid-1234' } + let(:expected_url) { "https://prediction.cmab.optimizely.com/predict/#{rule_id}" } + let(:expected_body_for_webmock) do + { + instances: [{ + visitorId: user_id, + experimentId: rule_id, + attributes: [ + {'id' => 'attr1', 'value' => 'value1', 'type' => 'custom_attribute'}, + {'id' => 'attr2', 'value' => 'value2', 'type' => 'custom_attribute'} + ], + cmabUUID: cmab_uuid + }] + }.to_json + end + let(:expected_headers) { {'Content-Type' => 'application/json'} } + + before do + allow(Kernel).to receive(:sleep) + WebMock.disable_net_connect! + end + + after do + RSpec::Mocks.space.proxy_for(spy_logger).reset + WebMock.reset! + WebMock.allow_net_connect! + end + + context 'when client is configured without retries' do + let(:client) { described_class.new(nil, Optimizely::CmabRetryConfig.new(max_retries: 0), spy_logger) } + + it 'should return the variation id on success' do + WebMock.stub_request(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers) + .to_return(status: 200, body: {'predictions' => [{'variationId' => 'abc123'}]}.to_json, headers: {'Content-Type' => 'application/json'}) + + result = client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) + + expect(result).to eq('abc123') + expect(WebMock).to have_requested(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers).once + expect(Kernel).not_to have_received(:sleep) + end + + it 'should raise error on http client exception' do + WebMock.stub_request(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers) + .to_raise(StandardError.new('Connection error')) + + expect do + client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) + end.to raise_error(Optimizely::CmabFetchError, /Connection error/) + + expect(WebMock).to have_requested(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers).once + expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Connection error')) + expect(Kernel).not_to have_received(:sleep) + end + + it 'should raise error on non success status' do + WebMock.stub_request(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers) + .to_return(status: 500) + + expect do + client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) + end.to raise_error(Optimizely::CmabFetchError, /500/) + + expect(WebMock).to have_requested(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers).once + expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('500')) + expect(Kernel).not_to have_received(:sleep) + end + + it 'should raise error on invalid json response' do + WebMock.stub_request(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers) + .to_return(status: 200, body: 'this is not json', headers: {'Content-Type' => 'text/plain'}) + + expect do + client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) + end.to raise_error(Optimizely::CmabInvalidResponseError, /Invalid CMAB fetch response/) + + expect(WebMock).to have_requested(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers).once + expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Invalid CMAB fetch response')) + expect(Kernel).not_to have_received(:sleep) + end + + it 'should raise error on invalid response structure' do + WebMock.stub_request(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers) + .to_return(status: 200, body: {'no_predictions' => []}.to_json, headers: {'Content-Type' => 'application/json'}) + + expect do + client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) + end.to raise_error(Optimizely::CmabInvalidResponseError, /Invalid CMAB fetch response/) + + expect(WebMock).to have_requested(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers).once + expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Invalid CMAB fetch response')) + expect(Kernel).not_to have_received(:sleep) + end + end + + context 'when client is configured with retries' do + let(:client_with_retry) { described_class.new(nil, retry_config, spy_logger) } + + it 'should return the variation id on first try' do + WebMock.stub_request(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers) + .to_return(status: 200, body: {'predictions' => [{'variationId' => 'abc123'}]}.to_json, headers: {'Content-Type' => 'application/json'}) + + result = client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid) + + expect(result).to eq('abc123') + expect(WebMock).to have_requested(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers).once + expect(Kernel).not_to have_received(:sleep) + end + + it 'should return the variation id on third try' do + WebMock.stub_request(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers) + .to_return({status: 500}, + {status: 500}, + {status: 200, body: {'predictions' => [{'variationId' => 'xyz456'}]}.to_json, headers: {'Content-Type' => 'application/json'}}) + + result = client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid) + + expect(result).to eq('xyz456') + expect(WebMock).to have_requested(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers).times(3) + + expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 1) after 0.01 seconds...').once + expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 2) after 0.02 seconds...').once + expect(spy_logger).not_to have_received(:log).with(Logger::INFO, a_string_including('Retrying CMAB request (attempt 3)')) + + expect(Kernel).to have_received(:sleep).with(0.01).once + expect(Kernel).to have_received(:sleep).with(0.02).once + expect(Kernel).not_to have_received(:sleep).with(0.04) + end + + it 'should exhaust all retry attempts' do + WebMock.stub_request(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers) + .to_return({status: 500}, + {status: 500}, + {status: 500}, + {status: 500}) + + expect do + client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid) + end.to raise_error(Optimizely::CmabFetchError) + + expect(WebMock).to have_requested(:post, expected_url) + .with(body: expected_body_for_webmock, headers: expected_headers).times(4) + + expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 1) after 0.01 seconds...').once + expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 2) after 0.02 seconds...').once + expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 3) after 0.04 seconds...').once + + expect(Kernel).to have_received(:sleep).with(0.01).once + expect(Kernel).to have_received(:sleep).with(0.02).once + expect(Kernel).to have_received(:sleep).with(0.04).once + + expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Max retries exceeded for CMAB request')) + end + end +end diff --git a/spec/config/datafile_project_config_spec.rb b/spec/config/datafile_project_config_spec.rb index e30d07e1..362141d6 100644 --- a/spec/config/datafile_project_config_spec.rb +++ b/spec/config/datafile_project_config_spec.rb @@ -1078,6 +1078,67 @@ end end + describe '#test_cmab_field_population' do + it 'Should return CMAB details' do + config_dict = Marshal.load(Marshal.dump(OptimizelySpec::VALID_CONFIG_BODY)) + config_dict['experiments'][0]['cmab'] = {'attributeIds' => %w[808797688 808797689], 'trafficAllocation' => 4000} + config_dict['experiments'][0]['trafficAllocation'] = [] + + config_json = JSON.dump(config_dict) + project_config = Optimizely::DatafileProjectConfig.new(config_json, logger, error_handler) + + experiment = project_config.get_experiment_from_key('test_experiment') + expect(experiment['cmab']).to eq({'attributeIds' => %w[808797688 808797689], 'trafficAllocation' => 4000}) + + experiment2 = project_config.get_experiment_from_key('test_experiment_with_audience') + expect(experiment2['cmab']).to eq(nil) + end + it 'should return nil if cmab field is missing' do + config_dict = Marshal.load(Marshal.dump(OptimizelySpec::VALID_CONFIG_BODY)) + config_dict['experiments'][0].delete('cmab') + config_json = JSON.dump(config_dict) + project_config = Optimizely::DatafileProjectConfig.new(config_json, logger, error_handler) + experiment = project_config.get_experiment_from_key('test_experiment') + expect(experiment['cmab']).to eq(nil) + end + + it 'should handle empty cmab object' do + config_dict = Marshal.load(Marshal.dump(OptimizelySpec::VALID_CONFIG_BODY)) + config_dict['experiments'][0]['cmab'] = {} + config_json = JSON.dump(config_dict) + project_config = Optimizely::DatafileProjectConfig.new(config_json, logger, error_handler) + experiment = project_config.get_experiment_from_key('test_experiment') + expect(experiment['cmab']).to eq({}) + end + + it 'should handle cmab with only attributeIds' do + config_dict = Marshal.load(Marshal.dump(OptimizelySpec::VALID_CONFIG_BODY)) + config_dict['experiments'][0]['cmab'] = {'attributeIds' => %w[808797688]} + config_json = JSON.dump(config_dict) + project_config = Optimizely::DatafileProjectConfig.new(config_json, logger, error_handler) + experiment = project_config.get_experiment_from_key('test_experiment') + expect(experiment['cmab']).to eq({'attributeIds' => %w[808797688]}) + end + + it 'should handle cmab with only trafficAllocation' do + config_dict = Marshal.load(Marshal.dump(OptimizelySpec::VALID_CONFIG_BODY)) + config_dict['experiments'][0]['cmab'] = {'trafficAllocation' => 1234} + config_json = JSON.dump(config_dict) + project_config = Optimizely::DatafileProjectConfig.new(config_json, logger, error_handler) + experiment = project_config.get_experiment_from_key('test_experiment') + expect(experiment['cmab']).to eq({'trafficAllocation' => 1234}) + end + + it 'should not affect other experiments when cmab is set' do + config_dict = Marshal.load(Marshal.dump(OptimizelySpec::VALID_CONFIG_BODY)) + config_dict['experiments'][0]['cmab'] = {'attributeIds' => %w[808797688 808797689], 'trafficAllocation' => 4000} + config_json = JSON.dump(config_dict) + project_config = Optimizely::DatafileProjectConfig.new(config_json, logger, error_handler) + experiment2 = project_config.get_experiment_from_key('test_experiment_with_audience') + expect(experiment2['cmab']).to eq(nil) + end + end + describe '#feature_experiment' do let(:config) { Optimizely::DatafileProjectConfig.new(config_body_JSON, logger, error_handler) } diff --git a/spec/optimizely_user_context_spec.rb b/spec/optimizely_user_context_spec.rb index c968c336..515068c0 100644 --- a/spec/optimizely_user_context_spec.rb +++ b/spec/optimizely_user_context_spec.rb @@ -251,7 +251,9 @@ variation_key: '3324490562', rule_key: nil, reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: '3324490562' ) user_context_obj = forced_decision_project_instance.create_user_context(user_id) context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(feature_key, nil) @@ -347,7 +349,9 @@ variation_key: 'b', rule_key: 'exp_with_audience', reasons: ['Variation (b) is mapped to flag (feature_1), rule (exp_with_audience) and user (tester) in the forced decision map.'], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: '10390977673', + variation_id: '10416523121' ) user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(feature_key, 'exp_with_audience') @@ -464,7 +468,9 @@ variation_key: '3324490562', rule_key: nil, reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: '3324490562' ) user_context_obj = forced_decision_project_instance.create_user_context(user_id) context_with_flag = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(feature_key, nil) diff --git a/spec/project_spec.rb b/spec/project_spec.rb index 7c02f765..f857a5ce 100644 --- a/spec/project_spec.rb +++ b/spec/project_spec.rb @@ -2079,7 +2079,7 @@ def callback(_args); end it 'should return only enabled feature flags keys' do # Sets all feature-flags keys with randomly assigned status features_keys = project_config.feature_flags.map do |item| - {key: (item['key']).to_s, value: [true, false].sample} # '[true, false].sample' generates random boolean + {key: item['key'].to_s, value: [true, false].sample} # '[true, false].sample' generates random boolean end enabled_features = features_keys.map { |x| x[:key] if x[:value] == true }.compact @@ -3758,7 +3758,9 @@ def callback(_args); end variation_key: 'Fred', rule_key: 'test_experiment_multivariate', reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: experiment_to_return['id'], + variation_id: variation_to_return['id'] ) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) decision_to_return = Optimizely::DecisionService::Decision.new( @@ -3801,7 +3803,9 @@ def callback(_args); end variation_key: 'Fred', rule_key: 'test_experiment_multivariate', reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: experiment_to_return['id'], + variation_id: variation_to_return['id'] ) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) decision_to_return = Optimizely::DecisionService::Decision.new( @@ -3883,7 +3887,9 @@ def callback(_args); end variation_key: 'Fred', rule_key: 'test_experiment_multivariate', reasons: [], - decision_event_dispatched: false + decision_event_dispatched: false, + experiment_id: experiment_to_return['id'], + variation_id: variation_to_return['id'] ) allow(project_config).to receive(:send_flag_decisions).and_return(false) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) @@ -3921,7 +3927,9 @@ def callback(_args); end variation_key: nil, rule_key: nil, reasons: [], - decision_event_dispatched: false + decision_event_dispatched: false, + experiment_id: nil, + variation_id: nil ) allow(project_config).to receive(:send_flag_decisions).and_return(false) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) @@ -3958,7 +3966,9 @@ def callback(_args); end variation_key: nil, rule_key: nil, reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: nil ) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) decision_to_return = nil @@ -4122,7 +4132,9 @@ def callback(_args); end "The user 'user1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'.", "Feature flag 'multi_variate_feature' is not used in a rollout." ], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: nil ) expect(project_instance.notification_center).to receive(:send_notifications) .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) @@ -4162,7 +4174,9 @@ def callback(_args); end variation_key: nil, rule_key: nil, reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: nil ) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) user_context = project_instance.create_user_context('user1') @@ -4481,7 +4495,9 @@ def callback(_args); end "The user 'user1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'.", "Feature flag 'multi_variate_feature' is not used in a rollout." ], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: nil ) allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) user_context = custom_project_instance.create_user_context('user1') @@ -4521,7 +4537,9 @@ def callback(_args); end variation_key: nil, rule_key: nil, reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: nil ) allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) user_context = custom_project_instance.create_user_context('user1')