optimizely-sdk 0.0.12

Sign up to get free protection for your applications and to get access to all the features.
@@ -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