optimizely-sdk 3.1.1 → 3.2.0

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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2019, Optimizely and contributors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ module Optimizely
19
+ class AsyncScheduler
20
+ attr_reader :running
21
+
22
+ def initialize(callback, interval, auto_update, logger = nil)
23
+ # Sets up AsyncScheduler to execute a callback periodically.
24
+ #
25
+ # callback - Main function to be executed periodically.
26
+ # interval - How many seconds to wait between executions.
27
+ # auto_update - boolean indicates to run infinitely or only once.
28
+ # logger - Optional Provides a logger instance.
29
+
30
+ @callback = callback
31
+ @interval = interval
32
+ @auto_update = auto_update
33
+ @running = false
34
+ @thread = nil
35
+ @logger = logger || NoOpLogger.new
36
+ end
37
+
38
+ def start!
39
+ # Starts the async scheduler.
40
+
41
+ if @running
42
+ @logger.log(
43
+ Logger::WARN,
44
+ 'Scheduler is already running. Ignoring .start() call.'
45
+ )
46
+ return
47
+ end
48
+
49
+ begin
50
+ @running = true
51
+ @thread = Thread.new { execution_wrapper(@callback) }
52
+ rescue StandardError => e
53
+ @logger.log(
54
+ Logger::ERROR,
55
+ "Couldn't create a new thread for async scheduler. #{e.message}"
56
+ )
57
+ end
58
+ end
59
+
60
+ def stop!
61
+ # Stops the async scheduler.
62
+
63
+ # If the scheduler is not running do nothing.
64
+ return unless @running
65
+
66
+ @running = false
67
+ @thread.exit
68
+ end
69
+
70
+ private
71
+
72
+ def execution_wrapper(callback)
73
+ # Executes the given callback periodically
74
+
75
+ loop do
76
+ begin
77
+ callback.call
78
+ rescue
79
+ @logger.log(
80
+ Logger::ERROR,
81
+ 'Something went wrong when running passed function.'
82
+ )
83
+ stop!
84
+ end
85
+ break unless @auto_update
86
+
87
+ sleep @interval
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2019, Optimizely and contributors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ require_relative '../config/datafile_project_config'
19
+ require_relative '../error_handler'
20
+ require_relative '../exceptions'
21
+ require_relative '../helpers/constants'
22
+ require_relative '../logger'
23
+ require_relative '../notification_center'
24
+ require_relative '../project_config'
25
+ require_relative 'project_config_manager'
26
+ require_relative 'async_scheduler'
27
+ require 'httparty'
28
+ require 'json'
29
+ module Optimizely
30
+ class HTTPProjectConfigManager < ProjectConfigManager
31
+ # Config manager that polls for the datafile and updated ProjectConfig based on an update interval.
32
+
33
+ attr_reader :config
34
+
35
+ # Initialize config manager. One of sdk_key or url has to be set to be able to use.
36
+ #
37
+ # sdk_key - Optional string uniquely identifying the datafile. It's required unless a URL is passed in.
38
+ # datafile: Optional JSON string representing the project.
39
+ # polling_interval - Optional floating point number representing time interval in seconds
40
+ # at which to request datafile and set ProjectConfig.
41
+ # blocking_timeout -
42
+ # auto_update -
43
+ # start_by_default -
44
+ # url - Optional string representing URL from where to fetch the datafile. If set it supersedes the sdk_key.
45
+ # url_template - Optional string template which in conjunction with sdk_key
46
+ # determines URL from where to fetch the datafile.
47
+ # logger - Provides a logger instance.
48
+ # error_handler - Provides a handle_error method to handle exceptions.
49
+ # skip_json_validation - Optional boolean param which allows skipping JSON schema
50
+ # validation upon object invocation. By default JSON schema validation will be performed.
51
+ def initialize(
52
+ sdk_key: nil,
53
+ url: nil,
54
+ url_template: nil,
55
+ polling_interval: nil,
56
+ blocking_timeout: nil,
57
+ auto_update: true,
58
+ start_by_default: true,
59
+ datafile: nil,
60
+ logger: nil,
61
+ error_handler: nil,
62
+ skip_json_validation: false,
63
+ notification_center: nil
64
+ )
65
+ @logger = logger || NoOpLogger.new
66
+ @error_handler = error_handler || NoOpErrorHandler.new
67
+ @datafile_url = get_datafile_url(sdk_key, url, url_template)
68
+ @polling_interval = nil
69
+ polling_interval(polling_interval)
70
+ @blocking_timeout = nil
71
+ blocking_timeout(blocking_timeout)
72
+ @last_modified = nil
73
+ @async_scheduler = AsyncScheduler.new(method(:fetch_datafile_config), @polling_interval, auto_update, @logger)
74
+ @async_scheduler.start! if start_by_default == true
75
+ @skip_json_validation = skip_json_validation
76
+ @notification_center = notification_center.is_a?(Optimizely::NotificationCenter) ? notification_center : NotificationCenter.new(@logger, @error_handler)
77
+ @config = datafile.nil? ? nil : DatafileProjectConfig.create(datafile, @logger, @error_handler, @skip_json_validation)
78
+ @mutex = Mutex.new
79
+ @resource = ConditionVariable.new
80
+ end
81
+
82
+ def ready?
83
+ !@config.nil?
84
+ end
85
+
86
+ def start!
87
+ @async_scheduler.start!
88
+ end
89
+
90
+ def stop!
91
+ @async_scheduler.stop!
92
+ end
93
+
94
+ def get_config
95
+ # Get Project Config.
96
+
97
+ # If the background datafile polling thread is running. and config has been initalized,
98
+ # we simply return config.
99
+ # If it is not, we wait and block maximum for @blocking_timeout.
100
+ # If thread is not running, we fetch the datafile and update config.
101
+ if @async_scheduler.running
102
+ return @config if ready?
103
+
104
+ @mutex.synchronize do
105
+ @resource.wait(@mutex, @blocking_timeout)
106
+ return @config
107
+ end
108
+ end
109
+
110
+ fetch_datafile_config
111
+ @config
112
+ end
113
+
114
+ private
115
+
116
+ def fetch_datafile_config
117
+ # Fetch datafile, handle response and send notification on config update.
118
+ config = request_config
119
+ return unless config
120
+
121
+ set_config config
122
+ end
123
+
124
+ def request_config
125
+ @logger.log(
126
+ Logger::DEBUG,
127
+ "Fetching datafile from #{@datafile_url}"
128
+ )
129
+ begin
130
+ headers = {
131
+ 'Content-Type' => 'application/json'
132
+ }
133
+
134
+ headers[Helpers::Constants::HTTP_HEADERS['LAST_MODIFIED']] = @last_modified if @last_modified
135
+
136
+ response = HTTParty.get(
137
+ @datafile_url,
138
+ headers: headers,
139
+ timeout: Helpers::Constants::CONFIG_MANAGER['REQUEST_TIMEOUT']
140
+ )
141
+ rescue StandardError => e
142
+ @logger.log(
143
+ Logger::ERROR,
144
+ "Fetching datafile from #{@datafile_url} failed. Error: #{e}"
145
+ )
146
+ return nil
147
+ end
148
+
149
+ # Leave datafile and config unchanged if it has not been modified.
150
+ if response.code == '304'
151
+ @logger.log(
152
+ Logger::DEBUG,
153
+ "Not updating config as datafile has not updated since #{@last_modified}."
154
+ )
155
+ return
156
+ end
157
+
158
+ @last_modified = response[Helpers::Constants::HTTP_HEADERS['LAST_MODIFIED']]
159
+
160
+ config = DatafileProjectConfig.create(response.body, @logger, @error_handler, @skip_json_validation) if response.body
161
+
162
+ config
163
+ end
164
+
165
+ def set_config(config)
166
+ # Send notification if project config is updated.
167
+ previous_revision = @config.revision if @config
168
+ return if previous_revision == config.revision
169
+
170
+ unless ready?
171
+ @config = config
172
+ @mutex.synchronize { @resource.signal }
173
+ end
174
+
175
+ @config = config
176
+
177
+ @notification_center.send_notifications(NotificationCenter::NOTIFICATION_TYPES[:OPTIMIZELY_CONFIG_UPDATE])
178
+
179
+ @logger.log(Logger::DEBUG, 'Received new datafile and updated config. ' \
180
+ "Old revision number: #{previous_revision}. New revision number: #{@config.revision}.")
181
+ end
182
+
183
+ def polling_interval(polling_interval)
184
+ # Sets frequency at which datafile has to be polled and ProjectConfig updated.
185
+ #
186
+ # polling_interval - Time in seconds after which to update datafile.
187
+
188
+ # If valid set given polling interval, default update interval otherwise.
189
+
190
+ if polling_interval.nil?
191
+ @logger.log(
192
+ Logger::DEBUG,
193
+ "Polling interval is not provided. Defaulting to #{Helpers::Constants::CONFIG_MANAGER['DEFAULT_UPDATE_INTERVAL']} seconds."
194
+ )
195
+ @polling_interval = Helpers::Constants::CONFIG_MANAGER['DEFAULT_UPDATE_INTERVAL']
196
+ return
197
+ end
198
+
199
+ unless polling_interval.is_a? Integer
200
+ @logger.log(
201
+ Logger::ERROR,
202
+ "Polling interval '#{polling_interval}' has invalid type. Defaulting to #{Helpers::Constants::CONFIG_MANAGER['DEFAULT_UPDATE_INTERVAL']} seconds."
203
+ )
204
+ @polling_interval = Helpers::Constants::CONFIG_MANAGER['DEFAULT_UPDATE_INTERVAL']
205
+ return
206
+ end
207
+
208
+ unless polling_interval.between?(Helpers::Constants::CONFIG_MANAGER['MIN_SECONDS_LIMIT'], Helpers::Constants::CONFIG_MANAGER['MAX_SECONDS_LIMIT'])
209
+ @logger.log(
210
+ Logger::DEBUG,
211
+ "Polling interval '#{polling_interval}' has invalid range. Defaulting to #{Helpers::Constants::CONFIG_MANAGER['DEFAULT_UPDATE_INTERVAL']} seconds."
212
+ )
213
+ @polling_interval = Helpers::Constants::CONFIG_MANAGER['DEFAULT_UPDATE_INTERVAL']
214
+ return
215
+ end
216
+
217
+ @polling_interval = polling_interval
218
+ end
219
+
220
+ def blocking_timeout(blocking_timeout)
221
+ # Sets time in seconds to block the get_config call until config has been initialized.
222
+ #
223
+ # blocking_timeout - Time in seconds after which to update datafile.
224
+
225
+ # If valid set given timeout, default blocking_timeout otherwise.
226
+
227
+ if blocking_timeout.nil?
228
+ @logger.log(
229
+ Logger::DEBUG,
230
+ "Blocking timeout is not provided. Defaulting to #{Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']} seconds."
231
+ )
232
+ @polling_interval = Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']
233
+ return
234
+ end
235
+
236
+ unless blocking_timeout.is_a? Integer
237
+ @logger.log(
238
+ Logger::ERROR,
239
+ "Blocking timeout '#{blocking_timeout}' has invalid type. Defaulting to #{Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']} seconds."
240
+ )
241
+ @polling_interval = Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']
242
+ return
243
+ end
244
+
245
+ unless blocking_timeout.between?(Helpers::Constants::CONFIG_MANAGER['MIN_SECONDS_LIMIT'], Helpers::Constants::CONFIG_MANAGER['MAX_SECONDS_LIMIT'])
246
+ @logger.log(
247
+ Logger::DEBUG,
248
+ "Blocking timeout '#{blocking_timeout}' has invalid range. Defaulting to #{Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']} seconds."
249
+ )
250
+ @polling_interval = Helpers::Constants::CONFIG_MANAGER['DEFAULT_BLOCKING_TIMEOUT']
251
+ return
252
+ end
253
+
254
+ @blocking_timeout = blocking_timeout
255
+ end
256
+
257
+ def get_datafile_url(sdk_key, url, url_template)
258
+ # Determines URL from where to fetch the datafile.
259
+ # sdk_key - Key uniquely identifying the datafile.
260
+ # url - String representing URL from which to fetch the datafile.
261
+ # url_template - String representing template which is filled in with
262
+ # SDK key to determine URL from which to fetch the datafile.
263
+ # Returns String representing URL to fetch datafile from.
264
+ if sdk_key.nil? && url.nil?
265
+ @logger.log(Logger::ERROR, 'Must provide at least one of sdk_key or url.')
266
+ @error_handler.handle_error(InvalidInputsError)
267
+ end
268
+
269
+ unless url
270
+ url_template ||= Helpers::Constants::CONFIG_MANAGER['DATAFILE_URL_TEMPLATE']
271
+ begin
272
+ return (url_template % sdk_key)
273
+ rescue
274
+ @logger.log(Logger::ERROR, "Invalid url_template #{url_template} provided.")
275
+ @error_handler.handle_error(InvalidInputsError)
276
+ end
277
+ end
278
+
279
+ url
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2019, Optimizely and contributors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ module Optimizely
19
+ class ProjectConfigManager
20
+ # Interface for fetching ProjectConfig instance.
21
+
22
+ def config; end
23
+ end
24
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2019, Optimizely and contributors
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ require_relative '../config/datafile_project_config'
20
+ require_relative 'project_config_manager'
21
+ module Optimizely
22
+ class StaticProjectConfigManager < ProjectConfigManager
23
+ # Implementation of ProjectConfigManager interface.
24
+ attr_reader :config
25
+
26
+ def initialize(datafile, logger, error_handler, skip_json_validation)
27
+ # Looks up and sets datafile and config based on response body.
28
+ #
29
+ # datafile - JSON string representing the Optimizely project.
30
+ # logger - Provides a logger instance.
31
+ # error_handler - Provides a handle_error method to handle exceptions.
32
+ # skip_json_validation - Optional boolean param which allows skipping JSON schema
33
+ # validation upon object invocation. By default JSON schema validation will be performed.
34
+ # Returns instance of DatafileProjectConfig, nil otherwise.
35
+ @config = DatafileProjectConfig.create(
36
+ datafile,
37
+ logger,
38
+ error_handler,
39
+ skip_json_validation
40
+ )
41
+ end
42
+ end
43
+ end
@@ -32,7 +32,10 @@ module Optimizely
32
32
  # 6. Use Murmurhash3 to bucket the user
