optimizely-sdk 3.4.0 → 3.8.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 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.
@@ -24,38 +24,37 @@ module Optimizely
24
24
  module Audience
25
25
  module_function
26
26
 
27
- def user_in_experiment?(config, experiment, attributes, logger)
28
- # Determine for given experiment if user satisfies the audiences for the experiment.
27
+ def user_meets_audience_conditions?(config, experiment, attributes, logger, logging_hash = nil, logging_key = nil)
28
+ # Determine for given experiment/rollout rule if user satisfies the audience conditions.
29
29
  #
30
30
  # config - Representation of the Optimizely project config.
31
- # experiment - Experiment for which visitor is to be bucketed.
31
+ # experiment - Experiment/Rollout rule in which user is to be bucketed.
32
32
  # attributes - Hash representing user attributes which will be used in determining if
33
33
  # the audience conditions are met.
34
+ # logger - Provides a logger instance.
35
+ # logging_hash - Optional string representing logs hash inside Helpers::Constants.
36
+ # This defaults to 'EXPERIMENT_AUDIENCE_EVALUATION_LOGS'.
37
+ # logging_key - Optional string to be logged as an identifier of experiment under evaluation.
38
+ # This defaults to experiment['key'].
34
39
  #
35
40
  # Returns boolean representing if user satisfies audience conditions for the audiences or not.
41
+ decide_reasons = []
42
+ logging_hash ||= 'EXPERIMENT_AUDIENCE_EVALUATION_LOGS'
43
+ logging_key ||= experiment['key']
44
+
45
+ logs_hash = Object.const_get "Optimizely::Helpers::Constants::#{logging_hash}"
36
46
 
37
47
  audience_conditions = experiment['audienceConditions'] || experiment['audienceIds']
38
48
 
39
- logger.log(
40
- Logger::DEBUG,
41
- format(
42
- Helpers::Constants::AUDIENCE_EVALUATION_LOGS['EVALUATING_AUDIENCES_COMBINED'],
43
- experiment['key'],
44
- audience_conditions
45
- )
46
- )
49
+ message = format(logs_hash['EVALUATING_AUDIENCES_COMBINED'], logging_key, audience_conditions)
50
+ logger.log(Logger::DEBUG, message)
47
51
 
48
52
  # Return true if there are no audiences
49
53
  if audience_conditions.empty?
50
- logger.log(
51
- Logger::INFO,
52
- format(
53
- Helpers::Constants::AUDIENCE_EVALUATION_LOGS['AUDIENCE_EVALUATION_RESULT_COMBINED'],
54
- experiment['key'],
55
- 'TRUE'
56
- )
57
- )
58
- return true
54
+ message = format(logs_hash['AUDIENCE_EVALUATION_RESULT_COMBINED'], logging_key, 'TRUE')
55
+ logger.log(Logger::INFO, message)
56
+ decide_reasons.push(message)
57
+ return true, decide_reasons
59
58
  end
60
59
 
61
60
  attributes ||= {}
@@ -71,39 +70,28 @@ module Optimizely
71
70
  return nil unless audience
72
71
 
73
72
  audience_conditions = audience['conditions']
74
- logger.log(
75
- Logger::DEBUG,
76
- format(
77
- Helpers::Constants::AUDIENCE_EVALUATION_LOGS['EVALUATING_AUDIENCE'],
78
- audience_id,
79
- audience_conditions
80
- )
81
- )
73
+ message = format(logs_hash['EVALUATING_AUDIENCE'], audience_id, audience_conditions)
74
+ logger.log(Logger::DEBUG, message)
75
+ decide_reasons.push(message)
82
76
 
83
77
  audience_conditions = JSON.parse(audience_conditions) if audience_conditions.is_a?(String)
84
78
  result = ConditionTreeEvaluator.evaluate(audience_conditions, evaluate_custom_attr)
85
79
  result_str = result.nil? ? 'UNKNOWN' : result.to_s.upcase
86
- logger.log(
87
- Logger::INFO,
88
- format(Helpers::Constants::AUDIENCE_EVALUATION_LOGS['AUDIENCE_EVALUATION_RESULT'], audience_id, result_str)
89
- )
80
+ message = format(logs_hash['AUDIENCE_EVALUATION_RESULT'], audience_id, result_str)
81
+ logger.log(Logger::DEBUG, message)
82
+ decide_reasons.push(message)
83
+
90
84
  result
91
85
  end
92
86
 
93
87
  eval_result = ConditionTreeEvaluator.evaluate(audience_conditions, evaluate_audience)
94
-
95
88
  eval_result ||= false
96
89
 
