Skip to content

Commit 1f3c89b

Browse files
feat: odp datafile parsing and audience evaluation (#303)
* switch user attributes to user context * add integrations * add qualified segments
1 parent 0c24bd2 commit 1f3c89b

14 files changed

+758
-173
lines changed

lib/optimizely/audience.rb

+40-10
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,20 @@
1616
# limitations under the License.
1717
#
1818
require 'json'
19-
require_relative './custom_attribute_condition_evaluator'
19+
require_relative './user_condition_evaluator'
2020
require_relative 'condition_tree_evaluator'
2121
require_relative 'helpers/constants'
2222

2323
module Optimizely
2424
module Audience
2525
module_function
2626

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

60-
attributes ||= {}
59+
user_condition_evaluator = UserConditionEvaluator.new(user_context, logger)
6160

62-
custom_attr_condition_evaluator = CustomAttributeConditionEvaluator.new(attributes, logger)
63-
64-
evaluate_custom_attr = lambda do |condition|
65-
return custom_attr_condition_evaluator.evaluate(condition)
61+
evaluate_user_conditions = lambda do |condition|
62+
return user_condition_evaluator.evaluate(condition)
6663
end
6764

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

7774
audience_conditions = JSON.parse(audience_conditions) if audience_conditions.is_a?(String)
78-
result = ConditionTreeEvaluator.evaluate(audience_conditions, evaluate_custom_attr)
75+
result = ConditionTreeEvaluator.evaluate(audience_conditions, evaluate_user_conditions)
7976
result_str = result.nil? ? 'UNKNOWN' : result.to_s.upcase
8077
message = format(logs_hash['AUDIENCE_EVALUATION_RESULT'], audience_id, result_str)
8178
logger.log(Logger::DEBUG, message)
@@ -93,5 +90,38 @@ def user_meets_audience_conditions?(config, experiment, attributes, logger, logg
9390

9491
[eval_result, decide_reasons]
9592
end
93+
94+
def get_segments(conditions)
95+
# Return any audience segments from provided conditions.
96+
#
97+
# conditions - Nested array of and/or conditions.
98+
# Example: ['and', operand_1, ['or', operand_2, operand_3]]
99+
#
100+
# Returns unique array of segment names.
101+
conditions = JSON.parse(conditions) if conditions.is_a?(String)
102+
@parse_segments.call(conditions).uniq
103+
end
104+
105+
@parse_segments = lambda { |conditions|
106+
# Return any audience segments from provided conditions.
107+
# Helper function for get_segments.
108+
#
109+
# conditions - Nested array of and/or conditions.
110+
# Example: ['and', operand_1, ['or', operand_2, operand_3]]
111+
#
112+
# Returns array of segment names.
113+
segments = []
114+
115+
conditions.each do |condition|
116+
case condition
117+
when Array
118+
segments.concat @parse_segments.call(condition)
119+
when Hash
120+
segments.push(condition['value']) if condition.fetch('match', nil) == 'qualified'
121+
end
122+
end
123+
124+
segments
125+
}
96126
end
97127
end

lib/optimizely/config/datafile_project_config.rb

+17
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ class DatafileProjectConfig < ProjectConfig
4343
attr_reader :rollouts
4444
attr_reader :version
4545
attr_reader :send_flag_decisions
46+
attr_reader :integrations
47+
attr_reader :public_key_for_odp
48+
attr_reader :host_for_odp
49+
attr_reader :all_segments
4650

4751
attr_reader :attribute_key_map
4852
attr_reader :audience_id_map
@@ -61,6 +65,7 @@ class DatafileProjectConfig < ProjectConfig
6165
attr_reader :variation_id_map_by_experiment_id
6266
attr_reader :variation_key_map_by_experiment_id
6367
attr_reader :flag_variation_map
68+
attr_reader :integration_key_map
6469

6570
def initialize(datafile, logger, error_handler)
6671
# ProjectConfig init method to fetch and set project config data
@@ -92,6 +97,7 @@ def initialize(datafile, logger, error_handler)
9297
@environment_key = config.fetch('environmentKey', '')
9398
@rollouts = config.fetch('rollouts', [])
9499
@send_flag_decisions = config.fetch('sendFlagDecisions', false)
100+
@integrations = config.fetch('integrations', [])
95101

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

152+
if (odp_integration = @integration_key_map&.fetch('odp', nil))
153+
@public_key_for_odp = odp_integration['publicKey']
154+
@host_for_odp = odp_integration['host']
155+
end
156+
157+
@all_segments = []
158+
@audience_id_map.each_value do |audience|
159+
@all_segments.concat Audience.get_segments(audience['conditions'])
160+
end
161+
145162
@flag_variation_map = generate_feature_variation_map(@feature_flags)
146163
@all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map)
147164
@all_experiments.each do |id, exp|

lib/optimizely/decision_service.rb

+7-7
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def get_variation(project_config, experiment_id, user_context, decide_options =
106106
end
107107

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

279-
def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user)
279+
def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user_context)
280280
# Determine which variation the user is in for a given rollout.
281281
# Returns the variation from delivery rules.
282282
#
283283
# project_config - project_config - Instance of ProjectConfig
284284
# flag_key - The feature flag the user wants to access
285285
# rule - An experiment rule key
286-
# user - Optimizely user context instance
286+
# user_context - Optimizely user context instance
287287
#
288288
# Returns variation, boolean to skip for eveyone else rule and reasons
289289
reasons = []
290290
skip_to_everyone_else = false
291291
rule = rules[rule_index]
292292
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
293-
variation, forced_reasons = validated_forced_decision(project_config, context, user)
293+
variation, forced_reasons = validated_forced_decision(project_config, context, user_context)
294294
reasons.push(*forced_reasons)
295295

