optimizely-sdk 3.7.0 → 3.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84b914d98655786586e8f691c82529bb3a8f074a6c157aceff6c2ef0ab4a4e3e
4
- data.tar.gz: 484164de6c112f23adc94568371a33f564a99dba714c3141cf156783cb638a5d
3
+ metadata.gz: 62e8da2296d75e8f0cad746e8eca25c2670bde6bac084a840c46266322dc378f
4
+ data.tar.gz: 67d25b1695a294616b6dc62c7fa1d822c5262f273626d8c3c5a701e78b8e118f
5
5
  SHA512:
6
- metadata.gz: b56b571c9aed79bdf0fdce664fa8ae24c152e52ac6804f3621c1597701a0293e4e105e0ce2db482d75640e81d7c8d9d1db085d70d0aa8827290277c87c280f68
7
- data.tar.gz: a68af4ec2b0f7d02411e543aae1ca8e141d0be7a992e2d57ac6cd3c40e54ed3437ff39df312907914a679c7b75853902d4ab016b562428ae543f0af50730cabf
6
+ metadata.gz: 8891366e7765049a20b552eff7bd070c51eacf635664096235de97c9858f039410f23a5a3e6a902aee2269008cc50c1a45b22026c831843962c3ca04aa9f35af
7
+ data.tar.gz: 358139de2ecbbe9c6b2f4473bee6e56b784d22ef0e4067fc20a7f0e85a8d7cf293a6fe3cf7b9f4f1dbcf17c463392f8cedad62a7ad651f2fb70ecaee59fd5391
data/lib/optimizely.rb CHANGED
@@ -19,6 +19,9 @@ require_relative 'optimizely/audience'
19
19
  require_relative 'optimizely/config/datafile_project_config'
20
20
  require_relative 'optimizely/config_manager/http_project_config_manager'
21
21
  require_relative 'optimizely/config_manager/static_project_config_manager'
22
+ require_relative 'optimizely/decide/optimizely_decide_option'
23
+ require_relative 'optimizely/decide/optimizely_decision'
24
+ require_relative 'optimizely/decide/optimizely_decision_message'
22
25
  require_relative 'optimizely/decision_service'
23
26
  require_relative 'optimizely/error_handler'
24
27
  require_relative 'optimizely/event_builder'
@@ -34,9 +37,12 @@ require_relative 'optimizely/helpers/variable_type'
34
37
  require_relative 'optimizely/logger'
35
38
  require_relative 'optimizely/notification_center'
36
39
  require_relative 'optimizely/optimizely_config'
40
+ require_relative 'optimizely/optimizely_user_context'
37
41
 
38
42
  module Optimizely
39
43
  class Project
44
+ include Optimizely::Decide
45
+
40
46
  attr_reader :notification_center
41
47
  # @api no-doc
42
48
  attr_reader :config_manager, :decision_service, :error_handler, :event_dispatcher,
@@ -67,12 +73,21 @@ module Optimizely
67
73
  sdk_key = nil,
68
74
  config_manager = nil,
69
75
  notification_center = nil,
70
- event_processor = nil
76
+ event_processor = nil,
77
+ default_decide_options = []
71
78
  )
72
79
  @logger = logger || NoOpLogger.new
73
80
  @error_handler = error_handler || NoOpErrorHandler.new
74
81
  @event_dispatcher = event_dispatcher || EventDispatcher.new(logger: @logger, error_handler: @error_handler)
75
82
  @user_profile_service = user_profile_service
83
+ @default_decide_options = []
84
+
85
+ if default_decide_options.is_a? Array
86
+ @default_decide_options = default_decide_options.clone
87
+ else
88
+ @logger.log(Logger::DEBUG, 'Provided default decide options is not an array.')
89
+ @default_decide_options = []
90
+ end
76
91
 
77
92
  begin
78
93
  validate_instantiation_options
@@ -107,6 +122,175 @@ module Optimizely
107
122
  end
108
123
  end
109
124
 
