optimizely-sdk 3.4.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 +383 -49
- data/lib/optimizely/audience.rb +31 -43
- data/lib/optimizely/bucketer.rb +36 -33
- data/lib/optimizely/config/datafile_project_config.rb +19 -3
- data/lib/optimizely/config/proxy_config.rb +34 -0
- data/lib/optimizely/config_manager/async_scheduler.rb +6 -2
- data/lib/optimizely/config_manager/http_project_config_manager.rb +40 -23
- data/lib/optimizely/custom_attribute_condition_evaluator.rb +133 -37
- 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 +163 -139
- data/lib/optimizely/event/entity/decision.rb +6 -4
- data/lib/optimizely/event/entity/impression_event.rb +4 -2
- data/lib/optimizely/event/event_factory.rb +4 -3
- data/lib/optimizely/event/user_event_factory.rb +4 -3
- data/lib/optimizely/event_dispatcher.rb +8 -14
- data/lib/optimizely/exceptions.rb +17 -9
- data/lib/optimizely/helpers/constants.rb +19 -5
- data/lib/optimizely/helpers/http_utils.rb +64 -0
- data/lib/optimizely/helpers/variable_type.rb +8 -1
- data/lib/optimizely/optimizely_config.rb +2 -1
- data/lib/optimizely/optimizely_factory.rb +54 -5
- data/lib/optimizely/optimizely_user_context.rb +107 -0
- data/lib/optimizely/project_config.rb +5 -1
- data/lib/optimizely/semantic_version.rb +166 -0
- data/lib/optimizely/version.rb +1 -1
- metadata +9 -16
@@ -1,7 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
#
|
4
|
-
# Copyright 2019, Optimizely and contributors
|
4
|
+
# Copyright 2019-2020, Optimizely and contributors
|
5
5
|
#
|
6
6
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
7
|
# you may not use this file except in compliance with the License.
|
@@ -15,8 +15,10 @@
|
|
15
15
|
# See the License for the specific language governing permissions and
|
16
16
|
# limitations under the License.
|
17
17
|
#
|
18
|
+
require_relative 'exceptions'
|
18
19
|
require_relative 'helpers/constants'
|
19
20
|
require_relative 'helpers/validator'
|
21
|
+
require_relative 'semantic_version'
|
20
22
|
|
21
23
|
module Optimizely
|
22
24
|
class CustomAttributeConditionEvaluator
|
@@ -26,15 +28,29 @@ module Optimizely
|
|
26
28
|
EXACT_MATCH_TYPE = 'exact'
|
27
29
|
EXISTS_MATCH_TYPE = 'exists'
|
28
30
|
GREATER_THAN_MATCH_TYPE = 'gt'
|
31
|
+
GREATER_EQUAL_MATCH_TYPE = 'ge'
|
29
32
|
LESS_THAN_MATCH_TYPE = 'lt'
|
33
|
+
LESS_EQUAL_MATCH_TYPE = 'le'
|
30
34
|
SUBSTRING_MATCH_TYPE = 'substring'
|
35
|
+
SEMVER_EQ = 'semver_eq'
|
36
|
+
SEMVER_GE = 'semver_ge'
|
37
|
+
SEMVER_GT = 'semver_gt'
|
38
|
+
SEMVER_LE = 'semver_le'
|
39
|
+
SEMVER_LT = 'semver_lt'
|
31
40
|
|
32
41
|
EVALUATORS_BY_MATCH_TYPE = {
|
33
42
|
EXACT_MATCH_TYPE => :exact_evaluator,
|
34
43
|
EXISTS_MATCH_TYPE => :exists_evaluator,
|
35
44
|
GREATER_THAN_MATCH_TYPE => :greater_than_evaluator,
|
45
|
+
GREATER_EQUAL_MATCH_TYPE => :greater_than_or_equal_evaluator,
|
36
46
|
LESS_THAN_MATCH_TYPE => :less_than_evaluator,
|
37
|
-
|
47
|
+
LESS_EQUAL_MATCH_TYPE => :less_than_or_equal_evaluator,
|
48
|
+
SUBSTRING_MATCH_TYPE => :substring_evaluator,
|
49
|
+
SEMVER_EQ => :semver_equal_evaluator,
|
50
|
+
SEMVER_GE => :semver_greater_than_or_equal_evaluator,
|
51
|
+
SEMVER_GT => :semver_greater_than_evaluator,
|
52
|
+
SEMVER_LE => :semver_less_than_or_equal_evaluator,
|
53
|
+
SEMVER_LT => :semver_less_than_evaluator
|
38
54
|
}.freeze
|
39
55
|
|
40
56
|
attr_reader :user_attributes
|
@@ -95,7 +111,35 @@ module Optimizely
|
|
95
111
|
return nil
|
96
112
|
end
|
97
113
|
|
98
|
-
|
114
|
+
begin
|
115
|
+
send(EVALUATORS_BY_MATCH_TYPE[condition_match], leaf_condition)
|
116
|
+
rescue InvalidAttributeType
|
117
|
+
condition_name = leaf_condition['name']
|
118
|
+
user_value = @user_attributes[condition_name]
|
119
|
+
|
120
|
+
@logger.log(
|
121
|
+
Logger::WARN,
|
122
|
+
format(
|
123
|
+
Helpers::Constants::AUDIENCE_EVALUATION_LOGS['UNEXPECTED_TYPE'],
|
124
|
+
leaf_condition,
|
125
|
+
user_value.class,
|
126
|
+
condition_name
|
127
|
+
)
|
128
|
+
)
|
129
|
+
return nil
|
130
|
+
rescue InvalidSemanticVersion
|
131
|
+
condition_name = leaf_condition['name']
|
132
|
+
|
133
|
+
@logger.log(
|
134
|
+
Logger::WARN,
|
135
|
+
format(
|
136
|
+
Helpers::Constants::AUDIENCE_EVALUATION_LOGS['INVALID_SEMANTIC_VERSION'],
|
137
|
+
leaf_condition,
|
138
|
+
condition_name
|
139
|
+
)
|
140
|
+
)
|
141
|
+
return nil
|
142
|
+
end
|
99
143
|
end
|
100
144
|
|
101
145
|
def exact_evaluator(condition)
|
@@ -122,16 +166,7 @@ module Optimizely
|
|
122
166
|
|
123
167
|
if !value_type_valid_for_exact_conditions?(user_provided_value) ||
|
124
168
|
!Helpers::Validator.same_types?(condition_value, user_provided_value)
|
125
|
-
|
126
|
-
Logger::WARN,
|
127
|
-
format(
|
128
|
-
Helpers::Constants::AUDIENCE_EVALUATION_LOGS['UNEXPECTED_TYPE'],
|
129
|
-
condition,
|
130
|
-
user_provided_value.class,
|
131
|
-
condition['name']
|
132
|
-
)
|
133
|
-
)
|
134
|
-
return nil
|
169
|
+
raise InvalidAttributeType
|
135
170
|
end
|
136
171
|
|
137
172
|
if user_provided_value.is_a?(Numeric) && !Helpers::Validator.finite_number?(user_provided_value)
|
@@ -173,6 +208,20 @@ module Optimizely
|
|
173
208
|
user_provided_value > condition_value
|
174
209
|
end
|
175
210
|
|
211
|
+
def greater_than_or_equal_evaluator(condition)
|
212
|
+
# Evaluate the given greater than or equal match condition for the given user attributes.
|
213
|
+
# Returns boolean true if the user attribute value is greater than or equal to the condition value,
|
214
|
+
# false if the user attribute value is less than the condition value,
|
215
|
+
# nil if the condition value isn't a number or the user attribute value isn't a number.
|
216
|
+
|
217
|
+
condition_value = condition['value']
|
218
|
+
user_provided_value = @user_attributes[condition['name']]
|
219
|
+
|
220
|
+
return nil unless valid_numeric_values?(user_provided_value, condition_value, condition)
|
221
|
+
|
222
|
+
user_provided_value >= condition_value
|
223
|
+
end
|
224
|
+
|
176
225
|
def less_than_evaluator(condition)
|
177
226
|
# Evaluate the given less than match condition for the given user attributes.
|
178
227
|
# Returns boolean true if the user attribute value is less than the condition value,
|
@@ -187,6 +236,20 @@ module Optimizely
|
|
187
236
|
user_provided_value < condition_value
|
188
237
|
end
|
189
238
|
|
239
|
+
def less_than_or_equal_evaluator(condition)
|
240
|
+
# Evaluate the given less than or equal match condition for the given user attributes.
|
241
|
+
# Returns boolean true if the user attribute value is less than or equal to the condition value,
|
242
|
+
# false if the user attribute value is greater than the condition value,
|
243
|
+
# nil if the condition value isn't a number or the user attribute value isn't a number.
|
244
|
+
|
245
|
+
condition_value = condition['value']
|
246
|
+
user_provided_value = @user_attributes[condition['name']]
|
247
|
+
|
248
|
+
return nil unless valid_numeric_values?(user_provided_value, condition_value, condition)
|
249
|
+
|
250
|
+
user_provided_value <= condition_value
|
251
|
+
end
|
252
|
+
|
190
253
|
def substring_evaluator(condition)
|
191
254
|
# Evaluate the given substring match condition for the given user attributes.
|
192
255
|
# Returns boolean true if the condition value is a substring of the user attribute value,
|
@@ -204,22 +267,66 @@ module Optimizely
|
|
204
267
|
return nil
|
205
268
|
end
|
206
269
|
|
207
|
-
unless user_provided_value.is_a?(String)
|
208
|
-
@logger.log(
|
209
|
-
Logger::WARN,
|
210
|
-
format(
|
211
|
-
Helpers::Constants::AUDIENCE_EVALUATION_LOGS['UNEXPECTED_TYPE'],
|
212
|
-
condition,
|
213
|
-
user_provided_value.class,
|
214
|
-
condition['name']
|
215
|
-
)
|
216
|
-
)
|
217
|
-
return nil
|
218
|
-
end
|
270
|
+
raise InvalidAttributeType unless user_provided_value.is_a?(String)
|
219
271
|
|
220
272
|
user_provided_value.include? condition_value
|
221
273
|
end
|
222
274
|
|
275
|
+
def semver_equal_evaluator(condition)
|
276
|
+
# Evaluate the given semantic version equal match target version for the user version.
|
277
|
+
# Returns boolean true if the user version is equal to the target version,
|
278
|
+
# false if the user version is not equal to the target version
|
279
|
+
|
280
|
+
target_version = condition['value']
|
281
|
+
user_version = @user_attributes[condition['name']]
|
282
|
+
|
283
|
+
SemanticVersion.compare_user_version_with_target_version(target_version, user_version).zero?
|
284
|
+
end
|
285
|
+
|
286
|
+
def semver_greater_than_evaluator(condition)
|
287
|
+
# Evaluate the given semantic version greater than match target version for the user version.
|
288
|
+
# Returns boolean true if the user version is greater than the target version,
|
289
|
+
# false if the user version is less than or equal to the target version
|
290
|
+
|
291
|
+
target_version = condition['value']
|
292
|
+
user_version = @user_attributes[condition['name']]
|
293
|
+
|
294
|
+
SemanticVersion.compare_user_version_with_target_version(target_version, user_version).positive?
|
295
|
+
end
|
296
|
+
|
297
|
+
def semver_greater_than_or_equal_evaluator(condition)
|
298
|
+
# Evaluate the given semantic version greater than or equal to match target version for the user version.
|
299
|
+
# Returns boolean true if the user version is greater than or equal to the target version,
|
300
|
+
# false if the user version is less than the target version
|
301
|
+
|
302
|
+
target_version = condition['value']
|
303
|
+
user_version = @user_attributes[condition['name']]
|
304
|
+
|
305
|
+
SemanticVersion.compare_user_version_with_target_version(target_version, user_version) >= 0
|
306
|
+
end
|
307
|
+
|
308
|
+
def semver_less_than_evaluator(condition)
|
309
|
+
# Evaluate the given semantic version less than match target version for the user version.
|
310
|
+
# Returns boolean true if the user version is less than the target version,
|
311
|
+
# false if the user version is greater than or equal to the target version
|
312
|
+
|
313
|
+
target_version = condition['value']
|
314
|
+
user_version = @user_attributes[condition['name']]
|
315
|
+
|
316
|
+
SemanticVersion.compare_user_version_with_target_version(target_version, user_version).negative?
|
317
|
+
end
|
318
|
+
|
319
|
+
def semver_less_than_or_equal_evaluator(condition)
|
320
|
+
# Evaluate the given semantic version less than or equal to match target version for the user version.
|
321
|
+
# Returns boolean true if the user version is less than or equal to the target version,
|
322
|
+
# false if the user version is greater than the target version
|
323
|
+
|
324
|
+
target_version = condition['value']
|
325
|
+
user_version = @user_attributes[condition['name']]
|
326
|
+
|
327
|
+
SemanticVersion.compare_user_version_with_target_version(target_version, user_version) <= 0
|
328
|
+
end
|
329
|
+
|
223
330
|
private
|
224
331
|
|
225
332
|
def valid_numeric_values?(user_value, condition_value, condition)
|
@@ -234,18 +341,7 @@ module Optimizely
|
|
234
341
|
return false
|
235
342
|
end
|
236
343
|
|
237
|
-
unless user_value.is_a?(Numeric)
|
238
|
-
@logger.log(
|
239
|
-
Logger::WARN,
|
240
|
-
format(
|
241
|
-
Helpers::Constants::AUDIENCE_EVALUATION_LOGS['UNEXPECTED_TYPE'],
|
242
|
-
condition,
|
243
|
-
user_value.class,
|
244
|
-
condition['name']
|
245
|
-
)
|
246
|
-
)
|
247
|
-
return false
|
248
|
-
end
|
344
|
+
raise InvalidAttributeType unless user_value.is_a?(Numeric)
|
249
345
|
|
250
346
|
unless Helpers::Validator.finite_number?(user_value)
|
251
347
|
@logger.log(
|
@@ -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
|
@@ -1,7 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
#
|
4
|
-
# Copyright 2017-
|
4
|
+
# Copyright 2017-2020, Optimizely and contributors
|
5
5
|
#
|
6
6
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
7
|
# you may not use this file except in compliance with the License.
|
@@ -40,6 +40,7 @@ module Optimizely
|
|
40
40
|
Decision = Struct.new(:experiment, :variation, :source)
|
41
41
|
|
42
42
|
DECISION_SOURCES = {
|
43
|
+
'EXPERIMENT' => 'experiment',
|
43
44
|
'FEATURE_TEST' => 'feature-test',
|
44
45
|
'ROLLOUT' => 'rollout'
|
45
46
|
}.freeze
|
@@ -51,7 +52,7 @@ module Optimizely
|
|
51
52
|
@forced_variation_map = {}
|
52
53
|
end
|
53
54
|
|
54
|
-
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 = [])
|
55
56
|
# Determines variation into which user will be bucketed.
|
56
57
|
#
|
57
58
|
# project_config - project_config - Instance of ProjectConfig
|
@@ -62,56 +63,78 @@ module Optimizely
|
|
62
63
|
# Returns variation ID where visitor will be bucketed
|
63
64
|
# (nil if experiment is inactive or user does not meet audience conditions)
|
64
65
|
|
66
|
+
decide_reasons = []
|
65
67
|
# By default, the bucketing ID should be the user ID
|
66
|
-
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)
|
67
70
|
# Check to make sure experiment is active
|
68
71
|
experiment = project_config.get_experiment_from_key(experiment_key)
|
69
|
-
return nil if experiment.nil?
|
72
|
+
return nil, decide_reasons if experiment.nil?
|
70
73
|
|
71
74
|
experiment_id = experiment['id']
|
72
75
|
unless project_config.experiment_running?(experiment)
|
73
|
-
|
74
|
-
|
76
|
+
message = "Experiment '#{experiment_key}' is not running."
|
77
|
+
@logger.log(Logger::INFO, message)
|
78
|
+
decide_reasons.push(message)
|
79
|
+
return nil, decide_reasons
|
75
80
|
end
|
76
81
|
|
77
82
|
# Check if a forced variation is set for the user
|
78
|
-
forced_variation = get_forced_variation(project_config, experiment_key, user_id)
|
79
|
-
|
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
|
80
86
|
|
81
87
|
# Check if user is in a white-listed variation
|
82
|
-
whitelisted_variation_id = get_whitelisted_variation_id(project_config, experiment_key, user_id)
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
)
|
93
|
-
|
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
|
94
105
|
end
|
95
106
|
|
96
107
|
# Check audience conditions
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
)
|
102
|
-
|
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
|
103
115
|
end
|
104
116
|
|
105
117
|
# Bucket normally
|
106
|
-
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)
|
107
120
|
variation_id = variation ? variation['id'] : nil
|
108
121
|
|
122
|
+
message = ''
|
123
|
+
if variation_id
|
124
|
+
variation_key = variation['key']
|
125
|
+
message = "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_key}'."
|
126
|
+
else
|
127
|
+
message = "User '#{user_id}' is in no variation."
|
128
|
+
end
|
129
|
+
@logger.log(Logger::INFO, message)
|
130
|
+
decide_reasons.push(message)
|
131
|
+
|
109
132
|
# Persist bucketing decision
|
110
|
-
save_user_profile(user_profile, experiment_id, variation_id)
|
111
|
-
variation_id
|
133
|
+
save_user_profile(user_profile, experiment_id, variation_id) unless should_ignore_user_profile_service
|
134
|
+
[variation_id, decide_reasons]
|
112
135
|
end
|
113
136
|
|
114
|
-
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 = [])
|
115
138
|
# Get the variation the user is bucketed into for the given FeatureFlag.
|
116
139
|
#
|
117
140
|
# project_config - project_config - Instance of ProjectConfig
|
@@ -121,28 +144,20 @@ module Optimizely
|
|
121
144
|
#
|
122
145
|
# Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
|
123
146
|
|
147
|
+
decide_reasons = []
|
148
|
+
|
124
149
|
# check if the feature is being experiment on and whether the user is bucketed into the experiment
|
125
|
-
decision = get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes)
|
126
|
-
|
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?
|
127
153
|
|
128
|
-
|
129
|
-
|
130
|
-
if decision
|
131
|
-
@logger.log(
|
132
|
-
Logger::INFO,
|
133
|
-
"User '#{user_id}' is bucketed into a rollout for feature flag '#{feature_flag_key}'."
|
134
|
-
)
|
135
|
-
return decision
|
136
|
-
end
|
137
|
-
@logger.log(
|
138
|
-
Logger::INFO,
|
139
|
-
"User '#{user_id}' is not bucketed into a rollout for feature flag '#{feature_flag_key}'."
|
140
|
-
)
|
154
|
+
decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes)
|
155
|
+
decide_reasons.push(*reasons_received)
|
141
156
|
|
142
|
-
|
157
|
+
[decision, decide_reasons]
|
143
158
|
end
|
144
159
|
|
145
|
-
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 = [])
|
146
161
|
# Gets the variation the user is bucketed into for the feature flag's experiment.
|
147
162
|
#
|
148
163
|
# project_config - project_config - Instance of ProjectConfig
|
@@ -152,45 +167,41 @@ module Optimizely
|
|
152
167
|
#
|
153
168
|
# Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
|
154
169
|
# or nil if the user is not bucketed into any of the experiments on the feature
|
170
|
+
decide_reasons = []
|
155
171
|
feature_flag_key = feature_flag['key']
|
156
172
|
if feature_flag['experimentIds'].empty?
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
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
|
162
177
|
end
|
163
178
|
|
164
179
|
# Evaluate each experiment and return the first bucketed experiment variation
|
165
180
|
feature_flag['experimentIds'].each do |experiment_id|
|
166
181
|
experiment = project_config.experiment_id_map[experiment_id]
|
167
182
|
unless experiment
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
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
|
173
187
|
end
|
174
188
|
|
175
189
|
experiment_key = experiment['key']
|
176
|
-
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)
|
177
192
|
|
178
193
|
next unless variation_id
|
179
194
|
|
180
195
|
variation = project_config.variation_id_map[experiment_key][variation_id]
|
181
|
-
|
182
|
-
|
183
|
-
"The user '#{user_id}' is bucketed into experiment '#{experiment_key}' of feature '#{feature_flag_key}'."
|
184
|
-
)
|
185
|
-
return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST'])
|
196
|
+
|
197
|
+
return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST']), decide_reasons
|
186
198
|
end
|
187
199
|
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
)
|
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)
|
192
203
|
|
193
|
-
nil
|
204
|
+
[nil, decide_reasons]
|
194
205
|
end
|
195
206
|
|
196
207
|
def get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes = nil)
|
@@ -203,27 +214,27 @@ module Optimizely
|
|
203
214
|
# attributes - Hash representing user attributes
|
204
215
|
#
|
205
216
|
# Returns the Decision struct or nil if not bucketed into any of the targeting rules
|
206
|
-
|
217
|
+
decide_reasons = []
|
218
|
+
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
|
219
|
+
decide_reasons.push(*bucketing_id_reasons)
|
207
220
|
rollout_id = feature_flag['rolloutId']
|
208
221
|
if rollout_id.nil? || rollout_id.empty?
|
209
222
|
feature_flag_key = feature_flag['key']
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
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
|
215
227
|
end
|
216
228
|
|
217
229
|
rollout = project_config.get_rollout_from_id(rollout_id)
|
218
230
|
if rollout.nil?
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
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
|
224
235
|
end
|
225
236
|
|
226
|
-
return nil if rollout['experiments'].empty?
|
237
|
+
return nil, decide_reasons if rollout['experiments'].empty?
|
227
238
|
|
228
239
|
rollout_rules = rollout['experiments']
|
229
240
|
number_of_rules = rollout_rules.length - 1
|
@@ -231,44 +242,54 @@ module Optimizely
|
|
231
242
|
# Go through each experiment in order and try to get the variation for the user
|
232
243
|
number_of_rules.times do |index|
|
233
244
|
rollout_rule = rollout_rules[index]
|
234
|
-
|
235
|
-
audience = project_config.get_audience_from_id(audience_id)
|
236
|
-
audience_name = audience['name']
|
245
|
+
logging_key = index + 1
|
237
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)
|
238
249
|
# Check that user meets audience conditions for targeting rule
|
239
|
-
unless
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
)
|
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)
|
244
254
|
# move onto the next targeting rule
|
245
255
|
next
|
246
256
|
end
|
247
257
|
|
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)
|
261
|
+
|
248
262
|
# Evaluate if user satisfies the traffic allocation for this rollout rule
|
249
|
-
variation = @bucketer.bucket(project_config, rollout_rule, bucketing_id, user_id)
|
250
|
-
|
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?
|
251
266
|
|
252
267
|
break
|
253
268
|
end
|
254
269
|
|
255
270
|
# get last rule which is the everyone else rule
|
256
271
|
everyone_else_experiment = rollout_rules[number_of_rules]
|
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
|
-
Logger::DEBUG,
|
264
|
-
"User '#{user_id}' does not meet the conditions to be in rollout rule for audience '#{audience_name}'."
|
265
|
-
)
|
266
|
-
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
|
267
282
|
end
|
268
|
-
variation = @bucketer.bucket(project_config, everyone_else_experiment, bucketing_id, user_id)
|
269
|
-
return Decision.new(everyone_else_experiment, variation, DECISION_SOURCES['ROLLOUT']) unless variation.nil?
|
270
283
|
|
271
|
-
|
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)
|
287
|
+
|
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]
|
272
293
|
end
|
273
294
|
|
274
295
|
def set_forced_variation(project_config, experiment_key, user_id, variation_key)
|
@@ -318,9 +339,11 @@ module Optimizely
|
|
318
339
|
#
|
319
340
|
# Returns Variation The variation which the given user and experiment should be forced into
|
320
341
|
|
342
|
+
decide_reasons = []
|
321
343
|
unless @forced_variation_map.key? user_id
|
322
|
-
|
323
|
-
|
344
|
+
message = "User '#{user_id}' is not in the forced variation map."
|
345
|
+
@logger.log(Logger::DEBUG, message)
|
346
|
+
return nil, decide_reasons
|
324
347
|
end
|
325
348
|
|
326
349
|
experiment_to_variation_map = @forced_variation_map[user_id]
|
@@ -328,12 +351,13 @@ module Optimizely
|
|
328
351
|
experiment_id = experiment['id'] if experiment
|
329
352
|
# check for nil and empty string experiment ID
|
330
353
|
# this case is logged in get_experiment_from_key
|
331
|
-
return nil if experiment_id.nil? || experiment_id.empty?
|
354
|
+
return nil, decide_reasons if experiment_id.nil? || experiment_id.empty?
|
332
355
|
|
333
356
|
unless experiment_to_variation_map.key? experiment_id
|
334
|
-
|
335
|
-
|
336
|
-
|
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
|
337
361
|
end
|
338
362
|
|
339
363
|
variation_id = experiment_to_variation_map[experiment_id]
|
@@ -343,12 +367,13 @@ module Optimizely
|
|
343
367
|
|
344
368
|
# check if the variation exists in the datafile
|
345
369
|
# this case is logged in get_variation_from_id
|
346
|
-
return nil if variation_key.empty?
|
370
|
+
return nil, decide_reasons if variation_key.empty?
|
347
371
|
|
348
|
-
|
349
|
-
|
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)
|
350
375
|
|
351
|
-
variation
|
376
|
+
[variation, decide_reasons]
|
352
377
|
end
|
353
378
|
|
354
379
|
private
|
@@ -364,27 +389,24 @@ module Optimizely
|
|
364
389
|
|
365
390
|
whitelisted_variations = project_config.get_whitelisted_variations(experiment_key)
|
366
391
|
|
367
|
-
return nil unless whitelisted_variations
|
392
|
+
return nil, nil unless whitelisted_variations
|
368
393
|
|
369
394
|
whitelisted_variation_key = whitelisted_variations[user_id]
|
370
395
|
|
371
|
-
return nil unless whitelisted_variation_key
|
396
|
+
return nil, nil unless whitelisted_variation_key
|
372
397
|
|
373
398
|
whitelisted_variation_id = project_config.get_variation_id_from_key(experiment_key, whitelisted_variation_key)
|
374
399
|
|
375
400
|
unless whitelisted_variation_id
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
)
|
380
|
-
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
|
381
404
|
end
|
382
405
|
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
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]
|
388
410
|
end
|
389
411
|
|
390
412
|
def get_saved_variation_id(project_config, experiment_id, user_profile)
|
@@ -395,19 +417,18 @@ module Optimizely
|
|
395
417
|
# user_profile - Hash user profile
|
396
418
|
#
|
397
419
|
# Returns string variation ID (nil if no decision is found)
|
398
|
-
return nil unless user_profile[:experiment_bucket_map]
|
420
|
+
return nil, nil unless user_profile[:experiment_bucket_map]
|
399
421
|
|
400
422
|
decision = user_profile[:experiment_bucket_map][experiment_id]
|
401
|
-
return nil unless decision
|
423
|
+
return nil, nil unless decision
|
402
424
|
|
403
425
|
variation_id = decision[:variation_id]
|
404
|
-
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)
|
405
430
|
|
406
|
-
|
407
|
-
Logger::INFO,
|
408
|
-
"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."
|
409
|
-
)
|
410
|
-
nil
|
431
|
+
[nil, message]
|
411
432
|
end
|
412
433
|
|
413
434
|
def get_user_profile(user_id)
|
@@ -422,15 +443,17 @@ module Optimizely
|
|
422
443
|
experiment_bucket_map: {}
|
423
444
|
}
|
424
445
|
|
425
|
-
return user_profile unless @user_profile_service
|
446
|
+
return user_profile, nil unless @user_profile_service
|
426
447
|
|
448
|
+
message = nil
|
427
449
|
begin
|
428
450
|
user_profile = @user_profile_service.lookup(user_id) || user_profile
|
429
451
|
rescue => e
|
430
|
-
|
452
|
+
message = "Error while looking up user profile for user ID '#{user_id}': #{e}."
|
453
|
+
@logger.log(Logger::ERROR, message)
|
431
454
|
end
|
432
455
|
|
433
|
-
user_profile
|
456
|
+
[user_profile, message]
|
434
457
|
end
|
435
458
|
|
436
459
|
def save_user_profile(user_profile, experiment_id, variation_id)
|
@@ -461,16 +484,17 @@ module Optimizely
|
|
461
484
|
# attributes - Hash user attributes
|
462
485
|
# Returns String representing bucketing ID if it is a String type in attributes else return user ID
|
463
486
|
|
464
|
-
return user_id unless attributes
|
487
|
+
return user_id, nil unless attributes
|
465
488
|
|
466
489
|
bucketing_id = attributes[Optimizely::Helpers::Constants::CONTROL_ATTRIBUTES['BUCKETING_ID']]
|
467
490
|
|
468
491
|
if bucketing_id
|
469
|
-
return bucketing_id if bucketing_id.is_a?(String)
|
492
|
+
return bucketing_id, nil if bucketing_id.is_a?(String)
|
470
493
|
|
471
|
-
|
494
|
+
message = 'Bucketing ID attribute is not a string. Defaulted to user ID.'
|
495
|
+
@logger.log(Logger::WARN, message)
|
472
496
|
end
|
473
|
-
user_id
|
497
|
+
[user_id, message]
|
474
498
|
end
|
475
499
|
end
|
476
500
|
end
|