97
- logger.log(
98
- Logger::INFO,
99
- format(
100
- Helpers::Constants::AUDIENCE_EVALUATION_LOGS['AUDIENCE_EVALUATION_RESULT_COMBINED'],
101
- experiment['key'],
102
- eval_result.to_s.upcase
103
- )
104
- )
90
+ message = format(logs_hash['AUDIENCE_EVALUATION_RESULT_COMBINED'], logging_key, eval_result.to_s.upcase)
91
+ logger.log(Logger::INFO, message)
92
+ decide_reasons.push(message)
105
93
 
106
- eval_result
94
+ [eval_result, decide_reasons]
107
95
  end
108
96
  end
109
97
  end
@@ -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.
@@ -39,14 +39,17 @@ module Optimizely
39
39
  # Determines ID of variation to be shown for a given experiment key and user ID.
40
40
  #
41
41
  # project_config - Instance of ProjectConfig
42
- # experiment - Experiment for which visitor is to be bucketed.
42
+ # experiment - Experiment or Rollout rule for which visitor is to be bucketed.
43
43
  # bucketing_id - String A customer-assigned value used to generate the bucketing key
44
44
  # user_id - String ID for user.
45
45
  #
46
46
  # Returns variation in which visitor with ID user_id has been placed. Nil if no variation.
47
- return nil if experiment.nil?
47
+ return nil, [] if experiment.nil?
48
+
49
+ decide_reasons = []
48
50
 
49
51
  # check if experiment is in a group; if so, check if user is bucketed into specified experiment
52
+ # this will not affect evaluation of rollout rules.
50
53
  experiment_id = experiment['id']
51
54
  experiment_key = experiment['key']
52
55
  group_id = experiment['groupId']
@@ -54,52 +57,49 @@ module Optimizely
54
57
  group = project_config.group_id_map.fetch(group_id)
55
58
  if Helpers::Group.random_policy?(group)
56
59
  traffic_allocations = group.fetch('trafficAllocation')
57
- bucketed_experiment_id = find_bucket(bucketing_id, user_id, group_id, traffic_allocations)
60
+ bucketed_experiment_id, find_bucket_reasons = find_bucket(bucketing_id, user_id, group_id, traffic_allocations)
61
+ decide_reasons.push(*find_bucket_reasons)
62
+
58
63
  # return if the user is not bucketed into any experiment
59
64
  unless bucketed_experiment_id
60
- @logger.log(Logger::INFO, "User '#{user_id}' is in no experiment.")
61
- return nil
65
+ message = "User '#{user_id}' is in no experiment."
66
+ @logger.log(Logger::INFO, message)
67
+ decide_reasons.push(message)
68
+ return nil, decide_reasons
62
69
  end
63
70
 
64
71
  # return if the user is bucketed into a different experiment than the one specified
65
72
  if bucketed_experiment_id != experiment_id
66
- @logger.log(
67
- Logger::INFO,
68
- "User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}."
69
- )
70
- return nil
73
+ message = "User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}."
74
+ @logger.log(Logger::INFO, message)
75
+ decide_reasons.push(message)
76
+ return nil, decide_reasons
71
77
  end
72
78
 
73
79
  # continue bucketing if the user is bucketed into the experiment specified
74
- @logger.log(
75
- Logger::INFO,
76
- "User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}."
77
- )
80
+ message = "User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}."
81
+ @logger.log(Logger::INFO, message)
82
+ decide_reasons.push(message)
78
83
  end
79
84
  end
80
85
 
81
86
  traffic_allocations = experiment['trafficAllocation']
82
- variation_id = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations)
87
+ variation_id, find_bucket_reasons = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations)
88
+ decide_reasons.push(*find_bucket_reasons)
89
+
83
90
  if variation_id && variation_id != ''
84
91
  variation = project_config.get_variation_from_id(experiment_key, variation_id)
85
- variation_key = variation ? variation['key'] : nil
86
- @logger.log(
87
- Logger::INFO,
88
- "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_key}'."
89
- )
90
- return variation
92
+ return variation, decide_reasons
91
93
  end
92
94
 
93
95
  # Handle the case when the traffic range is empty due to sticky bucketing
94
96
  if variation_id == ''
95
- @logger.log(
96
- Logger::DEBUG,
97
- 'Bucketed into an empty traffic range. Returning nil.'
98
- )
97
+ message = 'Bucketed into an empty traffic range. Returning nil.'
98
+ @logger.log(Logger::DEBUG, message)
99
+ decide_reasons.push(message)
99
100
  end
100
101
 
