Skip to content

Commit

Permalink
feat(Decide): Add Decide API (#274)
Browse files Browse the repository at this point in the history
## Summary
Added new Apis to support the decide feature. Introduced a new `OptimizelyUserContext ` class through `create_user_context` class api. This creates an optimizely instance with memoized user context and exposes the following APIs
1. `set_attribute`
2. `decide`
3. `decide_all`
4. `decide_for_keys`
5. `track_event`

## Test plan
1. Manually tested thoroughly.
2. Added unit tests to cover new functionality.
3. All new and existing FSC tests pass.
  • Loading branch information
oakbani authored Dec 14, 2020
1 parent 9da6a58 commit f718a18
Show file tree
Hide file tree
Showing 11 changed files with 1,523 additions and 128 deletions.
185 changes: 184 additions & 1 deletion lib/optimizely.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
require_relative 'optimizely/config/datafile_project_config'
require_relative 'optimizely/config_manager/http_project_config_manager'
require_relative 'optimizely/config_manager/static_project_config_manager'
require_relative 'optimizely/decide/optimizely_decide_option'
require_relative 'optimizely/decide/optimizely_decision'
require_relative 'optimizely/decide/optimizely_decision_message'
require_relative 'optimizely/decision_service'
require_relative 'optimizely/error_handler'
require_relative 'optimizely/event_builder'
Expand All @@ -34,9 +37,12 @@
require_relative 'optimizely/logger'
require_relative 'optimizely/notification_center'
require_relative 'optimizely/optimizely_config'
require_relative 'optimizely/optimizely_user_context'

module Optimizely
class Project
include Optimizely::Decide

attr_reader :notification_center
# @api no-doc
attr_reader :config_manager, :decision_service, :error_handler, :event_dispatcher,
Expand Down Expand Up @@ -67,12 +73,21 @@ def initialize(
sdk_key = nil,
config_manager = nil,
notification_center = nil,
event_processor = nil
event_processor = nil,
default_decide_options = []
)
@logger = logger || NoOpLogger.new
@error_handler = error_handler || NoOpErrorHandler.new
@event_dispatcher = event_dispatcher || EventDispatcher.new(logger: @logger, error_handler: @error_handler)
@user_profile_service = user_profile_service
@default_decide_options = []

if default_decide_options.is_a? Array
@default_decide_options = default_decide_options.clone
else
@logger.log(Logger::DEBUG, 'Provided default decide options is not an array.')
@default_decide_options = []
end

begin
validate_instantiation_options
Expand Down Expand Up @@ -107,6 +122,174 @@ def initialize(
end
end

# Create a context of the user for which decision APIs will be called.
#
# A user context will be created successfully even when the SDK is not fully configured yet.
#
# @param user_id - The user ID to be used for bucketing.
# @param attributes - A Hash representing user attribute names and values.
#
# @return [OptimizelyUserContext] An OptimizelyUserContext associated with this OptimizelyClient.
# @return [nil] If user attributes are not in valid format.

def create_user_context(user_id, attributes = nil)
# We do not check for is_valid here as a user context can be created successfully
# even when the SDK is not fully configured.

# validate user_id
return nil unless Optimizely::Helpers::Validator.inputs_valid?(
{
user_id: user_id
}, @logger, Logger::ERROR
)

# validate attributes
return nil unless user_inputs_valid?(attributes)

user_context = OptimizelyUserContext.new(self, user_id, attributes)
user_context
end

def decide(user_context, key, decide_options = [])
# raising on user context as it is internal and not provided directly by the user.
raise if user_context.class != OptimizelyUserContext

reasons = []

# check if SDK is ready
unless is_valid
@logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide').message)
reasons.push(OptimizelyDecisionMessage::SDK_NOT_READY)
return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
end

# validate that key is a string
unless key.is_a?(String)
@logger.log(Logger::ERROR, 'Provided key is invalid')
reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
end

# validate that key maps to a feature flag
config = project_config
feature_flag = config.get_feature_flag_from_key(key)
unless feature_flag
@logger.log(Logger::ERROR, "No feature flag was found for key '#{key}'.")
reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
end

# merge decide_options and default_decide_options
if decide_options.is_a? Array
decide_options += @default_decide_options
else
@logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.')
decide_options = @default_decide_options
end

# Create Optimizely Decision Result.
user_id = user_context.user_id
attributes = user_context.user_attributes
variation_key = nil
feature_enabled = false
rule_key = nil
flag_key = key
all_variables = {}
decision_event_dispatched = false
experiment = nil
decision_source = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']

decision = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes, decide_options, reasons)

# 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['key']
variation = decision['variation']
variation_key = variation['key']
feature_enabled = variation['featureEnabled']
decision_source = decision.source
end

unless decide_options.include? OptimizelyDecideOption::DISABLE_DECISION_EVENT
if 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)
decision_event_dispatched = true
end
end

# Generate all variables map if decide options doesn't include excludeVariables
unless decide_options.include? OptimizelyDecideOption::EXCLUDE_VARIABLES
feature_flag['variables'].each do |variable|
variable_value = get_feature_variable_for_variation(key, feature_enabled, variation, variable, user_id)
all_variables[variable['key']] = Helpers::VariableType.cast_value_to_type(variable_value, variable['type'], @logger)
end
end

should_include_reasons = decide_options.include? OptimizelyDecideOption::INCLUDE_REASONS

# Send notification
@notification_center.send_notifications(
NotificationCenter::NOTIFICATION_TYPES[:DECISION],
Helpers::Constants::DECISION_NOTIFICATION_TYPES['FLAG'],
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
)

