optimizely-sdk 3.3.2.rc1 → 3.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2017-2019, Optimizely and contributors
4
+ # Copyright 2017-2020, Optimizely and contributors
5
5
  #
6
6
  # Licensed under the Apache License, Version 2.0 (the "License");
7
7
  # you may not use this file except in compliance with the License.
@@ -94,7 +94,7 @@ module Optimizely
94
94
  end
95
95
 
96
96
  # Check audience conditions
97
- unless Audience.user_in_experiment?(project_config, experiment, attributes, @logger)
97
+ unless Audience.user_meets_audience_conditions?(project_config, experiment, attributes, @logger)
98
98
  @logger.log(
99
99
  Logger::INFO,
100
100
  "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
@@ -106,6 +106,16 @@ module Optimizely
106
106
  variation = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
107
107
  variation_id = variation ? variation['id'] : nil
108
108
 
109
+ if variation_id
110
+ variation_key = variation['key']
111
+ @logger.log(
112
+ Logger::INFO,
113
+ "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_key}'."
114
+ )
115
+ else
116
+ @logger.log(Logger::INFO, "User '#{user_id}' is in no variation.")
117
+ end
118
+
109
119
  # Persist bucketing decision
110
120
  save_user_profile(user_profile, experiment_id, variation_id)
111
121
  variation_id
@@ -125,21 +135,9 @@ module Optimizely
125
135
  decision = get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes)
126
136
  return decision unless decision.nil?
127
137
 
128
- feature_flag_key = feature_flag['key']
129
138
  decision = get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes)
130
- if decision
131
- @logger.log(
132
- Logger::INFO,
133
- "User '#{user_id}' is bucketed into a rollout for feature flag '#{feature_flag_key}'."
134
- )
135
- return decision
136
- end
137
- @logger.log(
138
- Logger::INFO,
139
- "User '#{user_id}' is not bucketed into a rollout for feature flag '#{feature_flag_key}'."
140
- )
141
139
 
142
- nil
140
+ decision
143
141
  end
144
142
 
145
143
  def get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes = nil)
@@ -178,10 +176,7 @@ module Optimizely
178
176
  next unless variation_id
179
177
 
180
178
  variation = project_config.variation_id_map[experiment_key][variation_id]
181
- @logger.log(
182
- Logger::INFO,
183
- "The user '#{user_id}' is bucketed into experiment '#{experiment_key}' of feature '#{feature_flag_key}'."
184
- )
179
+
185
180
  return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST'])
186
181
  end
187
182
 
@@ -231,20 +226,23 @@ module Optimizely
231
226
  # Go through each experiment in order and try to get the variation for the user
232
227
  number_of_rules.times do |index|
233
228
  rollout_rule = rollout_rules[index]
234
- audience_id = rollout_rule['audienceIds'][0]
235
- audience = project_config.get_audience_from_id(audience_id)
236
- audience_name = audience['name']
229
+ logging_key = index + 1
237
230
 
238
231
  # Check that user meets audience conditions for targeting rule
