optimizely-sdk 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml 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