101
- @logger.log(Logger::INFO, "User '#{user_id}' is in no variation.")
102
- nil
102
+ [nil, decide_reasons]
103
103
  end
104
104
 
105
105
  def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations)
@@ -110,21 +110,24 @@ module Optimizely
110
110
  # parent_id - String entity ID to use for bucketing ID
111
111
  # traffic_allocations - Array of traffic allocations
112
112
  #
113
- # Returns entity ID corresponding to the provided bucket value or nil if no match is found.
113
+ # Returns and array of two values where first value is the entity ID corresponding to the provided bucket value
114
+ # or nil if no match is found. The second value contains the array of reasons stating how the deicision was taken
115
+ decide_reasons = []
114
116
  bucketing_key = format(BUCKETING_ID_TEMPLATE, bucketing_id: bucketing_id, entity_id: parent_id)
115
117
  bucket_value = generate_bucket_value(bucketing_key)
116
- @logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}' "\
117
- "with bucketing ID: '#{bucketing_id}'.")
118
+
119
+ message = "Assigned bucket #{bucket_value} to user '#{user_id}' with bucketing ID: '#{bucketing_id}'."
120
+ @logger.log(Logger::DEBUG, message)
118
121
 
119
122
  traffic_allocations.each do |traffic_allocation|
120
123
  current_end_of_range = traffic_allocation['endOfRange']
121
124
  if bucket_value < current_end_of_range
122
125
  entity_id = traffic_allocation['entityId']
123
- return entity_id
126
+ return entity_id, decide_reasons
124
127
  end
125
128
  end
126
129
 
127
- nil
130
+ [nil, decide_reasons]
128
131
  end
129
132
 
130
133
  private
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright 2019, Optimizely and contributors
3
+ # Copyright 2019-2020, Optimizely and contributors
4
4
  #
5
5
  # Licensed under the Apache License, Version 2.0 (the "License");
6
6
  # you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@ module Optimizely
24
24
  RUNNING_EXPERIMENT_STATUS = ['Running'].freeze
25
25
  RESERVED_ATTRIBUTE_PREFIX = '$opt_'
26
26
 
27
+ attr_reader :datafile
27
28
  attr_reader :account_id
28
29
  attr_reader :attributes
29
30
  attr_reader :audiences
@@ -39,6 +40,7 @@ module Optimizely
39
40
  attr_reader :revision
40
41
  attr_reader :rollouts
41
42
  attr_reader :version
43
+ attr_reader :send_flag_decisions
42
44
 
43
45
  attr_reader :attribute_key_map
44
46
  attr_reader :audience_id_map
@@ -62,6 +64,7 @@ module Optimizely
62
64
 
63
65
  config = JSON.parse(datafile)
64
66
 
67
+ @datafile = datafile
65
68
  @error_handler = error_handler
66
69
  @logger = logger
67
70
  @version = config['version']
@@ -81,6 +84,18 @@ module Optimizely
81
84
  @bot_filtering = config['botFiltering']
82
85
  @revision = config['revision']
83
86
  @rollouts = config.fetch('rollouts', [])
87
+ @send_flag_decisions = config.fetch('sendFlagDecisions', false)
88
+
89
+ # Json type is represented in datafile as a subtype of string for the sake of backwards compatibility.
90
+ # Converting it to a first-class json type while creating Project Config
91
+ @feature_flags.each do |feature_flag|
92
+ feature_flag['variables'].each do |variable|
93
+ if variable['type'] == 'string' && variable['subType'] == 'json'
94
+ variable['type'] = 'json'
95
+ variable.delete('subType')
96
+ end
97
+ end
98
+ end
84
99
 
85
100
  # Utility maps for quick lookup
86
101
  @attribute_key_map = generate_key_map(@attributes, 'key')
@@ -159,8 +174,9 @@ module Optimizely
159
174
  config = new(datafile, logger, error_handler)
160
175
  rescue StandardError => e
161
176
  default_logger = SimpleLogger.new
162
- error_msg = e.class == InvalidDatafileVersionError ? e.message : InvalidInputError.new('datafile').message
163
- error_to_handle = e.class == InvalidDatafileVersionError ? InvalidDatafileVersionError : InvalidInputError
177
+ error_to_handle = e.class == InvalidDatafileVersionError ? e : InvalidInputError.new('datafile')
178
+ error_msg = error_to_handle.message
179
+
164
180
  default_logger.log(Logger::ERROR, error_msg)
165
181
  error_handler.handle_error error_to_handle