125
+ # Create a context of the user for which decision APIs will be called.
126
+ #
127
+ # A user context will be created successfully even when the SDK is not fully configured yet.
128
+ #
129
+ # @param user_id - The user ID to be used for bucketing.
130
+ # @param attributes - A Hash representing user attribute names and values.
131
+ #
132
+ # @return [OptimizelyUserContext] An OptimizelyUserContext associated with this OptimizelyClient.
133
+ # @return [nil] If user attributes are not in valid format.
134
+
135
+ def create_user_context(user_id, attributes = nil)
136
+ # We do not check for is_valid here as a user context can be created successfully
137
+ # even when the SDK is not fully configured.
138
+
139
+ # validate user_id
140
+ return nil unless Optimizely::Helpers::Validator.inputs_valid?(
141
+ {
142
+ user_id: user_id
143
+ }, @logger, Logger::ERROR
144
+ )
145
+
146
+ # validate attributes
147
+ return nil unless user_inputs_valid?(attributes)
148
+
149
+ user_context = OptimizelyUserContext.new(self, user_id, attributes)
150
+ user_context
151
+ end
152
+
153
+ def decide(user_context, key, decide_options = [])
154
+ # raising on user context as it is internal and not provided directly by the user.
155
+ raise if user_context.class != OptimizelyUserContext
156
+
157
+ reasons = []
158
+
159
+ # check if SDK is ready
160
+ unless is_valid
161
+ @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide').message)
162
+ reasons.push(OptimizelyDecisionMessage::SDK_NOT_READY)
163
+ return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
164
+ end
165
+
166
+ # validate that key is a string
167
+ unless key.is_a?(String)
168
+ @logger.log(Logger::ERROR, 'Provided key is invalid')
169
+ reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
170
+ return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
171
+ end
172
+
173
+ # validate that key maps to a feature flag
174
+ config = project_config
175
+ feature_flag = config.get_feature_flag_from_key(key)
176
+ unless feature_flag
177
+ @logger.log(Logger::ERROR, "No feature flag was found for key '#{key}'.")
178
+ reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
179
+ return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
180
+ end
181
+
182
+ # merge decide_options and default_decide_options
183
+ if decide_options.is_a? Array
184
+ decide_options += @default_decide_options
185
+ else
186
+ @logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.')
187
+ decide_options = @default_decide_options
188
+ end
189
+
190
+ # Create Optimizely Decision Result.
191
+ user_id = user_context.user_id
192
+ attributes = user_context.user_attributes
193
+ variation_key = nil
194
+ feature_enabled = false
195
+ rule_key = nil
196
+ flag_key = key
197
+ all_variables = {}
198
+ decision_event_dispatched = false
199
+ experiment = nil
200
+ decision_source = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']
201
+
202
+ decision, reasons_received = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes, decide_options)
203
+ reasons.push(*reasons_received)
204
+
205
+ # Send impression event if Decision came from a feature test and decide options doesn't include disableDecisionEvent
206
+ if decision.is_a?(Optimizely::DecisionService::Decision)
207
+ experiment = decision.experiment
208
+ rule_key = experiment['key']
209
+ variation = decision['variation']
210
+ variation_key = variation['key']
211
+ feature_enabled = variation['featureEnabled']
212
+ decision_source = decision.source
213
+ end
214
+
215
+ unless decide_options.include? OptimizelyDecideOption::DISABLE_DECISION_EVENT
216
+ if decision_source == Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] || config.send_flag_decisions
217
+ send_impression(config, experiment, variation_key || '', flag_key, rule_key || '', feature_enabled, decision_source, user_id, attributes)
218
+ decision_event_dispatched = true
219
+ end
220
+ end
221
+
222
+ # Generate all variables map if decide options doesn't include excludeVariables
223
+ unless decide_options.include? OptimizelyDecideOption::EXCLUDE_VARIABLES
224
+ feature_flag['variables'].each do |variable|
225
+ variable_value = get_feature_variable_for_variation(key, feature_enabled, variation, variable, user_id)
226
+ all_variables[variable['key']] = Helpers::VariableType.cast_value_to_type(variable_value, variable['type'], @logger)
227
+ end
228
+ end
229
+
230
+ should_include_reasons = decide_options.include? OptimizelyDecideOption::INCLUDE_REASONS
231
+
232
+ # Send notification
233
+ @notification_center.send_notifications(
234
+ NotificationCenter::NOTIFICATION_TYPES[:DECISION],
235
+ Helpers::Constants::DECISION_NOTIFICATION_TYPES['FLAG'],
236
+ user_id, (attributes || {}),
237
+ flag_key: flag_key,
238
+ enabled: feature_enabled,
239
+ variables: all_variables,
240
+ variation_key: variation_key,
241
+ rule_key: rule_key,
242
+ reasons: should_include_reasons ? reasons : [],
243
+ decision_event_dispatched: decision_event_dispatched
244
+ )
245
+
246
+ OptimizelyDecision.new(
247
+ variation_key: variation_key,
248
+ enabled: feature_enabled,
249
+ variables: all_variables,
250
+ rule_key: rule_key,
251
+ flag_key: flag_key,
252
+ user_context: user_context,
253
+ reasons: should_include_reasons ? reasons : []
254
+ )
255
+ end
256
+
257
+ def decide_all(user_context, decide_options = [])
258
+ # raising on user context as it is internal and not provided directly by the user.
259
+ raise if user_context.class != OptimizelyUserContext
260
+
261
+ # check if SDK is ready
262
+ unless is_valid
263
+ @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide_all').message)
264
+ return {}
265
+ end
266
+
267
+ keys = []
268
+ project_config.feature_flags.each do |feature_flag|
269
+ keys.push(feature_flag['key'])
270
+ end
271
+ decide_for_keys(user_context, keys, decide_options)
272
+ end
273
+
274
+ def decide_for_keys(user_context, keys, decide_options = [])
275
+ # raising on user context as it is internal and not provided directly by the user.
276
+ raise if user_context.class != OptimizelyUserContext
277
+
278
+ # check if SDK is ready
279
+ unless is_valid
280
+ @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide_for_keys').message)
281
+ return {}
282
+ end
283
+
284
+ enabled_flags_only = (!decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)) || (@default_decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
285
+
286
+ decisions = {}
287
+ keys.each do |key|
288
+ decision = decide(user_context, key, decide_options)
289
+ decisions[key] = decision unless enabled_flags_only && !decision.enabled
290
+ end
291
+ decisions
292
+ end
293
+
110
294
  # Buckets visitor and sends impression event to Optimizely.
111
295
  #
112
296
  # @param experiment_key - Experiment which needs to be activated.
@@ -222,7 +406,7 @@ module Optimizely
222
406
  config = project_config
223
407
 
224
408
  forced_variation_key = nil
225
- forced_variation = @decision_service.get_forced_variation(config, experiment_key, user_id)
409
+ forced_variation, = @decision_service.get_forced_variation(config, experiment_key, user_id)
226
410
  forced_variation_key = forced_variation['key'] if forced_variation
227
411
 
228
412
  forced_variation_key
@@ -306,7 +490,7 @@ module Optimizely
306
490
  return false
307
491
  end
308
492
 
309
- decision = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes)
493
+ decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes)
310
494
 
311
495
  feature_enabled = false
312
496
  source_string = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']
@@ -555,7 +739,7 @@ module Optimizely
555
739
  return nil
556
740
  end
557
741
 
558
- decision = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes)
742
+ decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes)
559
743
  variation = decision ? decision['variation'] : nil
560
744
  feature_enabled = variation ? variation['featureEnabled'] : false
