Skip to content

[FSSDK-11176] Update: Implement Decision Service methods to handle CMAB #369

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 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 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
56bd524
update: Remove commented debug output from Optimizely user context spec
FarhanAnjum-opti Jul 24, 2025
600bb79
Trigger CI build
FarhanAnjum-opti Jul 25, 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
47 changes: 38 additions & 9 deletions lib/optimizely.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,17 @@
require_relative 'optimizely/odp/odp_manager'
require_relative 'optimizely/helpers/sdk_settings'
require_relative 'optimizely/user_profile_tracker'
require_relative 'optimizely/cmab/cmab_client'
require_relative 'optimizely/cmab/cmab_service'

module Optimizely
class Project
include Optimizely::Decide

# CMAB Constants
DEFAULT_CMAB_CACHE_TIMEOUT = (30 * 60 * 1000)
DEFAULT_CMAB_CACHE_SIZE = 1000

attr_reader :notification_center
# @api no-doc
attr_reader :config_manager, :decision_service, :error_handler, :event_dispatcher,
Expand Down Expand Up @@ -131,7 +137,19 @@ def initialize(

setup_odp!(@config_manager.sdk_key)

@decision_service = DecisionService.new(@logger, @user_profile_service)
# Initialize CMAB components
@cmab_client = DefaultCmabClient.new(
retry_config: CmabRetryConfig.new,
logger: @logger
)
@cmab_cache = LRUCache.new(DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT)
@cmab_service = DefaultCmabService.new(
@cmab_cache,
@cmab_client,
@logger
)

@decision_service = DecisionService.new(@logger, @cmab_service, @user_profile_service)

@event_processor = if event_processor.respond_to?(:process)
event_processor
Expand Down Expand Up @@ -337,7 +355,7 @@ def decide_for_keys(user_context, keys, decide_options = [], ignore_default_opti

# If the feature flag is nil, create a default OptimizelyDecision and move to the next key
if feature_flag.nil?
decisions[key] = OptimizelyDecision.new(nil, false, nil, nil, key, user_context, [])
decisions[key] = OptimizelyDecision.new(variation_key: nil, enabled: false, variables: nil, rule_key: nil, flag_key: key, user_context: user_context, reasons: [])
next
end
valid_keys.push(key)
Expand All @@ -358,9 +376,17 @@ def decide_for_keys(user_context, keys, decide_options = [], ignore_default_opti
decision_list = @decision_service.get_variations_for_feature_list(config, flags_without_forced_decision, user_context, decide_options)

flags_without_forced_decision.each_with_index do |flag, i|
decision = decision_list[i][0]
reasons = decision_list[i][1]
decision = decision_list[i].decision
reasons = decision_list[i].reasons
error = decision_list[i].error
flag_key = flag['key']
# store error decision against key and remove key from valid keys
if error
optimizely_decision = OptimizelyDecision.new_error_decision(flag_key, user_context, reasons)
decisions[flag_key] = optimizely_decision
valid_keys.delete(flag_key) if valid_keys.include?(flag_key)
next
end
flag_decisions[flag_key] = decision
decision_reasons_dict[flag_key] ||= []
decision_reasons_dict[flag_key].push(*reasons)
Expand Down Expand Up @@ -599,8 +625,8 @@ def is_feature_enabled(feature_flag_key, user_id, attributes = nil)
end

user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)

decision_result = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
decision = decision_result.decision
feature_enabled = false
source_string = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']
if decision.is_a?(Optimizely::DecisionService::Decision)
Expand Down Expand Up @@ -839,7 +865,8 @@ def get_all_feature_variables(feature_flag_key, user_id, attributes = nil)
end

user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
decision_result = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
decision = decision_result.decision
variation = decision ? decision['variation'] : nil
feature_enabled = variation ? variation['featureEnabled'] : false
all_variables = {}
Expand Down Expand Up @@ -1029,7 +1056,8 @@ def get_variation_with_config(experiment_key, user_id, attributes, config)
user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
user_profile_tracker = UserProfileTracker.new(user_id, @user_profile_service, @logger)
user_profile_tracker.load_user_profile
variation_id, = @decision_service.get_variation(config, experiment_id, user_context, user_profile_tracker)
variation_result = @decision_service.get_variation(config, experiment_id, user_context, user_profile_tracker)
variation_id = variation_result.variation_id
user_profile_tracker.save_user_profile
variation = config.get_variation_from_id(experiment_key, variation_id) unless variation_id.nil?
variation_key = variation['key'] if variation
Expand Down Expand Up @@ -1097,7 +1125,8 @@ def get_feature_variable_for_type(feature_flag_key, variable_key, variable_type,
end

user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
decision_result = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
decision = decision_result.decision
variation = decision ? decision['variation'] : nil
feature_enabled = variation ? variation['featureEnabled'] : false

Expand Down
41 changes: 28 additions & 13 deletions lib/optimizely/bucketer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ def bucket(project_config, experiment, bucketing_id, user_id)
# user_id - String ID for user.
#
# Returns variation in which visitor with ID user_id has been placed. Nil if no variation.

variation_id, decide_reasons = bucket_to_entity_id(project_config, experiment, bucketing_id, user_id)
if variation_id && variation_id != ''
experiment_id = experiment['id']
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
return variation, decide_reasons
end

# Handle the case when the traffic range is empty due to sticky bucketing
if variation_id == ''
message = 'Bucketed into an empty traffic range. Returning nil.'
@logger.log(Logger::DEBUG, message)
decide_reasons.push(message)
end

[nil, decide_reasons]
end

def bucket_to_entity_id(project_config, experiment, bucketing_id, user_id)
return nil, [] if experiment.nil?

decide_reasons = []
Expand Down Expand Up @@ -84,22 +103,18 @@ def bucket(project_config, experiment, bucketing_id, user_id)
end

traffic_allocations = experiment['trafficAllocation']
if experiment['cmab']
traffic_allocations = [
{
'entityId' => '$',
'endOfRange' => experiment['cmab']['trafficAllocation']
}
]
end
variation_id, find_bucket_reasons = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations)
decide_reasons.push(*find_bucket_reasons)

if variation_id && variation_id != ''
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
return variation, decide_reasons
end

# Handle the case when the traffic range is empty due to sticky bucketing
if variation_id == ''
message = 'Bucketed into an empty traffic range. Returning nil.'
@logger.log(Logger::DEBUG, message)
decide_reasons.push(message)
end

[nil, decide_reasons]
[variation_id, decide_reasons]
end

def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations)
Expand Down
19 changes: 19 additions & 0 deletions lib/optimizely/decide/optimizely_decision.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@ def as_json
def to_json(*args)
as_json.to_json(*args)
end

# Create a new OptimizelyDecision representing an error state.
#
# @param key [String] The flag key
# @param user [OptimizelyUserContext] The user context
# @param reasons [Array<String>] List of reasons explaining the error
#
# @return [OptimizelyDecision] OptimizelyDecision with error state values
def self.new_error_decision(key, user, reasons = [])
new(
variation_key: nil,
enabled: false,
variables: {},
rule_key: nil,
flag_key: key,
user_context: user,
reasons: reasons
)
end
end
end
end
Loading
Loading