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.
- 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
|