561
745
  all_variables = {}
@@ -695,7 +879,7 @@ module Optimizely
695
879
 
696
880
  return nil unless user_inputs_valid?(attributes)
697
881
 
698
- variation_id = @decision_service.get_variation(config, experiment_key, user_id, attributes)
882
+ variation_id, = @decision_service.get_variation(config, experiment_key, user_id, attributes)
699
883
  variation = config.get_variation_from_id(experiment_key, variation_id) unless variation_id.nil?
700
884
  variation_key = variation['key'] if variation
701
885
  decision_notification_type = if config.feature_experiment?(experiment['id'])
@@ -761,7 +945,7 @@ module Optimizely
761
945
  return nil
762
946
  end
763
947
 
764
- decision = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes)
948
+ decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes)
765
949
  variation = decision ? decision['variation'] : nil
766
950
  feature_enabled = variation ? variation['featureEnabled'] : false
767
951
 
@@ -38,6 +38,7 @@ module Optimizely
38
38
  # This defaults to experiment['key'].
39
39
  #
40
40
  # Returns boolean representing if user satisfies audience conditions for the audiences or not.
41
+ decide_reasons = []
41
42
  logging_hash ||= 'EXPERIMENT_AUDIENCE_EVALUATION_LOGS'
42
43
  logging_key ||= experiment['key']
43
44
 
@@ -45,26 +46,15 @@ module Optimizely
45
46
 
46
47
  audience_conditions = experiment['audienceConditions'] || experiment['audienceIds']
47
48
 
48
- logger.log(
49
- Logger::DEBUG,
50
- format(
51
- logs_hash['EVALUATING_AUDIENCES_COMBINED'],
52
- logging_key,
53
- audience_conditions
54
- )
55
- )
49
+ message = format(logs_hash['EVALUATING_AUDIENCES_COMBINED'], logging_key, audience_conditions)
50
+ logger.log(Logger::DEBUG, message)
56
51
 
57
52
  # Return true if there are no audiences
58
53
  if audience_conditions.empty?
59
- logger.log(
60
- Logger::INFO,
61
- format(
62
- logs_hash['AUDIENCE_EVALUATION_RESULT_COMBINED'],
63
- logging_key,
64
- 'TRUE'
65
- )
66
- )
67
- 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
68
58
  end
69
59
 
70
60
  attributes ||= {}
@@ -80,39 +70,28 @@ module Optimizely
80
70
  return nil unless audience
81
71
 
82
72
  audience_conditions = audience['conditions']
83
- logger.log(
84
- Logger::DEBUG,
85
- format(
86
- logs_hash['EVALUATING_AUDIENCE'],
87
- audience_id,
88
- audience_conditions
89
- )
90
- )
73
+ message = format(logs_hash['EVALUATING_AUDIENCE'], audience_id, audience_conditions)
74
+ logger.log(Logger::DEBUG, message)
75
+ decide_reasons.push(message)
91
76
 
92
77
  audience_conditions = JSON.parse(audience_conditions) if audience_conditions.is_a?(String)
93
78
  result = ConditionTreeEvaluator.evaluate(audience_conditions, evaluate_custom_attr)
94
79
  result_str = result.nil? ? 'UNKNOWN' : result.to_s.upcase
95
- logger.log(
96
- Logger::DEBUG,
97
- format(logs_hash['AUDIENCE_EVALUATION_RESULT'], audience_id, result_str)
98
- )
80
+ message = format(logs_hash['AUDIENCE_EVALUATION_RESULT'], audience_id, result_str)
81
+ logger.log(Logger::DEBUG, message)
82
+ decide_reasons.push(message)
83
+
99
84
  result
100
85
  end
101
86
 
102
87
  eval_result = ConditionTreeEvaluator.evaluate(audience_conditions, evaluate_audience)
103
-
104
88
  eval_result ||= false
105
89
 
106
- logger.log(
107
- Logger::INFO,
108
- format(
109
- logs_hash['AUDIENCE_EVALUATION_RESULT_COMBINED'],
110
- logging_key,
111
- eval_result.to_s.upcase
112
- )
113
- )
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)
114
93
 
115
- eval_result
94
+ [eval_result, decide_reasons]
116
95
  end
117
96
  end
118
97
  end
@@ -44,7 +44,9 @@ module Optimizely
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
50
52
  # this will not affect evaluation of rollout rules.
@@ -55,46 +57,49 @@ module Optimizely
55
57
  group = project_config.group_id_map.fetch(group_id)
56
58
  if Helpers::Group.random_policy?(group)
57
59
  traffic_allocations = group.fetch('trafficAllocation')
58
- 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
+
59
63
  # return if the user is not bucketed into any experiment
60
64
  unless bucketed_experiment_id
61
- @logger.log(Logger::INFO, "User '#{user_id}' is in no experiment.")
62
- 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
63
69
  end
64
70
 
65
71
  # return if the user is bucketed into a different experiment than the one specified
66
72
  if bucketed_experiment_id != experiment_id
67
- @logger.log(
68
- Logger::INFO,
69
- "User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}."
70
- )
71
- 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
72
77
  end
73
78
 
74
79
  # continue bucketing if the user is bucketed into the experiment specified
75
- @logger.log(
76
- Logger::INFO,
77
- "User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}."
78
- )
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)
79
83
  end
80
84
  end
81
85
 
82
86
  traffic_allocations = experiment['trafficAllocation']
83
- 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
+
84
90
  if variation_id && variation_id != ''
85
91
  variation = project_config.get_variation_from_id(experiment_key, variation_id)
86
- return variation
92
+ return variation, decide_reasons
87
93
  end
88
94
 
89
95
  # Handle the case when the traffic range is empty due to sticky bucketing
90
96
  if variation_id == ''