166
182
  return nil
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2020, Optimizely and contributors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ #
18
+
19
+ module Optimizely
20
+ class ProxyConfig
21
+ attr_reader :host, :port, :username, :password
22
+
23
+ def initialize(host, port = nil, username = nil, password = nil)
24
+ # host - DNS name or IP address of proxy
25
+ # port - port to use to acess the proxy
26
+ # username - username if authorization is required
27
+ # password - password if authorization is required
28
+ @host = host
29
+ @port = port
30
+ @username = username
31
+ @password = password
32
+ end
33
+ end
34
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2019, Optimizely and contributors
4
+ # Copyright 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.
@@ -19,13 +19,14 @@ module Optimizely
19
19
  class AsyncScheduler
20
20
  attr_reader :running
21
21
 
22
- def initialize(callback, interval, auto_update, logger = nil)
22
+ def initialize(callback, interval, auto_update, logger = nil, error_handler = nil)
23
23
  # Sets up AsyncScheduler to execute a callback periodically.
24
24
  #
25
25
  # callback - Main function to be executed periodically.
26
26
  # interval - How many seconds to wait between executions.
27
27
  # auto_update - boolean indicates to run infinitely or only once.
28
28
  # logger - Optional Provides a logger instance.
29
+ # error_handler - Optional Provides a handle_error method to handle exceptions.
29
30
 
30
31
  @callback = callback
31
32
  @interval = interval
@@ -33,6 +34,7 @@ module Optimizely
33
34
  @running = false
34
35
  @thread = nil
35
36
  @logger = logger || NoOpLogger.new
37
+ @error_handler = error_handler || NoOpErrorHandler.new
36
38
  end
37
39
 
38
40
  def start!
@@ -54,6 +56,7 @@ module Optimizely
54
56
  Logger::ERROR,
55
57
  "Couldn't create a new thread for async scheduler. #{e.message}"
56
58
  )
59
+ @error_handler.handle_error(e)
57
60
  end
58
61
  end
59
62
 
@@ -80,6 +83,7 @@ module Optimizely
80
83
  Logger::ERROR,
81
84
  "Something went wrong when executing passed callback. #{e.message}"
82
85
  )
86
+ @error_handler.handle_error(e)
83
87
  stop!
84
88
  end
85
89
  break unless @auto_update
@@ -19,14 +19,16 @@ require_relative '../config/datafile_project_config'
19
19
  require_relative '../error_handler'
20
20
  require_relative '../exceptions'
21
21
  require_relative '../helpers/constants'
22
+ require_relative '../helpers/http_utils'
22
23
  require_relative '../logger'
23
24
  require_relative '../notification_center'
24
25
  require_relative '../project_config'
25
26
  require_relative '../optimizely_config'
26
27
  require_relative 'project_config_manager'
27
28
  require_relative 'async_scheduler'
28
- require 'httparty'
29
+
29
30
  require 'json'
31
+
30
32
  module Optimizely
31
33
  class HTTPProjectConfigManager < ProjectConfigManager
32
34
  # Config manager that polls for the datafile and updated ProjectConfig based on an update interval.
@@ -49,6 +51,8 @@ module Optimizely
49
51
  # error_handler - Provides a handle_error method to handle exceptions.
50
52
  # skip_json_validation - Optional boolean param which allows skipping JSON schema
51
53
  # validation upon object invocation. By default JSON schema validation will be performed.
54
+ # datafile_access_token - access token used to fetch private datafiles
55
+ # proxy_config - Optional proxy config instancea to configure making web requests through a proxy server.
52
56
  def initialize(
53
57
  sdk_key: nil,
54
58
  url: nil,
@@ -61,10 +65,13 @@ module Optimizely
61
65
  logger: nil,
62
66
  error_handler: nil,
63
67
  skip_json_validation: false,
64
- notification_center: nil
68
+ notification_center: nil,
69
+ datafile_access_token: nil,
70
+ proxy_config: nil
65
71
  )
66
72
  @logger = logger || NoOpLogger.new
67
73
  @error_handler = error_handler || NoOpErrorHandler.new
74
+ @access_token = datafile_access_token
68
75
  @datafile_url = get_datafile_url(sdk_key, url, url_template)
69
76
  @polling_interval = nil
70
77
  polling_interval(polling_interval)
@@ -81,6 +88,7 @@ module Optimizely
81
88
  # Start async scheduler in the end to avoid race condition where scheduler executes
82
89
  # callback which makes use of variables not yet initialized by the main thread.
83
90
  @async_scheduler.start! if start_by_default == true
91
+ @proxy_config = proxy_config
84
92
  @stopped = false
85
93
  end
86
94
 
@@ -143,21 +151,20 @@ module Optimizely
143
151
  end
