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.
- checksums.yaml +4 -4
- data/lib/optimizely.rb +169 -94
- data/lib/optimizely/audience.rb +7 -7
- data/lib/optimizely/bucketer.rb +16 -17
- data/lib/optimizely/config/datafile_project_config.rb +411 -0
- data/lib/optimizely/config_manager/async_scheduler.rb +91 -0
- data/lib/optimizely/config_manager/http_project_config_manager.rb +282 -0
- data/lib/optimizely/config_manager/project_config_manager.rb +24 -0
- data/lib/optimizely/config_manager/static_project_config_manager.rb +43 -0
- data/lib/optimizely/decision_service.rb +142 -52
- data/lib/optimizely/event_builder.rb +21 -28
- data/lib/optimizely/exceptions.rb +17 -1
- data/lib/optimizely/helpers/constants.rb +19 -0
- data/lib/optimizely/notification_center.rb +1 -0
- data/lib/optimizely/optimizely_factory.rb +73 -0
- data/lib/optimizely/project_config.rb +29 -434
- data/lib/optimizely/version.rb +1 -1
- metadata +12 -6
@@ -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
|
-
|
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(
|
45
|
-
@
|
47
|
+
def initialize(logger, user_profile_service = nil)
|
48
|
+
@logger = logger
|
46
49
|
@user_profile_service = user_profile_service
|
47
|
-
@bucketer = Bucketer.new(
|
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 =
|
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
|
68
|
-
@
|
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 =
|
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
|
-
@
|
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?(
|
93
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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 =
|
166
|
+
experiment = project_config.experiment_id_map[experiment_id]
|
160
167
|
unless experiment
|
161
|
-
@
|
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 =
|
174
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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 =
|
217
|
+
rollout = project_config.get_rollout_from_id(rollout_id)
|
210
218
|
if rollout.nil?
|
211
|
-
@
|
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 =
|
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?(
|
232
|
-
@
|
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?(
|
258
|
+
unless Audience.user_in_experiment?(project_config, everyone_else_experiment, attributes, @logger)
|
251
259
|
audience_id = everyone_else_experiment['audienceIds'][0]
|
252
|
-
audience =
|
260
|
+
audience = project_config.get_audience_from_id(audience_id)
|
253
261
|
audience_name = audience['name']
|
254
|
-
@
|
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 =
|
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 =
|
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
|
-
@
|
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
|
-
@
|
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
|
404
|
+
return variation_id if project_config.variation_id_exists?(experiment_id, variation_id)
|
315
405
|
|
316
|
-
@
|
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
|
-
@
|
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
|
-
@
|
451
|
+
@logger.log(Logger::INFO, "Saved variation ID #{variation_id} of experiment ID #{experiment_id} for user '#{user_id}'.")
|
362
452
|
rescue => e
|
363
|
-
@
|
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
|
-
@
|
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
|