91
- @logger.log(
92
- Logger::DEBUG,
93
- 'Bucketed into an empty traffic range. Returning nil.'
94
- )
97
+ message = 'Bucketed into an empty traffic range. Returning nil.'
98
+ @logger.log(Logger::DEBUG, message)
99
+ decide_reasons.push(message)
95
100
  end
96
101
 
97
- nil
102
+ [nil, decide_reasons]
98
103
  end
99
104
 
100
105
  def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations)
@@ -105,21 +110,24 @@ module Optimizely
105
110
  # parent_id - String entity ID to use for bucketing ID
106
111
  # traffic_allocations - Array of traffic allocations
107
112
  #
108
- # 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 = []
109
116
  bucketing_key = format(BUCKETING_ID_TEMPLATE, bucketing_id: bucketing_id, entity_id: parent_id)
110
117
  bucket_value = generate_bucket_value(bucketing_key)
111
- @logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}' "\
112
- "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)
113
121
 
114
122
  traffic_allocations.each do |traffic_allocation|
115
123
  current_end_of_range = traffic_allocation['endOfRange']
116
124
  if bucket_value < current_end_of_range
117
125
  entity_id = traffic_allocation['entityId']
118
- return entity_id
126
+ return entity_id, decide_reasons
119
127
  end
120
128
  end
121
129
 
122
- nil
130
+ [nil, decide_reasons]
123
131
  end
124
132
 
125
133
  private
@@ -0,0 +1,28 @@
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
+ module Optimizely
19
+ module Decide
20
+ module OptimizelyDecideOption
21
+ DISABLE_DECISION_EVENT = 'DISABLE_DECISION_EVENT'
22
+ ENABLED_FLAGS_ONLY = 'ENABLED_FLAGS_ONLY'
23
+ IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE'
24
+ INCLUDE_REASONS = 'INCLUDE_REASONS'
25
+ EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES'
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,60 @@
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
+ require 'json'
19
+
20
+ module Optimizely
21
+ module Decide
22
+ class OptimizelyDecision
23
+ attr_reader :variation_key, :enabled, :variables, :rule_key, :flag_key, :user_context, :reasons
24
+
25
+ def initialize(
26
+ variation_key: nil,
27
+ enabled: nil,
28
+ variables: nil,
29
+ rule_key: nil,
30
+ flag_key: nil,
31
+ user_context: nil,
32
+ reasons: nil
33
+ )
34
+ @variation_key = variation_key
35
+ @enabled = enabled || false
36
+ @variables = variables || {}
37
+ @rule_key = rule_key
38
+ @flag_key = flag_key
39
+ @user_context = user_context
40
+ @reasons = reasons || []
41
+ end
42
+
43
+ def as_json
44
+ {
45
+ variation_key: @variation_key,
46
+ enabled: @enabled,
47
+ variables: @variables,
48
+ rule_key: @rule_key,
49
+ flag_key: @flag_key,
50
+ user_context: @user_context.as_json,
51
+ reasons: @reasons
52
+ }
53
+ end
54
+
55
+ def to_json(*args)
56
+ as_json.to_json(*args)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,26 @@
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
+ module Optimizely
19
+ module Decide
20
+ module OptimizelyDecisionMessage
21
+ SDK_NOT_READY = 'Optimizely SDK not configured properly yet.'
22
+ FLAG_KEY_INVALID = 'No flag was found for key "%s".'
23
+ VARIABLE_VALUE_INVALID = 'Variable value for key "%s" is invalid or wrong type.'
24
+ end
25
+ end
26
+ end
@@ -52,7 +52,7 @@ module Optimizely
52
52
  @forced_variation_map = {}
53
53
  end
54
54
 
55
- def get_variation(project_config, experiment_key, user_id, attributes = nil)
55
+ def get_variation(project_config, experiment_key, user_id, attributes = nil, decide_options = [])
56
56
  # Determines variation into which user will be bucketed.
57
57
  #
58
58
  # project_config - project_config - Instance of ProjectConfig
@@ -63,66 +63,78 @@ module Optimizely
63
63
  # Returns variation ID where visitor will be bucketed
64
64
  # (nil if experiment is inactive or user does not meet audience conditions)
65
65
 
66
+ decide_reasons = []
66
67
  # By default, the bucketing ID should be the user ID
67
- bucketing_id = get_bucketing_id(user_id, attributes)
68
+ bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
69
+ decide_reasons.push(*bucketing_id_reasons)
68
70
  # Check to make sure experiment is active
69
71
  experiment = project_config.get_experiment_from_key(experiment_key)
70
- return nil if experiment.nil?
72
+ return nil, decide_reasons if experiment.nil?
71
73
 
72
74
  experiment_id = experiment['id']
73
75
  unless project_config.experiment_running?(experiment)
74
- @logger.log(Logger::INFO, "Experiment '#{experiment_key}' is not running.")
75
- return nil
76
+ message = "Experiment '#{experiment_key}' is not running."
77
+ @logger.log(Logger::INFO, message)
78
+ decide_reasons.push(message)
79
+ return nil, decide_reasons
76
80
  end
77
81
 
78
82
  # Check if a forced variation is set for the user
79
- forced_variation = get_forced_variation(project_config, experiment_key, user_id)
80
- return forced_variation['id'] if forced_variation
83
+ forced_variation, reasons_received = get_forced_variation(project_config, experiment_key, user_id)
84
+ decide_reasons.push(*reasons_received)
85
+ return forced_variation['id'], decide_reasons if forced_variation
81
86
 
82
87
  # Check if user is in a white-listed variation
