Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
4f8765b
feat(Decide): Add Optimizely User Context
oakbani Oct 21, 2020
a7b78d8
line at EOF
oakbani Oct 21, 2020
c5f138c
Merge branch 'master' into oakbani/decide/user-context
oakbani Oct 22, 2020
c8d3a35
feat(Decide): Add Decide API
oakbani Oct 23, 2020
f770ce7
fix
oakbani Oct 23, 2020
244c4f3
nil by []
oakbani Oct 23, 2020
1bd6df7
fix enabled
oakbani Oct 23, 2020
ddd5349
add to_json in entities
oakbani Oct 27, 2020
98ced8c
Made it work with
zashraf1985 Oct 30, 2020
e7366f4
fixed decide_all api
zashraf1985 Oct 30, 2020
03a397f
fixed decide_for_keys
zashraf1985 Nov 2, 2020
3e9e417
fixed some failing unit tests
zashraf1985 Nov 2, 2020
ab824c7
added some test cases
zashraf1985 Nov 3, 2020
507dac6
added decide temporary tags
zashraf1985 Nov 4, 2020
13833de
added pending to forcefully fail unfinished tests
zashraf1985 Nov 4, 2020
b981f64
added some unit tests for decide
zashraf1985 Nov 4, 2020
d102a14
added unit tests for decide api
zashraf1985 Nov 5, 2020
7fb2ddf
added tests for ignoring user profile service option
zashraf1985 Nov 6, 2020
16a509d
added few more tests and some cleanup
zashraf1985 Nov 6, 2020
f6437b7
added a null check before cloning attributes
zashraf1985 Nov 6, 2020
ac90cd0
simplified the null check
zashraf1985 Nov 6, 2020
4026855
changed array append to push to support old ruby versions
zashraf1985 Nov 6, 2020
d6be7c2
added support for ENABLED_FLAGS_ONLY decide option
zashraf1985 Nov 12, 2020
752114d
Added unit tests for decide_for_keys and decide_all
zashraf1985 Nov 13, 2020
ee32ee1
added flag decisions support in decide api
zashraf1985 Nov 13, 2020
57ac8a0
added getters to OptimizelyDecision
zashraf1985 Nov 13, 2020
a300b3e
Merge branch 'master' into oakbani/decide-internal
zashraf1985 Nov 17, 2020
0070d3b
added unit tests for flag decisionss support in decide API
zashraf1985 Nov 17, 2020
6727d30
added sdk ready check to all apis
zashraf1985 Nov 17, 2020
d9a5549
added a check to include reasons
zashraf1985 Nov 18, 2020
cd1648e
added decide reasons
zashraf1985 Nov 18, 2020
8ec1619
fixed a minor issue with decide reasons
zashraf1985 Nov 19, 2020
a041cb6
Merge branch 'master' into oakbani/decide-internal
zashraf1985 Nov 24, 2020
7d122a4
removed some logs from decide reasons
zashraf1985 Nov 24, 2020
c40237f
additional null check for optimizely object
zashraf1985 Nov 24, 2020
7f6d985
fixed failing FSC test for some events
zashraf1985 Nov 24, 2020
b793012
merged the send impression calls under one check
zashraf1985 Nov 25, 2020
0a38bdd
fixed failing FSC test
zashraf1985 Nov 25, 2020
c401588
modified some tests
zashraf1985 Nov 25, 2020
cffa963
added event payload tests
zashraf1985 Nov 26, 2020
43b14ee
Added tests for include_reasons
zashraf1985 Nov 26, 2020
245637e
fixed a test title
zashraf1985 Nov 26, 2020
b76af55
added unit tests for default_decide_options
zashraf1985 Nov 30, 2020
5057b3f
added sdk not ready tests for decide_all and decide_for_keys
zashraf1985 Nov 30, 2020
92afe26
incorporated some review changes
zashraf1985 Dec 7, 2020
1ef9539
added a synchronize block when setting attributes
zashraf1985 Dec 7, 2020
89c3d6b
Added doc comments
zashraf1985 Dec 8, 2020
315d4aa
lint fixes
zashraf1985 Dec 9, 2020
f58d8bd
clone and sync user attributes and context
zashraf1985 Dec 10, 2020
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
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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add checking SDK_NOT_READY error here? If not, we should return an empty map instead of a map of error-decisions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

# 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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decide_for_keys() api?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added now

# 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