33
33
 
34
34
  attr_reader :bucketer
35
- attr_reader :config
35
+
36
+ # Hash of user IDs to a Hash of experiments to variations.
37
+ # This contains all the forced variations set by the user by calling setForcedVariation.
38
+ attr_reader :forced_variation_map
36
39
 
37
40
  Decision = Struct.new(:experiment, :variation, :source)
38
41
 
@@ -41,15 +44,17 @@ module Optimizely
41
44
  'ROLLOUT' => 'rollout'
42
45
  }.freeze
43
46
 
44
- def initialize(config, user_profile_service = nil)
45
- @config = config
47
+ def initialize(logger, user_profile_service = nil)
48
+ @logger = logger
46
49
  @user_profile_service = user_profile_service
47
- @bucketer = Bucketer.new(@config)
50
+ @bucketer = Bucketer.new(logger)
51
+ @forced_variation_map = {}
48
52
  end
49
53
 
50
- def get_variation(experiment_key, user_id, attributes = nil)
54
+ def get_variation(project_config, experiment_key, user_id, attributes = nil)
51
55
  # Determines variation into which user will be bucketed.
52
56
  #
57
+ # project_config - project_config - Instance of ProjectConfig
53
58
  # experiment_key - Experiment for which visitor variation needs to be determined