83
- whitelisted_variation_id = get_whitelisted_variation_id(project_config, experiment_key, user_id)
84
- return whitelisted_variation_id if whitelisted_variation_id
85
-
86
- # Check for saved bucketing decisions
87
- user_profile = get_user_profile(user_id)
88
- saved_variation_id = get_saved_variation_id(project_config, experiment_id, user_profile)
89
- if saved_variation_id
90
- @logger.log(
91
- Logger::INFO,
92
- "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
93
- )
94
- return saved_variation_id
88
+ whitelisted_variation_id, reasons_received = get_whitelisted_variation_id(project_config, experiment_key, user_id)
89
+ decide_reasons.push(*reasons_received)
90
+ return whitelisted_variation_id, decide_reasons if whitelisted_variation_id
91
+
92
+ should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
93
+ # Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService
94
+ unless should_ignore_user_profile_service
95
+ user_profile, reasons_received = get_user_profile(user_id)
96
+ decide_reasons.push(*reasons_received)
97
+ saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile)
98
+ decide_reasons.push(*reasons_received)
99
+ if saved_variation_id
100
+ message = "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
101
+ @logger.log(Logger::INFO, message)
102
+ decide_reasons.push(message)
103
+ return saved_variation_id, decide_reasons
104
+ end
95
105
  end
96
106
 
97
107
  # Check audience conditions
98
- unless Audience.user_meets_audience_conditions?(project_config, experiment, attributes, @logger)
99
- @logger.log(
100
- Logger::INFO,
101
- "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
102
- )
103
- return nil
108
+ user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, experiment, attributes, @logger)
109
+ decide_reasons.push(*reasons_received)
110
+ unless user_meets_audience_conditions
111
+ message = "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
112
+ @logger.log(Logger::INFO, message)
113
+ decide_reasons.push(message)
114
+ return nil, decide_reasons
104
115
  end
105
116
 
106
117
  # Bucket normally
107
- variation = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
118
+ variation, bucket_reasons = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
119
+ decide_reasons.push(*bucket_reasons)
108
120
  variation_id = variation ? variation['id'] : nil
109
121
 
122
+ message = ''
110
123
  if variation_id
111
124
  variation_key = variation['key']
112
- @logger.log(
113
- Logger::INFO,
114
- "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_key}'."
115
- )
125
+ message = "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_key}'."
116
126
  else
117
- @logger.log(Logger::INFO, "User '#{user_id}' is in no variation.")
127
+ message = "User '#{user_id}' is in no variation."
118
128
  end
129
+ @logger.log(Logger::INFO, message)
130
+ decide_reasons.push(message)
119
131
 
120
132
  # Persist bucketing decision
121
- save_user_profile(user_profile, experiment_id, variation_id)
122
- variation_id
133
+ save_user_profile(user_profile, experiment_id, variation_id) unless should_ignore_user_profile_service
134
+ [variation_id, decide_reasons]
123
135
  end
124
136
 
125
- def get_variation_for_feature(project_config, feature_flag, user_id, attributes = nil)
137
+ def get_variation_for_feature(project_config, feature_flag, user_id, attributes = nil, decide_options = [])
126
138
  # Get the variation the user is bucketed into for the given FeatureFlag.
127
139
  #
128
140
  # project_config - project_config - Instance of ProjectConfig
@@ -132,16 +144,20 @@ module Optimizely
132
144
  #
133
145
  # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
134
146
 
147
+ decide_reasons = []
148
+
135
149
  # check if the feature is being experiment on and whether the user is bucketed into the experiment
136
- decision = get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes)
137
- return decision unless decision.nil?
150
+ decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes, decide_options)
151
+ decide_reasons.push(*reasons_received)
152
+ return decision, decide_reasons unless decision.nil?
138
153
 
139
- decision = get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes)
154
+ decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes)
155
+ decide_reasons.push(*reasons_received)
140
156
 
141
- decision
157
+ [decision, decide_reasons]
142
158
  end
143
159
 
144
- def get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes = nil)
160
+ def get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes = nil, decide_options = [])
145
161
  # Gets the variation the user is bucketed into for the feature flag's experiment.
146
162
  #
147
163
  # project_config - project_config - Instance of ProjectConfig
@@ -151,42 +167,41 @@ module Optimizely
151
167
  #
152
168
  # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
153
169
  # or nil if the user is not bucketed into any of the experiments on the feature
170
+ decide_reasons = []
154
171
  feature_flag_key = feature_flag['key']
155
172
  if feature_flag['experimentIds'].empty?
156
- @logger.log(
157
- Logger::DEBUG,
158
- "The feature flag '#{feature_flag_key}' is not used in any experiments."
159
- )
160
- return nil
173
+ message = "The feature flag '#{feature_flag_key}' is not used in any experiments."
174
+ @logger.log(Logger::DEBUG, message)
175
+ decide_reasons.push(message)
176
+ return nil, decide_reasons
161
177
  end
162
178
 
163
179
  # Evaluate each experiment and return the first bucketed experiment variation
164
180
  feature_flag['experimentIds'].each do |experiment_id|
165
181
  experiment = project_config.experiment_id_map[experiment_id]
166
182
  unless experiment
167
- @logger.log(
168
- Logger::DEBUG,
169
- "Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
170
- )
171
- return nil
183
+ message = "Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
184
+ @logger.log(Logger::DEBUG, message)
185
+ decide_reasons.push(message)
186
+ return nil, decide_reasons
172
187
  end
173
188
 
174
189
  experiment_key = experiment['key']
175
- variation_id = get_variation(project_config, experiment_key, user_id, attributes)
190
+ variation_id, reasons_received = get_variation(project_config, experiment_key, user_id, attributes, decide_options)
191
+ decide_reasons.push(*reasons_received)
176
192
 
177
193
  next unless variation_id
178
194
 
179
195
  variation = project_config.variation_id_map[experiment_key][variation_id]
180
196
 
181
- return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST'])
197
+ return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST']), decide_reasons
182
198
  end
183
199
 
