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 +4 -4
- data/lib/optimizely.rb +190 -6
- data/lib/optimizely/audience.rb +18 -39
- data/lib/optimizely/bucketer.rb +33 -25
- data/lib/optimizely/decide/optimizely_decide_option.rb +28 -0
- data/lib/optimizely/decide/optimizely_decision.rb +60 -0
- data/lib/optimizely/decide/optimizely_decision_message.rb +26 -0
- data/lib/optimizely/decision_service.rb +151 -129
- data/lib/optimizely/helpers/constants.rb +1 -0
- data/lib/optimizely/optimizely_user_context.rb +107 -0
- data/lib/optimizely/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 62e8da2296d75e8f0cad746e8eca25c2670bde6bac084a840c46266322dc378f
|
4
|
+
data.tar.gz: 67d25b1695a294616b6dc62c7fa1d822c5262f273626d8c3c5a701e78b8e118f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
|
data/lib/optimizely/audience.rb
CHANGED
@@ -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
|
-
|
49
|
-
|
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
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
84
|
-
|
85
|
-
|
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
|
-
|
96
|
-
|
97
|
-
|
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
|
-
|
107
|
-
|
108
|
-
|
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
|
data/lib/optimizely/bucketer.rb
CHANGED
@@ -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
|
-
|
62
|
-
|
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
|
-
|
68
|
-
|
69
|
-
|
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
|
-
|
76
|
-
|
77
|
-
|
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
|
-
|
92
|
-
|
93
|
-
|
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
|
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
|
-
|
112
|
-
|
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
|
-
|
75
|
-
|
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
|
-
|
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
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
)
|
94
|
-
|
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
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
)
|
103
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
157
|
-
|
158
|
-
|
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
|
-
|
168
|
-
|
169
|
-
|
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
|
-
|
185
|
-
|
186
|
-
|
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
|
-
|
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
|
-
|
207
|
-
|
208
|
-
|
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
|
-
|
216
|
-
|
217
|
-
|
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
|
234
|
-
|
235
|
-
|
236
|
-
|
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
|
-
|
243
|
-
|
244
|
-
|
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
|
-
|
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
|
259
|
-
|
260
|
-
|
261
|
-
|
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
|
-
|
267
|
-
|
268
|
-
|
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
|
-
|
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
|
-
|
325
|
-
|
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
|
-
|
337
|
-
|
338
|
-
|
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
|
-
|
351
|
-
|
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
|
-
|
379
|
-
|
380
|
-
|
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
|
-
|
386
|
-
|
387
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
@@ -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
|
data/lib/optimizely/version.rb
CHANGED
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.
|
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:
|
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
|