optimizely-sdk 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 901e065c0246efcd95969e7b04b84dc9aab2d9e7
4
+ data.tar.gz: c7a3e774508632a1161d827178e49ffa039d560f
5
+ SHA512:
6
+ metadata.gz: 4ff15bc14a26529b3332e41b772befc96559e0d9230d3d41c7c198f757f2a7aede6ec5030064421a9cb87ca676f76d997310ac8211af9fc36554713eb262413c
7
+ data.tar.gz: 9b6ccd16b467ee4d4f2092e775ef73218fa6ac44f70db80f670884c74aebae09f9623353431806d0f2620e08d4471f6d47cf522d1a32274ac046e57286668819
@@ -0,0 +1,179 @@
1
+ require_relative 'optimizely/audience'
2
+ require_relative 'optimizely/bucketer'
3
+ require_relative 'optimizely/error_handler'
4
+ require_relative 'optimizely/event_builder'
5
+ require_relative 'optimizely/event_dispatcher'
6
+ require_relative 'optimizely/exceptions'
7
+ require_relative 'optimizely/helpers/group'
8
+ require_relative 'optimizely/helpers/validator'
9
+ require_relative 'optimizely/logger'
10
+ require_relative 'optimizely/project_config'
11
+
12
+ module Optimizely
13
+ class Project
14
+ attr_accessor :config
15
+ attr_accessor :bucketer
16
+ attr_accessor :event_builder
17
+ attr_accessor :event_dispatcher
18
+ attr_accessor :logger
19
+ attr_accessor :error_handler
20
+
21
+ def initialize(datafile, event_dispatcher = nil, logger = nil, error_handler = nil)
22
+ # Constructor for Projects.
23
+ #
24
+ # datafile - JSON string representing the project.
25
+ # event_dispatcher - Provides a dispatch_event method which if given a URL and params sends a request to it.
26
+ # logger - Optional param which provides a log method to log messages. By default nothing would be logged.
27
+ # error_handler - Optional param which provides a handle_error method to handle exceptions.
28
+ # By default all exceptions will be suppressed.
29
+
30
+ @logger = logger || NoOpLogger.new
31
+ @error_handler = error_handler || NoOpErrorHandler.new
32
+ @event_dispatcher = event_dispatcher || EventDispatcher.new
33
+ validate_inputs(datafile)
34
+
35
+ @config = ProjectConfig.new(datafile, @logger, @error_handler)
36
+ @bucketer = Bucketer.new(@config)
37
+ @event_builder = EventBuilder.new(@config, @bucketer)
38
+ end
39
+
40
+ def activate(experiment_key, user_id, attributes = nil)
41
+ # Buckets visitor and sends impression event to Optimizely.
42
+ #
43
+ # experiment_key - Experiment which needs to be activated.
44
+ # user_id - String ID for user.
45
+ # attributes - Hash representing user attributes and values to be recorded.
46
+ #
47
+ # Returns variation key representing the variation the user will be bucketed in.
48
+ # Returns nil if experiment is not Running or if user is not in experiment.
49
+
50
+ if attributes && !attributes_valid?(attributes)
51
+ @logger.log(Logger::INFO, "Not activating user '#{user_id}'.")
52
+ return nil
53
+ end
54
+
55
+ unless preconditions_valid?(experiment_key, user_id, attributes)
56
+ @logger.log(Logger::INFO, "Not activating user '#{user_id}'.")
57
+ return nil
58
+ end
59
+
60
+ variation_id = @bucketer.bucket(experiment_key, user_id)
61
+
62
+ if not variation_id
63
+ @logger.log(Logger::INFO, "Not activating user '#{user_id}'.")
64
+ return nil
65
+ end
66
+
67
+ # Create and dispatch impression event
68
+ impression_event = @event_builder.create_impression_event(experiment_key, variation_id, user_id, attributes)
69
+ @logger.log(Logger::INFO,
70
+ 'Dispatching impression event to URL %s with params %s.' % [impression_event.url,
71
+ impression_event.params])
72
+ @event_dispatcher.dispatch_event(impression_event.url, impression_event.params)
73
+
74
+ @config.get_variation_key_from_id(experiment_key, variation_id)
75
+ end
76
+
77
+ def get_variation(experiment_key, user_id, attributes = nil)
78
+ # Gets variation where visitor will be bucketed.
79
+ #
80
+ # experiment_key - Experiment for which visitor variation needs to be determined.
81
+ # user_id - String ID for user.
82
+ # attributes - Hash representing user attributes.
83
+ #
84
+ # Returns variation key where visitor will be bucketed.
85
+ # Returns nil if experiment is not Running or if user is not in experiment.
86
+
87
+ if attributes && !attributes_valid?(attributes)
88
+ @logger.log(Logger::INFO, "Not activating user '#{user_id}.")
89
+ return nil
90
+ end
91
+
92
+ unless preconditions_valid?(experiment_key, user_id, attributes)
93
+ @logger.log(Logger::INFO, "Not activating user '#{user_id}.")
94
+ return nil
95
+ end
96
+
97
+ variation_id = @bucketer.bucket(experiment_key, user_id)
98
+ @config.get_variation_key_from_id(experiment_key, variation_id)
99
+ end
100
+
101
+ def track(event_key, user_id, attributes = nil, event_value = nil)
102
+ # Send conversion event to Optimizely.
103
+ #
104
+ # event_key - Goal key representing the event which needs to be recorded.
105
+ # user_id - String ID for user.
106
+ # attributes - Hash representing visitor attributes and values which need to be recorded.
107
+ # event_value - Value associated with the event. Can be used to represent revenue in cents.
108
+
109
+ # Create and dispatch conversion event
110
+
111
+ return nil if attributes && !attributes_valid?(attributes)
112
+
113
+ experiment_ids = @config.get_experiment_ids_for_goal(event_key)
114
+ if experiment_ids.empty?
115
+ @config.logger.log(Logger::INFO, "Not tracking user '#{user_id}' for experiment '#{experiment_key}'.")
116
+ return nil
117
+ end
118
+
119
+ # Filter out experiments that are not running or that do not include the user in audience conditions
120
+ valid_experiment_keys = []
121
+ experiment_ids.each do |experiment_id|
122
+ experiment_key = @config.experiment_id_map[experiment_id]['key']
123
+ unless preconditions_valid?(experiment_key, user_id, attributes)
124
+ @config.logger.log(Logger::INFO, "Not tracking user '#{user_id}' for experiment '#{experiment_key}'.")
125
+ next
126
+ end
127
+ valid_experiment_keys.push(experiment_key)
128
+ end
129
+
130
+ conversion_event = @event_builder.create_conversion_event(event_key, user_id, attributes,
131
+ event_value, valid_experiment_keys)
132
+ @logger.log(Logger::INFO,
133
+ 'Dispatching conversion event to URL %s with params %s.' % [conversion_event.url,
134
+ conversion_event.params])
135
+ @event_dispatcher.dispatch_event(conversion_event.url, conversion_event.params)
136
+ end
137
+
138
+ private
139
+
140
+ def preconditions_valid?(experiment_key, user_id, attributes)
141
+ # Validates preconditions for bucketing a user.
142
+ #
143
+ # experiment_key - String key for an experiment.
144
+ # user_id - String ID of user.
145
+ # attributes - Hash of user attributes.
146
+ #
147
+ # Returns boolean representing whether all preconditions are valid.
148
+
149
+ unless @config.experiment_running?(experiment_key)
150
+ @logger.log(Logger::INFO, "Experiment '#{experiment_key} is not running.")
151
+ return false
152
+ end
153
+
154
+ unless Audience.user_in_experiment?(@config, experiment_key, attributes)
155
+ @logger.log(Logger::INFO,
156
+ "User '#{user_id} does not meet the conditions to be in experiment '#{experiment_key}.")
157
+ return false
158
+ end
159
+
160
+ true
161
+ end
162
+
163
+ def attributes_valid?(attributes)
164
+ unless Helpers::Validator.attributes_valid?(attributes)
165
+ @logger.log(Logger::ERROR, 'Provided attributes are in an invalid format.')
166
+ @error_handler.handle_error(InvalidAttributeFormatError)
167
+ return false
168
+ end
169
+ true
170
+ end
171
+
172
+ def validate_inputs(datafile)
173
+ raise InvalidDatafileError unless Helpers::Validator.datafile_valid?(datafile)
174
+ raise InvalidLoggerError unless Helpers::Validator.logger_valid?(@logger)
175
+ raise InvalidErrorHandlerError unless Helpers::Validator.error_handler_valid?(@error_handler)
176
+ raise InvalidEventDispatcherError unless Helpers::Validator.event_dispatcher_valid?(@event_dispatcher)
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,37 @@
1
+ require 'json'
2
+ require_relative './condition'
3
+
4
+ module Optimizely
5
+ module Audience
6
+ module_function
7
+
8
+ def user_in_experiment?(config, experiment_key, attributes)
9
+ # Determine for given experiment if user satisfies the audiences for the experiment.
10
+ #
11
+ # config - Representation of the Optimizely project config.
12
+ # experiment_key - Key representing experiment for which visitor is to be bucketed.
13
+ # attributes - Hash representing user attributes which will be used in determining if
14
+ # the audience conditions are met.
15
+ #
16
+ # Returns boolean representing if user satisfies audience conditions for any of the audiences or not.
17
+
18
+ audience_ids = config.get_audience_ids_for_experiment(experiment_key)
19
+
20
+ # Return true if there are no audiences
21
+ return true if audience_ids.empty?
22
+
23
+ # Return false if there are audiences but no attributes
24
+ return false unless attributes
25
+
26
+ # Return true if any one of the audience conditions are met
27
+ @condition_evaluator = ConditionEvaluator.new(attributes)
28
+ audience_ids.each do |audience_id|
29
+ audience_conditions = config.get_audience_conditions_from_id(audience_id)
30
+ audience_conditions = JSON.load(audience_conditions)
31
+ return true if @condition_evaluator.evaluate(audience_conditions)
32
+ end
33
+
34
+ false
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,143 @@
1
+ require 'murmurhash3'
2
+ require_relative 'helpers/group'
3
+
4
+ module Optimizely
5
+ class Bucketer
6
+ # Optimizely bucketing algorithm that evenly distributes visitors.
7
+
8
+ BUCKETING_ID_TEMPLATE = '%{user_id}%{entity_id}'
9
+ HASH_SEED = 1
10
+ MAX_HASH_VALUE = 2**32
11
+ MAX_TRAFFIC_VALUE = 10_000
12
+ UNSIGNED_MAX_32_BIT_VALUE = 0xFFFFFFFF
13
+
14
+ def initialize(config)
15
+ # Bucketer init method to set bucketing seed and project config data.
16
+ #
17
+ # config - ProjectConfig data to be used in making bucketing decisions.
18
+
19
+ @bucket_seed = HASH_SEED
20
+ @config = config
21
+ end
22
+
23
+ def bucket(experiment_key, user_id)
24
+ # Determines ID of variation to be shown for a given experiment key and user ID.
25
+ #
26
+ # experiment_key - String Key representing experiment for which visitor is to be bucketed.
27
+ # user_id - String ID for user.
28
+ #
29
+ # Returns String variation ID in which visitor with ID user_id has been placed. Nil if no variation.
30
+
31
+ # handle forced variations if applicable
32
+ forced_variations = @config.get_forced_variations(experiment_key)
33
+ forced_variation_key = forced_variations[user_id]
34
+ if forced_variation_key
35
+ forced_variation_id = @config.get_variation_id_from_key(experiment_key, forced_variation_key)
36
+ if forced_variation_id
37
+ @config.logger.log(Logger::INFO, "User '#{user_id}' is forced in variation '#{forced_variation_key}'.")
38
+ return forced_variation_id
39
+ else
40
+ @config.logger.log(
41
+ Logger::INFO,
42
+ "Variation key '#{forced_variation_key}' is not in datafile. Not activating user '#{user_id}'."
43
+ )
44
+ return nil
45
+ end
46
+ end
47
+
48
+ # check if experiment is in a group; if so, check if user is bucketed into specified experiment
49
+ experiment_id = @config.get_experiment_id(experiment_key)
50
+ group_id = @config.get_experiment_group_id(experiment_key)
51
+ if group_id
52
+ group = @config.group_key_map.fetch(group_id)
53
+ if Helpers::Group.random_policy?(group)
54
+ bucketing_id = sprintf(BUCKETING_ID_TEMPLATE, user_id: user_id, entity_id: group_id)
55
+ traffic_allocations = group.fetch('trafficAllocation')
56
+ bucket_value = generate_bucket_value(bucketing_id)
57
+ @config.logger.log(Logger::DEBUG, "Assigned experiment bucket #{bucket_value} to user '#{user_id}'.")
58
+ bucketed_experiment_id = find_bucket(bucket_value, traffic_allocations)
59
+
60
+ # return if the user is not bucketed into any experiment
61
+ unless bucketed_experiment_id
62
+ @config.logger.log(Logger::INFO, "User '#{user_id}' is in no experiment.")
63
+ return nil
64
+ end
65
+
66
+ # return if the user is bucketed into a different experiment than the one specified
67
+ if bucketed_experiment_id != experiment_id
68
+ @config.logger.log(
69
+ Logger::INFO,
70
+ "User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}."
71
+ )
72
+ return nil
73
+ end
74
+
75
+ # continue bucketing if the user is bucketed into the experiment specified
76
+ @config.logger.log(
77
+ Logger::INFO,
78
+ "User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}."
79
+ )
80
+ end
81
+ end
82
+
83
+ bucketing_id = sprintf(BUCKETING_ID_TEMPLATE, user_id: user_id, entity_id: experiment_id)
84
+ bucket_value = generate_bucket_value(bucketing_id)
85
+ @config.logger.log(Logger::DEBUG, "Assigned variation bucket #{bucket_value} to user '#{user_id}'.")
86
+ traffic_allocations = @config.get_traffic_allocation(experiment_key)
87
+ variation_id = find_bucket(bucket_value, traffic_allocations)
88
+ if variation_id
89
+ variation_key = @config.get_variation_key_from_id(experiment_key, variation_id)
90
+ @config.logger.log(
91
+ Logger::INFO,
92
+ "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_key}'."
93
+ )
94
+ return variation_id
95
+ end
96
+
97
+ @config.logger.log(Logger::INFO, "User '#{user_id}' is in no variation.")
98
+ nil
99
+ end
100
+
101
+ private
102
+
103
+ def find_bucket(bucket_value, traffic_allocations)
104
+ # Helper function to find the matching entity ID for a given bucketing value in a list of traffic allocations.
105
+ #
106
+ # bucket_value - Integer bucket value
107
+ # traffic_allocations - Array of traffic allocations
108
+ #
109
+ # Returns entity ID corresponding to the provided bucket value or nil if no match is found.
110
+
111
+ traffic_allocations.each do |traffic_allocation|
112
+ current_end_of_range = traffic_allocation['endOfRange']
113
+ if bucket_value < current_end_of_range
114
+ entity_id = traffic_allocation['entityId']
115
+ return entity_id
116
+ end
117
+ end
118
+
119
+ nil
120
+ end
121
+
122
+ def generate_bucket_value(bucketing_id)
123
+ # Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE).
124
+ #
125
+ # bucketing_id - String ID for bucketing.
126
+ #
127
+ # Returns bucket value corresponding to the provided bucketing ID.
128
+
129
+ ratio = (generate_unsigned_hash_code_32_bit(bucketing_id)).to_f / MAX_HASH_VALUE
130
+ (ratio * MAX_TRAFFIC_VALUE).to_i
131
+ end
132
+
133
+ def generate_unsigned_hash_code_32_bit(bucketing_id)
134
+ # Helper function to retreive hash code
135
+ #
136
+ # bucketing_id - String ID for bucketing.
137
+ #
138
+ # Returns hash code which is a 32 bit unsigned integer.
139
+
140
+ MurmurHash3::V32.str_hash(bucketing_id, @bucket_seed) & UNSIGNED_MAX_32_BIT_VALUE
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,118 @@
1
+ require 'json'
2
+
3
+ module Optimizely
4
+ class ConditionalOperatorTypes
5
+ AND = 'and'
6
+ OR = 'or'
7
+ NOT = 'not'
8
+ end
9
+
10
+ class ConditionEvaluator
11
+ DEFAULT_OPERATOR_TYPES = [
12
+ ConditionalOperatorTypes::AND,
13
+ ConditionalOperatorTypes::OR,
14
+ ConditionalOperatorTypes::NOT
15
+ ]
16
+
17
+ attr_reader :user_attributes
18
+
19
+ def initialize(user_attributes)
20
+ @user_attributes = user_attributes
21
+ end
22
+
23
+ def and_evaluator(conditions)
24
+ # Evaluates an array of conditions as if the evaluator had been applied
25
+ # to each entry and the results AND-ed together.
26
+ #
27
+ # conditions - Array of conditions ex: [operand_1, operand_2]
28
+ #
29
+ # Returns boolean true if all operands evaluate to true.
30
+
31
+ conditions.each do |condition|
32
+ result = evaluate(condition)
33
+ return result if result == false
34
+ end
35
+
36
+ true
37
+ end
38
+
39
+ def or_evaluator(conditions)
40
+ # Evaluates an array of conditions as if the evaluator had been applied
41
+ # to each entry and the results AND-ed together.
42
+ #
43
+ # conditions - Array of conditions ex: [operand_1, operand_2]
44
+ #
45
+ # Returns boolean true if any operand evaluates to true.
46
+
47
+ conditions.each do |condition|
48
+ result = evaluate(condition)
49
+ return result if result == true
50
+ end
51
+
52
+ false
53
+ end
54
+
55
+ def not_evaluator(single_condition)
56
+ # Evaluates an array of conditions as if the evaluator had been applied
57
+ # to a single entry and NOT was applied to the result.
58
+ #
59
+ # single_condition - Array of a single condition ex: [operand_1]
60
+ #
61
+ # Returns boolean true if the operand evaluates to false.
62
+
63
+ return false if single_condition.length != 1
64
+
65
+ !evaluate(single_condition[0])
66
+ end
67
+
68
+ def evaluator(condition_array)
69
+ # Method to compare single audience condition against provided user data i.e. attributes.
70
+ #
71
+ # condition_array - Array consisting of condition key and corresponding value.
72
+ #
73
+ # Returns boolean indicating the result of comparing the condition value against the user attributes.
74
+
75
+ condition_array[1] == @user_attributes[condition_array[0]]
76
+ end
77
+
78
+ def evaluate(conditions)
79
+ # Top level method to evaluate audience conditions.
80
+ #
81
+ # conditions - Nested array of and/or conditions.
82
+ # Example: ['and', operand_1, ['or', operand_2, operand_3]]
83
+ #
84
+ # Returns boolean result of evaluating the conditions evaluated.
85
+
86
+ if conditions.is_a? Array
87
+ operator_type = conditions[0]
88
+ return false unless DEFAULT_OPERATOR_TYPES.include?(operator_type)
89
+ case operator_type
90
+ when ConditionalOperatorTypes::AND
91
+ return and_evaluator(conditions[1..-1])
92
+ when ConditionalOperatorTypes::OR
93
+ return or_evaluator(conditions[1..-1])
94
+ when ConditionalOperatorTypes::NOT
95
+ return not_evaluator(conditions[1..-1])
96
+ end
97
+ end
98
+
99
+ # Create array of condition key and corresponding value of audience condition.
100
+ condition_array = audience_condition_deserializer(conditions)
101
+
102
+ # Compare audience condition against provided user data i.e. attributes.
103
+ evaluator(condition_array)
104
+ end
105
+
106
+ private
107
+
108
+ def audience_condition_deserializer(condition)
109
+ # Deserializer defining how hashes need to be decoded for audience conditions.
110
+ #
111
+ # condition - Hash representing one audience condition.
112
+ #
113
+ # Returns array consisting of condition key and corresponding value.
114
+
115
+ [condition['name'], condition['value']]
116
+ end
117
+ end
118
+ end