184
- @logger.log(
185
- Logger::INFO,
186
- "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
187
- )
200
+ message = "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
201
+ @logger.log(Logger::INFO, message)
202
+ decide_reasons.push(message)
188
203
 
189
- nil
204
+ [nil, decide_reasons]
190
205
  end
191
206
 
192
207
  def get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes = nil)
@@ -199,27 +214,27 @@ module Optimizely
199
214
  # attributes - Hash representing user attributes
200
215
  #
201
216
  # Returns the Decision struct or nil if not bucketed into any of the targeting rules
202
- bucketing_id = get_bucketing_id(user_id, attributes)
217
+ decide_reasons = []
218
+ bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
219
+ decide_reasons.push(*bucketing_id_reasons)
203
220
  rollout_id = feature_flag['rolloutId']
204
221
  if rollout_id.nil? || rollout_id.empty?
205
222
  feature_flag_key = feature_flag['key']
206
- @logger.log(
207
- Logger::DEBUG,
208
- "Feature flag '#{feature_flag_key}' is not used in a rollout."
209
- )
210
- return nil
223
+ message = "Feature flag '#{feature_flag_key}' is not used in a rollout."
224
+ @logger.log(Logger::DEBUG, message)
225
+ decide_reasons.push(message)
226
+ return nil, decide_reasons
211
227
  end
212
228
 
213
229
  rollout = project_config.get_rollout_from_id(rollout_id)
214
230
  if rollout.nil?
215
- @logger.log(
216
- Logger::DEBUG,
217
- "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'"
218
- )
219
- return nil
231
+ message = "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'"
232
+ @logger.log(Logger::DEBUG, message)
233
+ decide_reasons.push(message)
234
+ return nil, decide_reasons
220
235
  end
221
236
 
222
- return nil if rollout['experiments'].empty?
237
+ return nil, decide_reasons if rollout['experiments'].empty?
223
238
 
224
239
  rollout_rules = rollout['experiments']
225
240
  number_of_rules = rollout_rules.length - 1
@@ -229,24 +244,25 @@ module Optimizely
229
244
  rollout_rule = rollout_rules[index]
230
245
  logging_key = index + 1
231
246
 
247
+ user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, rollout_rule, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
248
+ decide_reasons.push(*reasons_received)
232
249
  # Check that user meets audience conditions for targeting rule