OptimizelyDecision.new(
variation_key: variation_key,
enabled: feature_enabled,
variables: all_variables,
rule_key: rule_key,
flag_key: flag_key,
user_context: user_context,
reasons: should_include_reasons ? reasons : []
)
end

def decide_all(user_context, decide_options = [])
# raising on user context as it is internal and not provided directly by the user.
raise if user_context.class != OptimizelyUserContext

# check if SDK is ready
unless is_valid
@logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide_all').message)
return {}
end

keys = []
project_config.feature_flags.each do |feature_flag|
keys.push(feature_flag['key'])
end
decide_for_keys(user_context, keys, decide_options)
end

def decide_for_keys(user_context, keys, decide_options = [])
# raising on user context as it is internal and not provided directly by the user.
raise if user_context.class != OptimizelyUserContext

# check if SDK is ready
unless is_valid
@logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide_for_keys').message)
return {}
end

enabled_flags_only = (!decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)) || (@default_decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)

decisions = {}
keys.each do |key|
decision = decide(user_context, key, decide_options)
decisions[key] = decision unless enabled_flags_only && !decision.enabled
end
decisions
end

# Buckets visitor and sends impression event to Optimizely.
#
# @param experiment_key - Experiment which needs to be activated.
Expand Down
37 changes: 19 additions & 18 deletions lib/optimizely/bucketer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def initialize(logger)
@bucket_seed = HASH_SEED
end

def bucket(project_config, experiment, bucketing_id, user_id)
def bucket(project_config, experiment, bucketing_id, user_id, decide_reasons = nil)
# Determines ID of variation to be shown for a given experiment key and user ID.
#
# project_config - Instance of ProjectConfig
Expand All @@ -58,46 +58,45 @@ def bucket(project_config, experiment, bucketing_id, user_id)
bucketed_experiment_id = find_bucket(bucketing_id, user_id, group_id, traffic_allocations)
# return if the user is not bucketed into any experiment
unless bucketed_experiment_id
@logger.log(Logger::INFO, "User '#{user_id}' is in no experiment.")
message = "User '#{user_id}' is in no experiment."
@logger.log(Logger::INFO, message)
decide_reasons&.push(message)
return nil
end

# return if the user is bucketed into a different experiment than the one specified
if bucketed_experiment_id != experiment_id
@logger.log(
Logger::INFO,
"User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}."
)
message = "User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}."
@logger.log(Logger::INFO, message)
decide_reasons&.push(message)
return nil
end

# continue bucketing if the user is bucketed into the experiment specified
@logger.log(
Logger::INFO,
"User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}."
)
message = "User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}."
@logger.log(Logger::INFO, message)
decide_reasons&.push(message)
end
end

traffic_allocations = experiment['trafficAllocation']
variation_id = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations)
variation_id = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations, decide_reasons)
if variation_id && variation_id != ''
variation = project_config.get_variation_from_id(experiment_key, variation_id)
return variation
end

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

nil
end

def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations)
def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations, decide_reasons = nil)
# Helper function to find the matching entity ID for a given bucketing value in a list of traffic allocations.
#
# bucketing_id - String A customer-assigned value user to generate bucketing key
Expand All @@ -108,8 +107,10 @@ def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations)
# Returns entity ID corresponding to the provided bucket value or nil if no match is found.
bucketing_key = format(BUCKETING_ID_TEMPLATE, bucketing_id: bucketing_id, entity_id: parent_id)
bucket_value = generate_bucket_value(bucketing_key)
@logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}' "\
"with bucketing ID: '#{bucketing_id}'.")

message = "Assigned bucket #{bucket_value} to user '#{user_id}' with bucketing ID: '#{bucketing_id}'."
@logger.log(Logger::DEBUG, message)
decide_reasons&.push(message)

traffic_allocations.each do |traffic_allocation|
current_end_of_range = traffic_allocation['endOfRange']
Expand Down
28 changes: 28 additions & 0 deletions lib/optimizely/decide/optimizely_decide_option.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

# Copyright 2020, 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.
#

module Optimizely
module Decide
module OptimizelyDecideOption
DISABLE_DECISION_EVENT = 'DISABLE_DECISION_EVENT'
ENABLED_FLAGS_ONLY = 'ENABLED_FLAGS_ONLY'
IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE'
INCLUDE_REASONS = 'INCLUDE_REASONS'
EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES'
end
end
end
60 changes: 60 additions & 0 deletions lib/optimizely/decide/optimizely_decision.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

# Copyright 2020, 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 'json'

module Optimizely
module Decide
class OptimizelyDecision
attr_reader :variation_key, :enabled, :variables, :rule_key, :flag_key, :user_context, :reasons

def initialize(
variation_key: nil,
enabled: nil,
variables: nil,
rule_key: nil,
flag_key: nil,
user_context: nil,
reasons: nil
)
@variation_key = variation_key
@enabled = enabled || false
@variables = variables || {}
@rule_key = rule_key
@flag_key = flag_key
@user_context = user_context
@reasons = reasons || []
end

def as_json
{
variation_key: @variation_key,
enabled: @enabled,
variables: @variables,
rule_key: @rule_key,
flag_key: @flag_key,
user_context: @user_context.as_json,
reasons: @reasons
}
end

def to_json(*args)
as_json.to_json(*args)
end
end
end
end
Loading

0 comments on commit f718a18

Please sign in to comment.