296296
return [variation, skip_to_everyone_else, reasons] if variation
297297

298-
user_id = user.user_id
299-
attributes = user.user_attributes
298+
user_id = user_context.user_id
299+
attributes = user_context.user_attributes
300300
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
301301
reasons.push(*bucketing_id_reasons)
302302

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

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

307-
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, rule, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
307+
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, rule, user_context, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
308308
reasons.push(*reasons_received)
309309
unless user_meets_audience_conditions
310310
message = "User '#{user_id}' does not meet the conditions for targeting rule '#{logging_key}'."

lib/optimizely/helpers/constants.rb

+18
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,24 @@ module Constants
285285
},
286286
'revision' => {
287287
'type' => 'string'
288+
},
289+
'integrations' => {
290+
'type' => 'array',
291+
'items' => {
292+
'type' => 'object',
293+
'properties' => {
294+
'key' => {
295+
'type' => 'string'
296+
},
297+
'host' => {
298+
'type' => 'string'
299+
},
300+
'publicKey' => {
301+
'type' => 'string'
302+
}
303+
},
304+
'required' => %w[key]
305+
}
288306
}
289307
},
290308
'required' => %w[

lib/optimizely/optimizely_user_context.rb

+29
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,18 @@ class OptimizelyUserContext
3333
def initialize(optimizely_client, user_id, user_attributes)
3434
@attr_mutex = Mutex.new
3535
@forced_decision_mutex = Mutex.new
36+
@qualified_segment_mutex = Mutex.new
3637
@optimizely_client = optimizely_client
3738
@user_id = user_id
3839
@user_attributes = user_attributes.nil? ? {} : user_attributes.clone
3940
@forced_decisions = {}
41+
@qualified_segments = []
4042
end
4143

4244
def clone
4345
user_context = OptimizelyUserContext.new(@optimizely_client, @user_id, user_attributes)
4446
@forced_decision_mutex.synchronize { user_context.instance_variable_set('@forced_decisions', @forced_decisions.dup) unless @forced_decisions.empty? }
47+
@qualified_segment_mutex.synchronize { user_context.instance_variable_set('@qualified_segments', @qualified_segments.dup) unless @qualified_segments.empty? }
4548
user_context
4649
end
4750

@@ -175,5 +178,31 @@ def as_json
175178
def to_json(*args)
176179
as_json.to_json(*args)
177180
end
181+
182+
# Returns An array of qualified segments for this user
183+
#
184+
# @return - An array of segments names.
185+
186+
def qualified_segments
187+
@qualified_segment_mutex.synchronize { @qualified_segments.clone }
188+
end
189+
190+
# Replace qualified segments with provided segments
191+
#
192+
# @param segments - An array of segment names
193+
194+
def qualified_segments=(segments)
195+
@qualified_segment_mutex.synchronize { @qualified_segments = segments.clone }
196+
end
197+
198+
# Checks if user is qualified for the provided segment.
199+
#
200+
# @param segment - A segment name
201+
202+
def qualified_for?(segment)
203+
return false if @qualified_segments.empty?
204+
205+
@qualified_segment_mutex.synchronize { @qualified_segments.include?(segment) }
206+
end
178207
end
179208
end

lib/optimizely/project_config.rb

+8
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ def send_flag_decisions; end
5454

5555
def rollouts; end
5656

57+
def integrations; end
58+
59+
def public_key_for_odp; end
60+
61+
def host_for_odp; end
62+
63+
def all_segments; end
64+
5765
def experiment_running?(experiment); end
5866

5967
def get_experiment_from_key(experiment_key); end

lib/optimizely/custom_attribute_condition_evaluator.rb renamed to lib/optimizely/user_condition_evaluator.rb

+30-8
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
require_relative 'semantic_version'
2222

2323
module Optimizely
24-
class CustomAttributeConditionEvaluator
25-
CUSTOM_ATTRIBUTE_CONDITION_TYPE = 'custom_attribute'
24+
class UserConditionEvaluator
25+
CONDITION_TYPES = %w[custom_attribute third_party_dimension].freeze
2626

2727
# Conditional match types
2828
EXACT_MATCH_TYPE = 'exact'
@@ -37,6 +37,7 @@ class CustomAttributeConditionEvaluator
3737
SEMVER_GT = 'semver_gt'
3838
SEMVER_LE = 'semver_le'
3939
SEMVER_LT = 'semver_lt'
40+
QUALIFIED_MATCH_TYPE = 'qualified'
4041

4142
EVALUATORS_BY_MATCH_TYPE = {
4243
EXACT_MATCH_TYPE => :exact_evaluator,
@@ -50,13 +51,15 @@ class CustomAttributeConditionEvaluator
5051
SEMVER_GE => :semver_greater_than_or_equal_evaluator,
5152
SEMVER_GT => :semver_greater_than_evaluator,
5253
SEMVER_LE => :semver_less_than_or_equal_evaluator,
53-
SEMVER_LT => :semver_less_than_evaluator
54+
SEMVER_LT => :semver_less_than_evaluator,
55+
QUALIFIED_MATCH_TYPE => :qualified_evaluator
5456
}.freeze
5557

5658
attr_reader :user_attributes
5759

58-
def initialize(user_attributes, logger)
59-
@user_attributes = user_attributes
60+
def initialize(user_context, logger)
61+
@user_context = user_context
62+
@user_attributes = user_context.user_attributes
6063
@logger = logger
6164
end
6265

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

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

8083
condition_match = leaf_condition['match'] || EXACT_MATCH_TYPE
8184

82-
if !@user_attributes.key?(leaf_condition['name']) && condition_match != EXISTS_MATCH_TYPE
85+
if !@user_attributes.key?(leaf_condition['name']) && ![EXISTS_MATCH_TYPE, QUALIFIED_MATCH_TYPE].include?(condition_match)
8386
@logger.log(
8487
Logger::DEBUG,
8588
format(
@@ -91,7 +94,7 @@ def evaluate(leaf_condition)
9194
return nil
9295
end
9396

94-
if @user_attributes[leaf_condition['name']].nil? && condition_match != EXISTS_MATCH_TYPE
97+
if @user_attributes[leaf_condition['name']].nil? && ![EXISTS_MATCH_TYPE, QUALIFIED_MATCH_TYPE].include?(condition_match)
9598
@logger.log(
9699
Logger::DEBUG,
97100
format(
@@ -327,6 +330,25 @@ def semver_less_than_or_equal_evaluator(condition)
327330
SemanticVersion.compare_user_version_with_target_version(target_version, user_version) <= 0
328331
end
329332

333+
def qualified_evaluator(condition)
334+
# Evaluate the given match condition for the given user qaulified segments.
335+
# Returns boolean true if condition value is in the user's qualified segments,
336+
# false if the condition value is not in the user's qualified segments,
337+
# nil if the condition value isn't a string.
338+
339+
condition_value = condition['value']
340+
341+
unless condition_value.is_a?(String)
342+
@logger.log(
343+
Logger::WARN,
344+
format(Helpers::Constants::AUDIENCE_EVALUATION_LOGS['UNKNOWN_CONDITION_VALUE'], condition)
345+
)
346+
return nil
347+
end
348+
349+
@user_context.qualified_for?(condition_value)
350+
end
351+
330352
private
331353

332354
def valid_numeric_values?(user_value, condition_value, condition)

0 commit comments

Comments
 (0)