239
- unless Audience.user_in_experiment?(project_config, rollout_rule, attributes, @logger)
232
+ unless Audience.user_meets_audience_conditions?(project_config, rollout_rule, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
240
233
  @logger.log(
241
234
  Logger::DEBUG,
242
- "User '#{user_id}' does not meet the conditions to be in rollout rule for audience '#{audience_name}'."
235
+ "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'."
243
236
  )
244
237
  # move onto the next targeting rule
245
238
  next
246
239
  end
247
240
 
241
+ @logger.log(
242
+ Logger::DEBUG,
243
+ "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
244
+ )
245
+
248
246
  # Evaluate if user satisfies the traffic allocation for this rollout rule
249
247
  variation = @bucketer.bucket(project_config, rollout_rule, bucketing_id, user_id)
250
248
  return Decision.new(rollout_rule, variation, DECISION_SOURCES['ROLLOUT']) unless variation.nil?
@@ -254,17 +252,20 @@ module Optimizely
254
252
 
255
253
  # get last rule which is the everyone else rule
256
254
  everyone_else_experiment = rollout_rules[number_of_rules]
255
+ logging_key = 'Everyone Else'
257
256
  # Check that user meets audience conditions for last rule
258
- unless Audience.user_in_experiment?(project_config, everyone_else_experiment, attributes, @logger)
259
- audience_id = everyone_else_experiment['audienceIds'][0]
260
- audience = project_config.get_audience_from_id(audience_id)
261
- audience_name = audience['name']
257
+ unless Audience.user_meets_audience_conditions?(project_config, everyone_else_experiment, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
262
258
  @logger.log(
263
259
  Logger::DEBUG,
264
- "User '#{user_id}' does not meet the conditions to be in rollout rule for audience '#{audience_name}'."
260
+ "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'."
265
261
  )
266
262
  return nil
267
263
  end
264
+
265
+ @logger.log(
266
+ Logger::DEBUG,
267
+ "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
268
+ )
268
269
  variation = @bucketer.bucket(project_config, everyone_else_experiment, bucketing_id, user_id)
269
270
  return Decision.new(everyone_else_experiment, variation, DECISION_SOURCES['ROLLOUT']) unless variation.nil?
270
271
 
@@ -31,7 +31,6 @@ module Optimizely
31
31
  DEFAULT_BATCH_INTERVAL = 30_000 # interval in milliseconds
32
32
  DEFAULT_QUEUE_CAPACITY = 1000
33
33
  DEFAULT_TIMEOUT_INTERVAL = 5 # interval in seconds
34
- MAX_NIL_COUNT = 3
35
34
 
36
35
  FLUSH_SIGNAL = 'FLUSH_SIGNAL'
37
36
  SHUTDOWN_SIGNAL = 'SHUTDOWN_SIGNAL'
@@ -62,7 +61,7 @@ module Optimizely
62
61
  @notification_center = notification_center
63
62
  @current_batch = []
64
63
  @started = false
65
- start!
64
+ @stopped = false
66
65
  end
67
66
 
68
67
  def start!
@@ -72,26 +71,37 @@ module Optimizely
72
71
  end
73
72
  @flushing_interval_deadline = Helpers::DateTimeUtils.create_timestamp + @flush_interval
74
73
  @logger.log(Logger::INFO, 'Starting scheduler.')
75
- @thread = Thread.new { run }
74
+ if @wait_mutex.nil?
75
+ @wait_mutex = Mutex.new
76
+ @resource = ConditionVariable.new
77
+ end
78
+ @thread = Thread.new { run_queue }
76
79
  @started = true
80
+ @stopped = false
77
81
  end
78
82
 
79
83
  def flush
80
84
  @event_queue << FLUSH_SIGNAL
85
+ @wait_mutex.synchronize { @resource.signal }
81
86
  end
82
87
 
83
88
  def process(user_event)
84
89
  @logger.log(Logger::DEBUG, "Received userEvent: #{user_event}")
85
90
 
86
- if !@started || !@thread.alive?
91
+ # if the processor has been explicitly stopped. Don't accept tasks
92
+ if @stopped
87
93
  @logger.log(Logger::WARN, 'Executor shutdown, not accepting tasks.')
88
94
  return
89
95
  end
90
96
 
97
+ # start if the processor hasn't been started
98
+ start! unless @started
99
+
91
100
  begin
92
101
  @event_queue.push(user_event, true)
93
- rescue Exception
94
- @logger.log(Logger::WARN, 'Payload not accepted by the queue.')
102
+ @wait_mutex.synchronize { @resource.signal }
103
+ rescue => e
104
+ @logger.log(Logger::WARN, 'Payload not accepted by the queue: ' + e.message)
95
105
  return
96
106
  end
97
107
  end
@@ -101,42 +111,20 @@ module Optimizely
101
111
 
102
112
  @logger.log(Logger::INFO, 'Stopping scheduler.')
103
113
  @event_queue << SHUTDOWN_SIGNAL
114
+ @wait_mutex.synchronize { @resource.signal }
104
115
  @thread.join(DEFAULT_TIMEOUT_INTERVAL)
105
116
  @started = false
117
+ @stopped = true
106
118
  end
107
119
 
108
120
  private
109
121
 
110
- def run
111
- # if we receive a number of item nils that reach MAX_NIL_COUNT,
112
- # then we hang on the pop via setting use_pop to false
113
- @nil_count = 0
114
- # hang on pop if true
115
- @use_pop = false
116
- loop do
117
- if Helpers::DateTimeUtils.create_timestamp >= @flushing_interval_deadline
118
- @logger.log(Logger::DEBUG, 'Deadline exceeded flushing current batch.')
119
- flush_queue!
120
- @flushing_interval_deadline = Helpers::DateTimeUtils.create_timestamp + @flush_interval
121
- @use_pop = true if @nil_count > MAX_NIL_COUNT
122
- end
123
-
124
- item = @event_queue.pop if @event_queue.length.positive? || @use_pop
125
-
126
- if item.nil?
127
- # when nil count is greater than MAX_NIL_COUNT, we hang on the pop until there is an item available.
128
- # this avoids to much spinning of the loop.
129
- @nil_count += 1
130
- next
131
- end
132
-
133
- # reset nil_count and use_pop if we have received an item.
134
- @nil_count = 0
135
- @use_pop = false
136
-
122
+ def process_queue
123
+ while @event_queue.length.positive?
124
+ item = @event_queue.pop
137
125
  if item == SHUTDOWN_SIGNAL
138
126
  @logger.log(Logger::DEBUG, 'Received shutdown signal.')
139
- break
127
+ return false
140
128
  end
141
129
 
142
130
  if item == FLUSH_SIGNAL
@@ -147,15 +135,35 @@ module Optimizely
147
135
 
148
136
  add_to_batch(item) if item.is_a? Optimizely::UserEvent
149
137
  end
138
+ true
139
+ end
140
+
141
+ def run_queue
142
+ loop do
143
+ if Helpers::DateTimeUtils.create_timestamp >= @flushing_interval_deadline
144
+ @logger.log(Logger::DEBUG, 'Deadline exceeded flushing current batch.')
145
+
146
+ break unless process_queue
147
+
148
+ flush_queue!
149
+ @flushing_interval_deadline = Helpers::DateTimeUtils.create_timestamp + @flush_interval
150
+ end
151
+
152
+ break unless process_queue
153
+
154
+ # what is the current interval to flush in seconds
155
+ interval = (@flushing_interval_deadline - Helpers::DateTimeUtils.create_timestamp) * 0.001
156
+
157
+ next unless interval.positive?
158
+
159
+ @wait_mutex.synchronize { @resource.wait(@wait_mutex, interval) }
160
+ end
150
161
  rescue SignalException
151
162
  @logger.log(Logger::ERROR, 'Interrupted while processing buffer.')
152
- rescue Exception => e
163
+ rescue => e
153
164
  @logger.log(Logger::ERROR, "Uncaught exception processing buffer. #{e.message}")
154
165
  ensure
155
- @logger.log(
156
- Logger::INFO,
157
- 'Exiting processing loop. Attempting to flush pending events.'
158
- )
166
+ @logger.log(Logger::INFO, 'Exiting processing loop. Attempting to flush pending events.')
159
167
  flush_queue!
160
168
  end
161
169
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2016-2017, 2019, Optimizely and contributors
4
+ # Copyright 2016-2017, 2019-2020 Optimizely and contributors
5
5
  #
6
6
  # Licensed under the Apache License, Version 2.0 (the "License");
7
7
  # you may not use this file except in compliance with the License.
@@ -16,8 +16,7 @@
16
16
  # limitations under the License.
17
17
  #
18
18
  require_relative 'exceptions'
19
-
20
- require 'httparty'
19
+ require_relative 'helpers/http_utils'
21
20
 
22
21
  module Optimizely
23
22
  class NoOpEventDispatcher
@@ -30,28 +29,23 @@ module Optimizely
30
29
  # @api constants
31
30
  REQUEST_TIMEOUT = 10
32
31
 
33
- def initialize(logger: nil, error_handler: nil)
32
+ def initialize(logger: nil, error_handler: nil, proxy_config: nil)
34
33
  @logger = logger || NoOpLogger.new
35
34
  @error_handler = error_handler || NoOpErrorHandler.new
35
+ @proxy_config = proxy_config
36
36
  end
37
37
 
38
38
  # Dispatch the event being represented by the Event object.
39
39
  #
40
40
  # @param event - Event object
41
41
  def dispatch_event(event)
42
- if event.http_verb == :get
43
- response = HTTParty.get(event.url, headers: event.headers, query: event.params, timeout: REQUEST_TIMEOUT)
44
-
45
- elsif event.http_verb == :post
46
- response = HTTParty.post(event.url,
47
- body: event.params.to_json,
48
- headers: event.headers,
49
- timeout: REQUEST_TIMEOUT)
50
- end
42
+ response = Helpers::HttpUtils.make_request(
43
+ event.url, event.http_verb, event.params.to_json, event.headers, REQUEST_TIMEOUT, @proxy_config
44
+ )
51
45
 
52
46
  error_msg = "Event failed to dispatch with response code: #{response.code}"
53
47
 
54
- case response.code
48
+ case response.code.to_i
55
49
  when 400...500
56
50
  @logger.log(Logger::ERROR, error_msg)
57
51
  @error_handler.handle_error(HTTPCallError.new("HTTP Client Error: #{response.code}"))
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2016-2019, Optimizely and contributors
4
+ # Copyright 2016-2020, Optimizely and contributors
5
5
  #
6
6
  # Licensed under the Apache License, Version 2.0 (the "License");
7
7
  # you may not use this file except in compliance with the License.
@@ -81,14 +81,6 @@ module Optimizely
81
81
  end
82
82
  end
83
83
 
84
- class InvalidDatafileError < Error
85
- # Raised when a public method fails due to an invalid datafile
86
-
87
- def initialize(aborted_method)
88
- super("Provided datafile is in an invalid format. Aborting #{aborted_method}.")
89
- end
90
- end
91
-
92
84
  class InvalidDatafileVersionError < Error
93
85
  # Raised when a datafile with an unsupported version is provided
94
86
 
@@ -128,4 +120,20 @@ module Optimizely
128
120
  super("Optimizely instance is not valid. Failing '#{aborted_method}'.")
129
121
  end
130
122
  end
123
+
124
+ class InvalidAttributeType < Error
125
+ # Raised when an attribute is not provided in expected type.
126
+
127
+ def initialize(msg = 'Provided attribute value is not in the expected data type.')
128
+ super
129
+ end
130
+ end
131
+
132
+ class InvalidSemanticVersion < Error
133
+ # Raised when an invalid value is provided as semantic version.
134
+
135
+ def initialize(msg = 'Provided semantic version is invalid.')
136
+ super
137
+ end
138
+ end
131
139
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2016-2019, Optimizely and contributors
4
+ # Copyright 2016-2020, Optimizely and contributors
5
5
  #
6
6
  # Licensed under the Apache License, Version 2.0 (the "License");
7
7
  # you may not use this file except in compliance with the License.
@@ -304,7 +304,8 @@ module Optimizely
304
304
  'BOOLEAN' => 'boolean',
305
305
  'DOUBLE' => 'double',
306
306
  'INTEGER' => 'integer',
307
- 'STRING' => 'string'
307
+ 'STRING' => 'string',
308
+ 'JSON' => 'json'
308
309
  }.freeze
309
310
 
310
311
  INPUT_VARIABLES = {
@@ -334,11 +335,11 @@ module Optimizely
334
335
 
335
336
  AUDIENCE_EVALUATION_LOGS = {
336
337
  'AUDIENCE_EVALUATION_RESULT' => "Audience '%s' evaluated to %s.",
337
- 'AUDIENCE_EVALUATION_RESULT_COMBINED' => "Audiences for experiment '%s' collectively evaluated to %s.",
338
338
  'EVALUATING_AUDIENCE' => "Starting to evaluate audience '%s' with conditions: %s.",
339
- 'EVALUATING_AUDIENCES_COMBINED' => "Evaluating audiences for experiment '%s': %s.",
340
339
  'INFINITE_ATTRIBUTE_VALUE' => 'Audience condition %s evaluated to UNKNOWN because the number value ' \
341
340
  "for user attribute '%s' is not in the range [-2^53, +2^53].",
341
+ 'INVALID_SEMANTIC_VERSION' => 'Audience condition %s evaluated as UNKNOWN because an invalid semantic version ' \
342
+ "was passed for user attribute '%s'.",
342
343
  'MISSING_ATTRIBUTE_VALUE' => 'Audience condition %s evaluated as UNKNOWN because no value ' \
343
344
  "was passed for user attribute '%s'.",
344
345
  'NULL_ATTRIBUTE_VALUE' => 'Audience condition %s evaluated to UNKNOWN because a nil value was passed ' \
@@ -353,15 +354,27 @@ module Optimizely
353
354
  'to upgrade to a newer release of the Optimizely SDK.'
354
355
  }.freeze
355
356
 
357
+ EXPERIMENT_AUDIENCE_EVALUATION_LOGS = {
358
+ 'AUDIENCE_EVALUATION_RESULT_COMBINED' => "Audiences for experiment '%s' collectively evaluated to %s.",
359
+ 'EVALUATING_AUDIENCES_COMBINED' => "Evaluating audiences for experiment '%s': %s."
360
+ }.merge(AUDIENCE_EVALUATION_LOGS).freeze
361
+
362
+ ROLLOUT_AUDIENCE_EVALUATION_LOGS = {
363
+ 'AUDIENCE_EVALUATION_RESULT_COMBINED' => "Audiences for rule '%s' collectively evaluated to %s.",
364
+ 'EVALUATING_AUDIENCES_COMBINED' => "Evaluating audiences for rule '%s': %s."
365
+ }.merge(AUDIENCE_EVALUATION_LOGS).freeze
366
+
356
367
  DECISION_NOTIFICATION_TYPES = {
357
368
  'AB_TEST' => 'ab-test',
358
369
  'FEATURE' => 'feature',
359
370
  'FEATURE_TEST' => 'feature-test',
360
- 'FEATURE_VARIABLE' => 'feature-variable'
371
+ 'FEATURE_VARIABLE' => 'feature-variable',
372
+ 'ALL_FEATURE_VARIABLES' => 'all-feature-variables'
361
373
  }.freeze
362
374
 
363
375
  CONFIG_MANAGER = {
364
376
  'DATAFILE_URL_TEMPLATE' => 'https://cdn.optimizely.com/datafiles/%s.json',
377
+ 'AUTHENTICATED_DATAFILE_URL_TEMPLATE' => 'https://config.optimizely.com/datafiles/auth/%s.json',
365
378
  # Default time in seconds to block the 'config' method call until 'config' instance has been initialized.
366
379
  'DEFAULT_BLOCKING_TIMEOUT' => 15,
367
380
  # Default config update interval of 5 minutes
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright 2020, 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 'net/http'
20
+
21
+ module Optimizely
22
+ module Helpers
23
+ module HttpUtils
24
+ module_function
25
+
26
+ def make_request(url, http_method, request_body = nil, headers = {}, read_timeout = nil, proxy_config = nil)
27
+ # makes http/https GET/POST request and returns response
28
+ #
29
+ uri = URI.parse(url)
30
+
31
+ if http_method == :get
32
+ request = Net::HTTP::Get.new(uri.request_uri)
33
+ elsif http_method == :post
34
+ request = Net::HTTP::Post.new(uri.request_uri)
35
+ request.body = request_body if request_body
36
+ else
37
+ return nil
38
+ end
39
+
40
+ # set headers
41
+ headers&.each do |key, val|
42
+ request[key] = val
43
+ end
44
+
45
+ # do not try to make request with proxy unless we have at least a host
46
+ http_class = if proxy_config&.host
47
+ Net::HTTP::Proxy(
48
+ proxy_config.host,
49
+ proxy_config.port,
50
+ proxy_config.username,
51
+ proxy_config.password
52
+ )
53
+ else
54
+ Net::HTTP
55
+ end
56
+
57
+ http = http_class.new(uri.host, uri.port)
58
+ http.read_timeout = read_timeout if read_timeout
59
+ http.use_ssl = uri.scheme == 'https'
60
+ http.request(request)
61
+ end
62
+ end
63
+ end
64
+ end