Skip to content

Commit

Permalink
feat: odp datafile parsing and audience evaluation (#303)
Browse files Browse the repository at this point in the history
* switch user attributes to user context

* add integrations

* add qualified segments
  • Loading branch information
andrewleap-optimizely authored Jul 25, 2022
1 parent 0c24bd2 commit 1f3c89b
Show file tree
Hide file tree
Showing 14 changed files with 758 additions and 173 deletions.
50 changes: 40 additions & 10 deletions lib/optimizely/audience.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,20 @@
# limitations under the License.
#
require 'json'
require_relative './custom_attribute_condition_evaluator'
require_relative './user_condition_evaluator'
require_relative 'condition_tree_evaluator'
require_relative 'helpers/constants'

module Optimizely
module Audience
module_function

def user_meets_audience_conditions?(config, experiment, attributes, logger, logging_hash = nil, logging_key = nil)
def user_meets_audience_conditions?(config, experiment, user_context, logger, logging_hash = nil, logging_key = nil)
# Determine for given experiment/rollout rule if user satisfies the audience conditions.
#
# config - Representation of the Optimizely project config.
# experiment - Experiment/Rollout rule in which user is to be bucketed.
# attributes - Hash representing user attributes which will be used in determining if
# the audience conditions are met.
# user_context - Optimizely user context instance
# logger - Provides a logger instance.
# logging_hash - Optional string representing logs hash inside Helpers::Constants.
# This defaults to 'EXPERIMENT_AUDIENCE_EVALUATION_LOGS'.
Expand All @@ -57,12 +56,10 @@ def user_meets_audience_conditions?(config, experiment, attributes, logger, logg
return true, decide_reasons
end

attributes ||= {}
user_condition_evaluator = UserConditionEvaluator.new(user_context, logger)

custom_attr_condition_evaluator = CustomAttributeConditionEvaluator.new(attributes, logger)

evaluate_custom_attr = lambda do |condition|
return custom_attr_condition_evaluator.evaluate(condition)
evaluate_user_conditions = lambda do |condition|
return user_condition_evaluator.evaluate(condition)
end

evaluate_audience = lambda do |audience_id|
Expand All @@ -75,7 +72,7 @@ def user_meets_audience_conditions?(config, experiment, attributes, logger, logg
decide_reasons.push(message)

audience_conditions = JSON.parse(audience_conditions) if audience_conditions.is_a?(String)
result = ConditionTreeEvaluator.evaluate(audience_conditions, evaluate_custom_attr)
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)
logger.log(Logger::DEBUG, message)
Expand All @@ -93,5 +90,38 @@ def user_meets_audience_conditions?(config, experiment, attributes, logger, logg

[eval_result, decide_reasons]
end

def get_segments(conditions)
# Return any audience segments from provided conditions.
#
# conditions - Nested array of and/or conditions.
# Example: ['and', operand_1, ['or', operand_2, operand_3]]
#
# Returns unique array of segment names.
conditions = JSON.parse(conditions) if conditions.is_a?(String)
@parse_segments.call(conditions).uniq
end

@parse_segments = lambda { |conditions|
# Return any audience segments from provided conditions.
# Helper function for get_segments.
#
# conditions - Nested array of and/or conditions.
# Example: ['and', operand_1, ['or', operand_2, operand_3]]
#
# Returns array of segment names.
segments = []

conditions.each do |condition|
case condition
when Array
segments.concat @parse_segments.call(condition)
when Hash
segments.push(condition['value']) if condition.fetch('match', nil) == 'qualified'
end
end

segments
}
end
end
17 changes: 17 additions & 0 deletions lib/optimizely/config/datafile_project_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ class DatafileProjectConfig < ProjectConfig
attr_reader :rollouts
attr_reader :version
attr_reader :send_flag_decisions
attr_reader :integrations
attr_reader :public_key_for_odp
attr_reader :host_for_odp
attr_reader :all_segments

attr_reader :attribute_key_map
attr_reader :audience_id_map
Expand All @@ -61,6 +65,7 @@ class DatafileProjectConfig < ProjectConfig
attr_reader :variation_id_map_by_experiment_id
attr_reader :variation_key_map_by_experiment_id
attr_reader :flag_variation_map
attr_reader :integration_key_map

def initialize(datafile, logger, error_handler)
# ProjectConfig init method to fetch and set project config data
Expand Down Expand Up @@ -92,6 +97,7 @@ def initialize(datafile, logger, error_handler)
@environment_key = config.fetch('environmentKey', '')
@rollouts = config.fetch('rollouts', [])
@send_flag_decisions = config.fetch('sendFlagDecisions', false)
@integrations = config.fetch('integrations', [])

# Json type is represented in datafile as a subtype of string for the sake of backwards compatibility.
# Converting it to a first-class json type while creating Project Config
Expand All @@ -117,6 +123,7 @@ def initialize(datafile, logger, error_handler)
@experiment_key_map = generate_key_map(@experiments, 'key')
@experiment_id_map = generate_key_map(@experiments, 'id')
@audience_id_map = generate_key_map(@audiences, 'id')
@integration_key_map = generate_key_map(@integrations, 'key')
@audience_id_map = @audience_id_map.merge(generate_key_map(@typed_audiences, 'id')) unless @typed_audiences.empty?
@variation_id_map = {}
@variation_key_map = {}
Expand All @@ -142,6 +149,16 @@ def initialize(datafile, logger, error_handler)
@rollout_experiment_id_map = @rollout_experiment_id_map.merge(generate_key_map(exps, 'id'))
end

if (odp_integration = @integration_key_map&.fetch('odp', nil))
@public_key_for_odp = odp_integration['publicKey']
@host_for_odp = odp_integration['host']
end

@all_segments = []
@audience_id_map.each_value do |audience|
@all_segments.concat Audience.get_segments(audience['conditions'])
end

@flag_variation_map = generate_feature_variation_map(@feature_flags)
@all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map)
@all_experiments.each do |id, exp|
Expand Down
14 changes: 7 additions & 7 deletions lib/optimizely/decision_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def get_variation(project_config, experiment_id, user_context, decide_options =
end

# Check audience conditions
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, experiment, attributes, @logger)
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, experiment, user_context, @logger)
decide_reasons.push(*reasons_received)
unless user_meets_audience_conditions
message = "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
Expand Down Expand Up @@ -276,35 +276,35 @@ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, opt
[variation_id, reasons]
end

def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user)
def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user_context)
# Determine which variation the user is in for a given rollout.
# Returns the variation from delivery rules.
#
# project_config - project_config - Instance of ProjectConfig
# flag_key - The feature flag the user wants to access
# rule - An experiment rule key
# user - Optimizely user context instance
# user_context - Optimizely user context instance
#
# Returns variation, boolean to skip for eveyone else rule and reasons
reasons = []
skip_to_everyone_else = false
rule = rules[rule_index]
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
variation, forced_reasons = validated_forced_decision(project_config, context, user)
variation, forced_reasons = validated_forced_decision(project_config, context, user_context)
reasons.push(*forced_reasons)