233
- unless Audience.user_meets_audience_conditions?(project_config, rollout_rule, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
234
- @logger.log(
235
- Logger::DEBUG,
236
- "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'."
237
- )
250
+ unless user_meets_audience_conditions
251
+ message = "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'."
252
+ @logger.log(Logger::DEBUG, message)
253
+ decide_reasons.push(message)
238
254
  # move onto the next targeting rule
239
255
  next
240
256
  end
241
257
 
242
- @logger.log(
243
- Logger::DEBUG,
244
- "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
245
- )
258
+ message = "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
259
+ @logger.log(Logger::DEBUG, message)
260
+ decide_reasons.push(message)
246
261
 
247
262
  # Evaluate if user satisfies the traffic allocation for this rollout rule
248
- variation = @bucketer.bucket(project_config, rollout_rule, bucketing_id, user_id)
249
- return Decision.new(rollout_rule, variation, DECISION_SOURCES['ROLLOUT']) unless variation.nil?
263
+ variation, bucket_reasons = @bucketer.bucket(project_config, rollout_rule, bucketing_id, user_id)
264
+ decide_reasons.push(*bucket_reasons)
265
+ return Decision.new(rollout_rule, variation, DECISION_SOURCES['ROLLOUT']), decide_reasons unless variation.nil?
250
266
 
251
267
  break
252
268
  end
@@ -254,23 +270,26 @@ module Optimizely
254
270
  # get last rule which is the everyone else rule
255
271
  everyone_else_experiment = rollout_rules[number_of_rules]
256
272
  logging_key = 'Everyone Else'
273
+
274
+ user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, everyone_else_experiment, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
275
+ decide_reasons.push(*reasons_received)
257
276
  # Check that user meets audience conditions for last rule
258
- unless Audience.user_meets_audience_conditions?(project_config, everyone_else_experiment, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
259
- @logger.log(
260
- Logger::DEBUG,
261
- "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'."
262
- )
263
- return nil
277
+ unless user_meets_audience_conditions
278
+ message = "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'."
279
+ @logger.log(Logger::DEBUG, message)
280
+ decide_reasons.push(message)
281
+ return nil, decide_reasons
264
282
  end
265
283
 
266
- @logger.log(
267
- Logger::DEBUG,
268
- "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
269
- )
270
- variation = @bucketer.bucket(project_config, everyone_else_experiment, bucketing_id, user_id)
271
- return Decision.new(everyone_else_experiment, variation, DECISION_SOURCES['ROLLOUT']) unless variation.nil?
284
+ message = "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
285
+ @logger.log(Logger::DEBUG, message)
286
+ decide_reasons.push(message)
272
287
 
273
- nil
288
+ variation, bucket_reasons = @bucketer.bucket(project_config, everyone_else_experiment, bucketing_id, user_id)
289
+ decide_reasons.push(*bucket_reasons)
290
+ return Decision.new(everyone_else_experiment, variation, DECISION_SOURCES['ROLLOUT']), decide_reasons unless variation.nil?
291
+
292
+ [nil, decide_reasons]
274
293
  end
275
294
 
276
295
  def set_forced_variation(project_config, experiment_key, user_id, variation_key)
@@ -320,9 +339,11 @@ module Optimizely
320
339
  #
321
340
  # Returns Variation The variation which the given user and experiment should be forced into
322
341
 
342
+ decide_reasons = []
323
343
  unless @forced_variation_map.key? user_id
324
- @logger.log(Logger::DEBUG, "User '#{user_id}' is not in the forced variation map.")
325
- return nil
344
+ message = "User '#{user_id}' is not in the forced variation map."
345
+ @logger.log(Logger::DEBUG, message)
346
+ return nil, decide_reasons
326
347
  end
327
348
 
328
349
  experiment_to_variation_map = @forced_variation_map[user_id]
@@ -330,12 +351,13 @@ module Optimizely
330
351
  experiment_id = experiment['id'] if experiment
331
352
  # check for nil and empty string experiment ID
332
353
  # this case is logged in get_experiment_from_key
333
- return nil if experiment_id.nil? || experiment_id.empty?
354
+ return nil, decide_reasons if experiment_id.nil? || experiment_id.empty?
334
355
 
335
356
  unless experiment_to_variation_map.key? experiment_id
336
- @logger.log(Logger::DEBUG, "No experiment '#{experiment_key}' mapped to user '#{user_id}' "\
337
- 'in the forced variation map.')
338
- return nil
357
+ message = "No experiment '#{experiment_key}' mapped to user '#{user_id}' in the forced variation map."
358
+ @logger.log(Logger::DEBUG, message)
359
+ decide_reasons.push(message)
360
+ return nil, decide_reasons
339
361
  end
340
362
 
341
363
  variation_id = experiment_to_variation_map[experiment_id]
@@ -345,12 +367,13 @@ module Optimizely
345
367
 
346
368
  # check if the variation exists in the datafile
347
369
  # this case is logged in get_variation_from_id
348
- return nil if variation_key.empty?
370
+ return nil, decide_reasons if variation_key.empty?
349
371
 
350
- @logger.log(Logger::DEBUG, "Variation '#{variation_key}' is mapped to experiment '#{experiment_key}' "\
351
- "and user '#{user_id}' in the forced variation map")
372
+ message = "Variation '#{variation_key}' is mapped to experiment '#{experiment_key}' and user '#{user_id}' in the forced variation map"
373
+ @logger.log(Logger::DEBUG, message)
374
+ decide_reasons.push(message)
352
375
 
353
- variation
376
+ [variation, decide_reasons]
354
377
  end
355
378
 
356
379
  private
@@ -366,27 +389,24 @@ module Optimizely
366
389
 
367
390
  whitelisted_variations = project_config.get_whitelisted_variations(experiment_key)
368
391
 
369
- return nil unless whitelisted_variations
392
+ return nil, nil unless whitelisted_variations
370
393
 
371
394
  whitelisted_variation_key = whitelisted_variations[user_id]
372
395
 
373
- return nil unless whitelisted_variation_key
396
+ return nil, nil unless whitelisted_variation_key
374
397
 
375
398
  whitelisted_variation_id = project_config.get_variation_id_from_key(experiment_key, whitelisted_variation_key)
376
399
 
377
400
  unless whitelisted_variation_id
378
- @logger.log(
379
- Logger::INFO,
380
- "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile."
381
- )
382
- return nil
401
+ message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile."
402
+ @logger.log(Logger::INFO, message)
403
+ return nil, message
383
404
  end
384
405
 
385
- @logger.log(
386
- Logger::INFO,
387
- "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'."
388
- )
389
- whitelisted_variation_id
406
+ message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'."
407
+ @logger.log(Logger::INFO, message)
408
+
409
+ [whitelisted_variation_id, message]
390
410
  end
391
411
 
392
412
  def get_saved_variation_id(project_config, experiment_id, user_profile)
@@ -397,19 +417,18 @@ module Optimizely
397
417
  # user_profile - Hash user profile
398
418
  #
399
419
  # Returns string variation ID (nil if no decision is found)
400
- return nil unless user_profile[:experiment_bucket_map]
420
+ return nil, nil unless user_profile[:experiment_bucket_map]
401
421
 
402
422
  decision = user_profile[:experiment_bucket_map][experiment_id]
403
- return nil unless decision
423
+ return nil, nil unless decision
404
424
 
405
425
  variation_id = decision[:variation_id]
406
- return variation_id if project_config.variation_id_exists?(experiment_id, variation_id)
426
+ return variation_id, nil if project_config.variation_id_exists?(experiment_id, variation_id)
427
+
428
+ message = "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."
429
+ @logger.log(Logger::INFO, message)
407
430
 
408
- @logger.log(
409
- Logger::INFO,
410
- "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."
411
- )
412
- nil
431
+ [nil, message]
413
432
  end
414
433
 
415
434
  def get_user_profile(user_id)
@@ -424,15 +443,17 @@ module Optimizely
424
443
  experiment_bucket_map: {}
425
444
  }
426
445
 
427
- return user_profile unless @user_profile_service
446
+ return user_profile, nil unless @user_profile_service
428
447
 
448
+ message = nil
429
449
  begin
430
450
  user_profile = @user_profile_service.lookup(user_id) || user_profile
431
451
  rescue => e
432
- @logger.log(Logger::ERROR, "Error while looking up user profile for user ID '#{user_id}': #{e}.")
452
+ message = "Error while looking up user profile for user ID '#{user_id}': #{e}."
453
+ @logger.log(Logger::ERROR, message)
433
454
  end
434
455
 
435
- user_profile
456
+ [user_profile, message]
436
457
  end
437
458
 
438
459
  def save_user_profile(user_profile, experiment_id, variation_id)
@@ -463,16 +484,17 @@ module Optimizely
463
484
  # attributes - Hash user attributes
464
485
  # Returns String representing bucketing ID if it is a String type in attributes else return user ID
465
486
 
466
- return user_id unless attributes
487
+ return user_id, nil unless attributes
467
488
 
468
489
  bucketing_id = attributes[Optimizely::Helpers::Constants::CONTROL_ATTRIBUTES['BUCKETING_ID']]
469
490
 
470
491
  if bucketing_id
471
- return bucketing_id if bucketing_id.is_a?(String)
492
+ return bucketing_id, nil if bucketing_id.is_a?(String)
472
493
 
473
- @logger.log(Logger::WARN, 'Bucketing ID attribute is not a string. Defaulted to user ID.')
494
+ message = 'Bucketing ID attribute is not a string. Defaulted to user ID.'
495
+ @logger.log(Logger::WARN, message)
474
496
  end
475
- user_id
497
+ [user_id, message]
476
498
  end
477
499
  end
478
500
  end
@@ -369,6 +369,7 @@ module Optimizely
369
369
  'FEATURE' => 'feature',
370
370
  'FEATURE_TEST' => 'feature-test',
371
371
  'FEATURE_VARIABLE' => 'feature-variable',
372
+ 'FLAG' => 'flag',
372
373
  'ALL_FEATURE_VARIABLES' => 'all-feature-variables'
373
374
  }.freeze
