Skip to content

feat: odp datafile parsing and audience evaluation #303

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

Merged
merged 9 commits into from
Jul 25, 2022
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