return [variation, skip_to_everyone_else, reasons] if variation

user_id = user.user_id
attributes = user.user_attributes
user_id = user_context.user_id
attributes = user_context.user_attributes
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
reasons.push(*bucketing_id_reasons)

everyone_else = (rule_index == rules.length - 1)

logging_key = everyone_else ? 'Everyone Else' : (rule_index + 1).to_s

user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, rule, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, rule, user_context, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
reasons.push(*reasons_received)
unless user_meets_audience_conditions
message = "User '#{user_id}' does not meet the conditions for targeting rule '#{logging_key}'."
Expand Down
18 changes: 18 additions & 0 deletions lib/optimizely/helpers/constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,24 @@ module Constants
},
'revision' => {
'type' => 'string'
},
'integrations' => {
'type' => 'array',
'items' => {
'type' => 'object',
'properties' => {
'key' => {
'type' => 'string'
},
'host' => {
'type' => 'string'
},
'publicKey' => {
'type' => 'string'
}
},
'required' => %w[key]
}
}
},
'required' => %w[
Expand Down
29 changes: 29 additions & 0 deletions lib/optimizely/optimizely_user_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,18 @@ class OptimizelyUserContext
def initialize(optimizely_client, user_id, user_attributes)
@attr_mutex = Mutex.new
@forced_decision_mutex = Mutex.new
@qualified_segment_mutex = Mutex.new
@optimizely_client = optimizely_client
@user_id = user_id
@user_attributes = user_attributes.nil? ? {} : user_attributes.clone
@forced_decisions = {}
@qualified_segments = []
end

def clone
user_context = OptimizelyUserContext.new(@optimizely_client, @user_id, user_attributes)
@forced_decision_mutex.synchronize { user_context.instance_variable_set('@forced_decisions', @forced_decisions.dup) unless @forced_decisions.empty? }
@qualified_segment_mutex.synchronize { user_context.instance_variable_set('@qualified_segments', @qualified_segments.dup) unless @qualified_segments.empty? }
user_context
end

Expand Down Expand Up @@ -175,5 +178,31 @@ def as_json
def to_json(*args)
as_json.to_json(*args)
end

# Returns An array of qualified segments for this user
#
# @return - An array of segments names.

def qualified_segments
@qualified_segment_mutex.synchronize { @qualified_segments.clone }
end

# Replace qualified segments with provided segments
#
# @param segments - An array of segment names

def qualified_segments=(segments)
@qualified_segment_mutex.synchronize { @qualified_segments = segments.clone }
end

# Checks if user is qualified for the provided segment.
#
# @param segment - A segment name

def qualified_for?(segment)
return false if @qualified_segments.empty?

@qualified_segment_mutex.synchronize { @qualified_segments.include?(segment) }
end
end
end
8 changes: 8 additions & 0 deletions lib/optimizely/project_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ def send_flag_decisions; end

def rollouts; end

def integrations; end

def public_key_for_odp; end

def host_for_odp; end

def all_segments; end

def experiment_running?(experiment); end

def get_experiment_from_key(experiment_key); end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
require_relative 'semantic_version'

module Optimizely
class CustomAttributeConditionEvaluator
CUSTOM_ATTRIBUTE_CONDITION_TYPE = 'custom_attribute'
class UserConditionEvaluator
CONDITION_TYPES = %w[custom_attribute third_party_dimension].freeze

# Conditional match types
EXACT_MATCH_TYPE = 'exact'
Expand All @@ -37,6 +37,7 @@ class CustomAttributeConditionEvaluator
SEMVER_GT = 'semver_gt'
SEMVER_LE = 'semver_le'
SEMVER_LT = 'semver_lt'
QUALIFIED_MATCH_TYPE = 'qualified'

EVALUATORS_BY_MATCH_TYPE = {
EXACT_MATCH_TYPE => :exact_evaluator,
Expand All @@ -50,13 +51,15 @@ class CustomAttributeConditionEvaluator
SEMVER_GE => :semver_greater_than_or_equal_evaluator,
SEMVER_GT => :semver_greater_than_evaluator,
SEMVER_LE => :semver_less_than_or_equal_evaluator,
SEMVER_LT => :semver_less_than_evaluator
SEMVER_LT => :semver_less_than_evaluator,
QUALIFIED_MATCH_TYPE => :qualified_evaluator
}.freeze

attr_reader :user_attributes

def initialize(user_attributes, logger)
@user_attributes = user_attributes
def initialize(user_context, logger)
@user_context = user_context
@user_attributes = user_context.user_attributes
@logger = logger
end

Expand All @@ -69,7 +72,7 @@ def evaluate(leaf_condition)
# Returns boolean if the given user attributes match/don't match the given conditions,
# nil if the given conditions can't be evaluated.

unless leaf_condition['type'] == CUSTOM_ATTRIBUTE_CONDITION_TYPE
unless CONDITION_TYPES.include? leaf_condition['type']
@logger.log(
Logger::WARN,
format(Helpers::Constants::AUDIENCE_EVALUATION_LOGS['UNKNOWN_CONDITION_TYPE'], leaf_condition)
Expand All @@ -79,7 +82,7 @@ def evaluate(leaf_condition)

condition_match = leaf_condition['match'] || EXACT_MATCH_TYPE

if !@user_attributes.key?(leaf_condition['name']) && condition_match != EXISTS_MATCH_TYPE
if !@user_attributes.key?(leaf_condition['name']) && ![EXISTS_MATCH_TYPE, QUALIFIED_MATCH_TYPE].include?(condition_match)
@logger.log(
Logger::DEBUG,
format(
Expand All @@ -91,7 +94,7 @@ def evaluate(leaf_condition)
return nil
end

if @user_attributes[leaf_condition['name']].nil? && condition_match != EXISTS_MATCH_TYPE
if @user_attributes[leaf_condition['name']].nil? && ![EXISTS_MATCH_TYPE, QUALIFIED_MATCH_TYPE].include?(condition_match)
@logger.log(
Logger::DEBUG,
format(
Expand Down Expand Up @@ -327,6 +330,25 @@ def semver_less_than_or_equal_evaluator(condition)
SemanticVersion.compare_user_version_with_target_version(target_version, user_version) <= 0
end

def qualified_evaluator(condition)
# Evaluate the given match condition for the given user qaulified segments.
# Returns boolean true if condition value is in the user's qualified segments,
# false if the condition value is not in the user's qualified segments,
# nil if the condition value isn't a string.

condition_value = condition['value']

unless condition_value.is_a?(String)
@logger.log(
Logger::WARN,
format(Helpers::Constants::AUDIENCE_EVALUATION_LOGS['UNKNOWN_CONDITION_VALUE'], condition)
)
return nil
end

@user_context.qualified_for?(condition_value)
end

private

def valid_numeric_values?(user_value, condition_value, condition)
Expand Down
Loading

0 comments on commit 1f3c89b

Please sign in to comment.