374
375
 
@@ -0,0 +1,107 @@
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 'json'
20
+
21
+ module Optimizely
22
+ class OptimizelyUserContext
23
+ # Representation of an Optimizely User Context using which APIs are to be called.
24
+
25
+ attr_reader :user_id
26
+
27
+ def initialize(optimizely_client, user_id, user_attributes)
28
+ @attr_mutex = Mutex.new
29
+ @optimizely_client = optimizely_client
30
+ @user_id = user_id
31
+ @user_attributes = user_attributes.nil? ? {} : user_attributes.clone
32
+ end
33
+
34
+ def clone
35
+ OptimizelyUserContext.new(@optimizely_client, @user_id, user_attributes)
36
+ end
37
+
38
+ def user_attributes
39
+ @attr_mutex.synchronize { @user_attributes.clone }
40
+ end
41
+
42
+ # Set an attribute for a given key
43
+ #
44
+ # @param key - An attribute key
45
+ # @param value - An attribute value
46
+
47
+ def set_attribute(attribute_key, attribute_value)
48
+ @attr_mutex.synchronize { @user_attributes[attribute_key] = attribute_value }
49
+ end
50
+
51
+ # Returns a decision result (OptimizelyDecision) for a given flag key and a user context, which contains all data required to deliver the flag.
52
+ #
53
+ # If the SDK finds an error, it'll return a `decision` with nil for `variation_key`. The decision will include an error message in `reasons`
54
+ #
55
+ # @param key -A flag key for which a decision will be made
56
+ # @param options - A list of options for decision making.
57
+ #
58
+ # @return [OptimizelyDecision] A decision result
59
+
60
+ def decide(key, options = nil)
61
+ @optimizely_client&.decide(clone, key, options)
62
+ end
63
+
64
+ # Returns a hash of decision results (OptimizelyDecision) for multiple flag keys and a user context.
65
+ #
66
+ # If the SDK finds an error for a key, the response will include a decision for the key showing `reasons` for the error.
67
+ # The SDK will always return hash of decisions. When it can not process requests, it'll return an empty hash after logging the errors.
68
+ #
69
+ # @param keys - A list of flag keys for which the decisions will be made.
70
+ # @param options - A list of options for decision making.
71
+ #
72
+ # @return - Hash of decisions containing flag keys as hash keys and corresponding decisions as their values.
73
+
74
+ def decide_for_keys(keys, options = nil)
75
+ @optimizely_client&.decide_for_keys(clone, keys, options)
76
+ end
77
+
78
+ # Returns a hash of decision results (OptimizelyDecision) for all active flag keys.
79
+ #
80
+ # @param options - A list of options for decision making.
81
+ #
82
+ # @return - Hash of decisions containing flag keys as hash keys and corresponding decisions as their values.
83
+
84
+ def decide_all(options = nil)
85
+ @optimizely_client&.decide_all(clone, options)
86
+ end
87
+
88
+ # Track an event
89
+ #
90
+ # @param event_key - Event key representing the event which needs to be recorded.
91
+
92
+ def track_event(event_key, event_tags = nil)
93
+ @optimizely_client&.track(event_key, @user_id, user_attributes, event_tags)
94
+ end
95
+
96
+ def as_json
97
+ {
98
+ user_id: @user_id,
99
+ attributes: @user_attributes
100
+ }
101
+ end
102
+
103
+ def to_json(*args)
104
+ as_json.to_json(*args)
105
+ end
106
+ end
107
+ end
@@ -17,5 +17,5 @@
17
17
  #
18
18
  module Optimizely
19
19
  CLIENT_ENGINE = 'ruby-sdk'
20
- VERSION = '3.7.0'
20
+ VERSION = '3.8.0'
21
21
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: optimizely-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.7.0
4
+ version: 3.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Optimizely
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-11-20 00:00:00.000000000 Z
11
+ date: 2021-02-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -141,6 +141,9 @@ files:
141
141
  - lib/optimizely/config_manager/project_config_manager.rb
142
142
  - lib/optimizely/config_manager/static_project_config_manager.rb
143
143
  - lib/optimizely/custom_attribute_condition_evaluator.rb
144
+ - lib/optimizely/decide/optimizely_decide_option.rb
145
+ - lib/optimizely/decide/optimizely_decision.rb
146
+ - lib/optimizely/decide/optimizely_decision_message.rb
144
147
  - lib/optimizely/decision_service.rb
145
148
  - lib/optimizely/error_handler.rb
146
149
  - lib/optimizely/event/batch_event_processor.rb
@@ -172,6 +175,7 @@ files:
172
175
  - lib/optimizely/notification_center.rb
173
176
  - lib/optimizely/optimizely_config.rb
174
177
  - lib/optimizely/optimizely_factory.rb
178
+ - lib/optimizely/optimizely_user_context.rb
175
179
  - lib/optimizely/params.rb
176
180
  - lib/optimizely/project_config.rb
177
181
  - lib/optimizely/semantic_version.rb