optimizely-sdk 3.1.1 → 3.2.0

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