54
59
  # user_id - String ID for user
55
60
  # attributes - Hash representing user attributes
@@ -60,28 +65,28 @@ module Optimizely
60
65
  # By default, the bucketing ID should be the user ID
61
66
  bucketing_id = get_bucketing_id(user_id, attributes)
62
67
  # Check to make sure experiment is active
63
- experiment = @config.get_experiment_from_key(experiment_key)
68
+ experiment = project_config.get_experiment_from_key(experiment_key)
64
69
  return nil if experiment.nil?
65
70
 
66
71
  experiment_id = experiment['id']
67
- unless @config.experiment_running?(experiment)
68
- @config.logger.log(Logger::INFO, "Experiment '#{experiment_key}' is not running.")
72
+ unless project_config.experiment_running?(experiment)
73
+ @logger.log(Logger::INFO, "Experiment '#{experiment_key}' is not running.")
69
74
  return nil
70
75
  end
71
76
 
72
77
  # Check if a forced variation is set for the user
73
- forced_variation = @config.get_forced_variation(experiment_key, user_id)
78
+ forced_variation = get_forced_variation(project_config, experiment_key, user_id)
74
79
  return forced_variation['id'] if forced_variation
75
80
 
76
81
  # Check if user is in a white-listed variation
77
- whitelisted_variation_id = get_whitelisted_variation_id(experiment_key, user_id)
82
+ whitelisted_variation_id = get_whitelisted_variation_id(project_config, experiment_key, user_id)
78
83
  return whitelisted_variation_id if whitelisted_variation_id
