optimizely-sdk 3.7.0 → 3.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +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
|