144
152
 
145
153
  def request_config
146
- @logger.log(
147
- Logger::DEBUG,
148
- "Fetching datafile from #{@datafile_url}"
149
- )
150
- begin
151
- headers = {
152
- 'Content-Type' => 'application/json'
153
- }
154
+ @logger.log(Logger::DEBUG, "Fetching datafile from #{@datafile_url}")
155
+ headers = {}
156
+ headers['Content-Type'] = 'application/json'
157
+ headers['If-Modified-Since'] = @last_modified if @last_modified
158
+ headers['Authorization'] = "Bearer #{@access_token}" unless @access_token.nil?
154
159
 
155
- headers[Helpers::Constants::HTTP_HEADERS['LAST_MODIFIED']] = @last_modified if @last_modified
160
+ # Cleaning headers before logging to avoid exposing authorization token
161
+ cleansed_headers = {}
162
+ headers.each { |key, value| cleansed_headers[key] = key == 'Authorization' ? '********' : value }
163
+ @logger.log(Logger::DEBUG, "Datafile request headers: #{cleansed_headers}")
156
164
 
157
- response = HTTParty.get(
158
- @datafile_url,
159
- headers: headers,
160
- timeout: Helpers::Constants::CONFIG_MANAGER['REQUEST_TIMEOUT']
165
+ begin
166
+ response = Helpers::HttpUtils.make_request(
167
+ @datafile_url, :get, nil, headers, Helpers::Constants::CONFIG_MANAGER['REQUEST_TIMEOUT'], @proxy_config
161
168
  )
162
169
  rescue StandardError => e
163
170
  @logger.log(
@@ -167,6 +174,9 @@ module Optimizely
167
174
  return nil
168
175
  end
169
176
 
177
+ response_code = response.code.to_i
178
+ @logger.log(Logger::DEBUG, "Datafile response status code #{response_code}")
179
+
170
180
  # Leave datafile and config unchanged if it has not been modified.
171
181
  if response.code == '304'
172
182
  @logger.log(
@@ -176,9 +186,14 @@ module Optimizely
176
186
  return
177
187
  end
178
188
 
179
- @last_modified = response[Helpers::Constants::HTTP_HEADERS['LAST_MODIFIED']]
180
-
181
- config = DatafileProjectConfig.create(response.body, @logger, @error_handler, @skip_json_validation) if response.body
189
+ if response_code >= 200 && response_code < 400
190
+ @logger.log(Logger::DEBUG, 'Successfully fetched datafile, generating Project config')
191
+ config = DatafileProjectConfig.create(response.body, @logger, @error_handler, @skip_json_validation)
192
+ @last_modified = response[Helpers::Constants::HTTP_HEADERS['LAST_MODIFIED']]
193
+ @logger.log(Logger::DEBUG, "Saved last modified header value from response: #{@last_modified}.")
194
+ else
195
+ @logger.log(Logger::DEBUG, "Datafile fetch failed, status: #{response.code}, message: #{response.message}")
196
+ end
182
197
 
183
198
  config
184
199
  end
@@ -284,17 +299,19 @@ module Optimizely
284
299
  # SDK key to determine URL from which to fetch the datafile.
285
300
  # Returns String representing URL to fetch datafile from.
286
301
  if sdk_key.nil? && url.nil?
287
- @logger.log(Logger::ERROR, 'Must provide at least one of sdk_key or url.')
288
- @error_handler.handle_error(InvalidInputsError)
302
+ error_msg = 'Must provide at least one of sdk_key or url.'
303
+ @logger.log(Logger::ERROR, error_msg)
304
+ @error_handler.handle_error(InvalidInputsError.new(error_msg))
289
305
  end
290
306
 
291
307
  unless url
292
- url_template ||= Helpers::Constants::CONFIG_MANAGER['DATAFILE_URL_TEMPLATE']
308
+ url_template ||= @access_token.nil? ? Helpers::Constants::CONFIG_MANAGER['DATAFILE_URL_TEMPLATE'] : Helpers::Constants::CONFIG_MANAGER['AUTHENTICATED_DATAFILE_URL_TEMPLATE']
293
309
  begin
294
310
  return (url_template % sdk_key)
295
311
  rescue
296
- @logger.log(Logger::ERROR, "Invalid url_template #{url_template} provided.")
297
- @error_handler.handle_error(InvalidInputsError)
312
+ error_msg = "Invalid url_template #{url_template} provided."
313
+ @logger.log(Logger::ERROR, error_msg)
314
+ @error_handler.handle_error(InvalidInputsError.new(error_msg))
298
315
  end
299
316
  end
300
317