79
84
 
80
85
  # Check for saved bucketing decisions
81
86
  user_profile = get_user_profile(user_id)
82
- saved_variation_id = get_saved_variation_id(experiment_id, user_profile)
87
+ saved_variation_id = get_saved_variation_id(project_config, experiment_id, user_profile)
83
88
  if saved_variation_id
84
- @config.logger.log(
89
+ @logger.log(
85
90
  Logger::INFO,
86
91
  "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
87
92
  )
@@ -89,8 +94,8 @@ module Optimizely
89
94
  end
90
95
 
91
96
  # Check audience conditions
92
- unless Audience.user_in_experiment?(@config, experiment, attributes)
93
- @config.logger.log(
97
+ unless Audience.user_in_experiment?(project_config, experiment, attributes, @logger)
98
+ @logger.log(
94
99
  Logger::INFO,
95
100
  "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
96
101
  )
@@ -98,7 +103,7 @@ module Optimizely
98
103
  end
99
104
 
100
105
  # Bucket normally
101
- variation = @bucketer.bucket(experiment, bucketing_id, user_id)
106
+ variation = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
102
107
  variation_id = variation ? variation['id'] : nil
103
108
 
104
109
  # Persist bucketing decision
@@ -106,9 +111,10 @@ module Optimizely
106
111
  variation_id
107
112
  end
108
113
 
109
- def get_variation_for_feature(feature_flag, user_id, attributes = nil)
114
+ def get_variation_for_feature(project_config, feature_flag, user_id, attributes = nil)
110
115
  # Get the variation the user is bucketed into for the given FeatureFlag.
111
116
  #
117
+ # project_config - project_config - Instance of ProjectConfig
112
118
  # feature_flag - The feature flag the user wants to access
113
119
  # user_id - String ID for the user
114
120
  # attributes - Hash representing user attributes
@@ -116,19 +122,19 @@ module Optimizely
116
122
  # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
117
123
 
118
124
  # check if the feature is being experiment on and whether the user is bucketed into the experiment
119
- decision = get_variation_for_feature_experiment(feature_flag, user_id, attributes)
125
+ decision = get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes)
120
126
  return decision unless decision.nil?
121
127
 
122
128
  feature_flag_key = feature_flag['key']
123
- decision = get_variation_for_feature_rollout(feature_flag, user_id, attributes)
129
+ decision = get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes)
124
130
  if decision
125
- @config.logger.log(
131
+ @logger.log(
126
132
  Logger::INFO,
127
133
  "User '#{user_id}' is bucketed into a rollout for feature flag '#{feature_flag_key}'."
128
134
  )
129
135
  return decision
130
136
  end
131
- @config.logger.log(
137
+ @logger.log(
132
138
  Logger::INFO,
133
139
  "User '#{user_id}' is not bucketed into a rollout for feature flag '#{feature_flag_key}'."
134
140
  )
@@ -136,9 +142,10 @@ module Optimizely
136
142
  nil
137
143
  end
138
144
 
139
- def get_variation_for_feature_experiment(feature_flag, user_id, attributes = nil)
145
+ def get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes = nil)
140
146
  # Gets the variation the user is bucketed into for the feature flag's experiment.
141
147
  #
148
+ # project_config - project_config - Instance of ProjectConfig
142
149
  # feature_flag - The feature flag the user wants to access
143
150
  # user_id - String ID for the user
144
151
  # attributes - Hash representing user attributes
@@ -147,7 +154,7 @@ module Optimizely
147
154
  # or nil if the user is not bucketed into any of the experiments on the feature
148
155
  feature_flag_key = feature_flag['key']
149
156
  if feature_flag['experimentIds'].empty?
150
- @config.logger.log(
157
+ @logger.log(
151
158
  Logger::DEBUG,
152
159
  "The feature flag '#{feature_flag_key}' is not used in any experiments."
153
160
  )
@@ -156,9 +163,9 @@ module Optimizely
156
163
 
157
164
  # Evaluate each experiment and return the first bucketed experiment variation
158
165
  feature_flag['experimentIds'].each do |experiment_id|
159
- experiment = @config.experiment_id_map[experiment_id]
166
+ experiment = project_config.experiment_id_map[experiment_id]
160
167
  unless experiment
161
- @config.logger.log(
168
+ @logger.log(
162
169
  Logger::DEBUG,
163
170
  "Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
164
171
  )
@@ -166,19 +173,19 @@ module Optimizely
166
173
  end
167
174
 
168
175
  experiment_key = experiment['key']
169
- variation_id = get_variation(experiment_key, user_id, attributes)
176
+ variation_id = get_variation(project_config, experiment_key, user_id, attributes)
170
177
 
171
178
  next unless variation_id
172
179
 
173
- variation = @config.variation_id_map[experiment_key][variation_id]
174
- @config.logger.log(
180
+ variation = project_config.variation_id_map[experiment_key][variation_id]
181
+ @logger.log(
175
182
  Logger::INFO,
176
183
  "The user '#{user_id}' is bucketed into experiment '#{experiment_key}' of feature '#{feature_flag_key}'."
177
184
  )
178
185
  return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST'])
179
186
  end
180
187
 
181
- @config.logger.log(
188
+ @logger.log(
182
189
  Logger::INFO,
183
190
  "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
184
191
  )
@@ -186,10 +193,11 @@ module Optimizely
186
193
  nil
187
194
  end
188
195
 
189
- def get_variation_for_feature_rollout(feature_flag, user_id, attributes = nil)
196
+ def get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes = nil)
190
197
  # Determine which variation the user is in for a given rollout.
191
198
  # Returns the variation of the first experiment the user qualifies for.
192
199
  #
200
+ # project_config - project_config - Instance of ProjectConfig
193
201
  # feature_flag - The feature flag the user wants to access
194
202
  # user_id - String ID for the user
195
203
  # attributes - Hash representing user attributes
@@ -199,16 +207,16 @@ module Optimizely
199
207
  rollout_id = feature_flag['rolloutId']
200
208
  if rollout_id.nil? || rollout_id.empty?
201
209
  feature_flag_key = feature_flag['key']
202
- @config.logger.log(
210
+ @logger.log(
203
211
  Logger::DEBUG,
204
212
  "Feature flag '#{feature_flag_key}' is not used in a rollout."
205
213
  )
206
214
  return nil
207
215
  end
208
216
 
209
- rollout = @config.get_rollout_from_id(rollout_id)
217
+ rollout = project_config.get_rollout_from_id(rollout_id)
210
218
  if rollout.nil?
211
- @config.logger.log(
219
+ @logger.log(
212
220
  Logger::DEBUG,
213
221
  "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'"
214
222
  )
@@ -224,12 +232,12 @@ module Optimizely
224
232
  number_of_rules.times do |index|
225
233
  rollout_rule = rollout_rules[index]
226
234
  audience_id = rollout_rule['audienceIds'][0]
227
- audience = @config.get_audience_from_id(audience_id)
235
+ audience = project_config.get_audience_from_id(audience_id)
228
236
  audience_name = audience['name']
229
237
 
230
238
  # Check that user meets audience conditions for targeting rule
231
- unless Audience.user_in_experiment?(@config, rollout_rule, attributes)
232
- @config.logger.log(
239
+ unless Audience.user_in_experiment?(project_config, rollout_rule, attributes, @logger)
240
+ @logger.log(
233
241
  Logger::DEBUG,
234
242
  "User '#{user_id}' does not meet the conditions to be in rollout rule for audience '#{audience_name}'."
235
243
  )
@@ -238,7 +246,7 @@ module Optimizely
238
246
  end
239
247
 
240
248
  # Evaluate if user satisfies the traffic allocation for this rollout rule
241
- variation = @bucketer.bucket(rollout_rule, bucketing_id, user_id)
249
+ variation = @bucketer.bucket(project_config, rollout_rule, bucketing_id, user_id)
242
250
  return Decision.new(rollout_rule, variation, DECISION_SOURCES['ROLLOUT']) unless variation.nil?
243
251
 
244
252
  break
@@ -247,33 +255,114 @@ module Optimizely
247
255
  # get last rule which is the everyone else rule
248
256
  everyone_else_experiment = rollout_rules[number_of_rules]
249
257
  # Check that user meets audience conditions for last rule
250
- unless Audience.user_in_experiment?(@config, everyone_else_experiment, attributes)
258
+ unless Audience.user_in_experiment?(project_config, everyone_else_experiment, attributes, @logger)
251
259
  audience_id = everyone_else_experiment['audienceIds'][0]
252
- audience = @config.get_audience_from_id(audience_id)
260
+ audience = project_config.get_audience_from_id(audience_id)
253
261
  audience_name = audience['name']
254
- @config.logger.log(
262
+ @logger.log(
255
263
  Logger::DEBUG,
256
264
  "User '#{user_id}' does not meet the conditions to be in rollout rule for audience '#{audience_name}'."
257
265
  )
258
266
  return nil
259
267
  end
260
- variation = @bucketer.bucket(everyone_else_experiment, bucketing_id, user_id)
268
+ variation = @bucketer.bucket(project_config, everyone_else_experiment, bucketing_id, user_id)
261
269
  return Decision.new(everyone_else_experiment, variation, DECISION_SOURCES['ROLLOUT']) unless variation.nil?
262
270
 
263
271
  nil
264
272
  end
265
273
 
274
+ def set_forced_variation(project_config, experiment_key, user_id, variation_key)
275
+ # Sets a Hash of user IDs to a Hash of experiments to forced variations.
276
+ #
277
+ # project_config - Instance of ProjectConfig
278
+ # experiment_key - String Key for experiment
279
+ # user_id - String ID for user.
280
+ # variation_key - String Key for variation. If null, then clear the existing experiment-to-variation mapping
281
+ #
282
+ # Returns a boolean value that indicates if the set completed successfully
283
+
284
+ experiment = project_config.get_experiment_from_key(experiment_key)
285
+ experiment_id = experiment['id'] if experiment
286
+ # check if the experiment exists in the datafile
287
+ return false if experiment_id.nil? || experiment_id.empty?
288
+
289
+ # clear the forced variation if the variation key is null
290
+ if variation_key.nil?
291
+ @forced_variation_map[user_id].delete(experiment_id) if @forced_variation_map.key? user_id
292
+ @logger.log(Logger::DEBUG, "Variation mapped to experiment '#{experiment_key}' has been removed for user "\
293
+ "'#{user_id}'.")
294
+ return true
295
+ end
296
+
297
+ variation_id = project_config.get_variation_id_from_key(experiment_key, variation_key)
298
+
299
+ # check if the variation exists in the datafile
300
+ unless variation_id
301
+ # this case is logged in get_variation_id_from_key
302
+ return false
303
+ end
304
+
305
+ @forced_variation_map[user_id] = {} unless @forced_variation_map.key? user_id
306
+ @forced_variation_map[user_id][experiment_id] = variation_id
307
+ @logger.log(Logger::DEBUG, "Set variation '#{variation_id}' for experiment '#{experiment_id}' and "\
308
+ "user '#{user_id}' in the forced variation map.")
309
+ true
310
+ end
311
+
312
+ def get_forced_variation(project_config, experiment_key, user_id)
313
+ # Gets the forced variation for the given user and experiment.
314
+ #
315
+ # project_config - Instance of ProjectConfig
316
+ # experiment_key - String Key for experiment
317
+ # user_id - String ID for user
318
+ #
319
+ # Returns Variation The variation which the given user and experiment should be forced into
320
+
321
+ unless @forced_variation_map.key? user_id
322
+ @logger.log(Logger::DEBUG, "User '#{user_id}' is not in the forced variation map.")
323
+ return nil
324
+ end
325
+
326
+ experiment_to_variation_map = @forced_variation_map[user_id]
327
+ experiment = project_config.get_experiment_from_key(experiment_key)
328
+ experiment_id = experiment['id'] if experiment
329
+ # check for nil and empty string experiment ID
330
+ # this case is logged in get_experiment_from_key
331
+ return nil if experiment_id.nil? || experiment_id.empty?
332
+
333
+ unless experiment_to_variation_map.key? experiment_id
334
+ @logger.log(Logger::DEBUG, "No experiment '#{experiment_key}' mapped to user '#{user_id}' "\
335
+ 'in the forced variation map.')
336
+ return nil
337
+ end
338
+
339
+ variation_id = experiment_to_variation_map[experiment_id]
340
+ variation_key = ''
341
+ variation = project_config.get_variation_from_id(experiment_key, variation_id)
342
+ variation_key = variation['key'] if variation
343
+
344
+ # check if the variation exists in the datafile
345
+ # this case is logged in get_variation_from_id
346
+ return nil if variation_key.empty?
347
+
348
+ @logger.log(Logger::DEBUG, "Variation '#{variation_key}' is mapped to experiment '#{experiment_key}' "\
349
+ "and user '#{user_id}' in the forced variation map")
350
+
351
+ variation
352
+ end
353
+
266
354
  private
267
355
 
268
- def get_whitelisted_variation_id(experiment_key, user_id)
356
+ def get_whitelisted_variation_id(project_config, experiment_key, user_id)
269
357
  # Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
270
358
  #
359
+ # project_config - project_config - Instance of ProjectConfig
271
360
  # experiment_key - Key representing the experiment for which user is to be bucketed
272
361
  # user_id - ID for the user
273
362
  #
274
363
  # Returns variation ID into which user_id is whitelisted (nil if no variation)
275
364
 
276
- whitelisted_variations = @config.get_whitelisted_variations(experiment_key)
365
+ whitelisted_variations = project_config.get_whitelisted_variations(experiment_key)
277
366
 
278
367
  return nil unless whitelisted_variations
279
368
 
@@ -281,26 +370,27 @@ module Optimizely
281
370
 
282
371
  return nil unless whitelisted_variation_key
283
372
 
284
- whitelisted_variation_id = @config.get_variation_id_from_key(experiment_key, whitelisted_variation_key)
373
+ whitelisted_variation_id = project_config.get_variation_id_from_key(experiment_key, whitelisted_variation_key)
285
374
 
286
375
  unless whitelisted_variation_id
287
- @config.logger.log(
376
+ @logger.log(
288
377
  Logger::INFO,
289
378
  "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile."
290
379
  )
291
380
  return nil
292
381
  end
293
382
 
294
- @config.logger.log(
383
+ @logger.log(
295
384
  Logger::INFO,
296
385
  "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'."
297
386
  )
298
387
  whitelisted_variation_id
299
388
  end
300
389
 
301
- def get_saved_variation_id(experiment_id, user_profile)
390
+ def get_saved_variation_id(project_config, experiment_id, user_profile)
302
391
  # Retrieve variation ID of stored bucketing decision for a given experiment from a given user profile
303
392
  #
393
+ # project_config - project_config - Instance of ProjectConfig
304
394
  # experiment_id - String experiment ID
305
395
  # user_profile - Hash user profile
306
396
  #
@@ -311,9 +401,9 @@ module Optimizely
311
401
  return nil unless decision
312
402
 
313
403
  variation_id = decision[:variation_id]
314
- return variation_id if @config.variation_id_exists?(experiment_id, variation_id)
404
+ return variation_id if project_config.variation_id_exists?(experiment_id, variation_id)
315
405
 
316
- @config.logger.log(
406
+ @logger.log(
317
407
  Logger::INFO,
318
408
  "User '#{user_profile['user_id']}' was previously bucketed into variation ID '#{variation_id}' for experiment '#{experiment_id}', but no matching variation was found. Re-bucketing user."
319
409
  )
@@ -337,7 +427,7 @@ module Optimizely
337
427
  begin
338
428
  user_profile = @user_profile_service.lookup(user_id) || user_profile
339
429
  rescue => e
340
- @config.logger.log(Logger::ERROR, "Error while looking up user profile for user ID '#{user_id}': #{e}.")
430
+ @logger.log(Logger::ERROR, "Error while looking up user profile for user ID '#{user_id}': #{e}.")
341
431
  end
342
432
 
343
433
  user_profile
@@ -358,9 +448,9 @@ module Optimizely
358
448
  variation_id: variation_id
359
449
  }
360
450
  @user_profile_service.save(user_profile)
361
- @config.logger.log(Logger::INFO, "Saved variation ID #{variation_id} of experiment ID #{experiment_id} for user '#{user_id}'.")
451
+ @logger.log(Logger::INFO, "Saved variation ID #{variation_id} of experiment ID #{experiment_id} for user '#{user_id}'.")
362
452
  rescue => e
363
- @config.logger.log(Logger::ERROR, "Error while saving user profile for user ID '#{user_id}': #{e}.")
453
+ @logger.log(Logger::ERROR, "Error while saving user profile for user ID '#{user_id}': #{e}.")
364
454
  end
365
455
  end
366
456
 
@@ -378,7 +468,7 @@ module Optimizely
378
468
  if bucketing_id
379
469
  return bucketing_id if bucketing_id.is_a?(String)
380
470
 
381
- @config.logger.log(Logger::WARN, 'Bucketing ID attribute is not a string. Defaulted to user ID.')
471
+ @logger.log(Logger::WARN, 'Bucketing ID attribute is not a string. Defaulted to user ID.')
382
472
  end
383
473
  user_id
384
474
  end