optimizely-sdk 3.4.0 → 3.8.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
- SUBSTRING_MATCH_TYPE => :substring_evaluator
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
- send(EVALUATORS_BY_MATCH_TYPE[condition_match], leaf_condition)
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
- @logger.log(
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-2019, Optimizely and contributors
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
- @logger.log(Logger::INFO, "Experiment '#{experiment_key}' is not running.")
74
- return nil
76
+ message = "Experiment '#{experiment_key}' is not running."
77
+ @logger.log(Logger::INFO, message)
78
+ decide_reasons.push(message)
79
+ return nil, decide_reasons
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
- return forced_variation['id'] if forced_variation
83
+ forced_variation, reasons_received = get_forced_variation(project_config, experiment_key, user_id)
84
+ decide_reasons.push(*reasons_received)
85
+ return forced_variation['id'], decide_reasons if forced_variation
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
- return whitelisted_variation_id if whitelisted_variation_id
84
-
85
- # Check for saved bucketing decisions
86
- user_profile = get_user_profile(user_id)
87
- saved_variation_id = get_saved_variation_id(project_config, experiment_id, user_profile)
88
- if saved_variation_id
89
- @logger.log(
90
- Logger::INFO,
91
- "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
92
- )
93
- return saved_variation_id
88
+ whitelisted_variation_id, reasons_received = get_whitelisted_variation_id(project_config, experiment_key, user_id)
89
+ decide_reasons.push(*reasons_received)
90
+ return whitelisted_variation_id, decide_reasons if whitelisted_variation_id
91
+
92
+ should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
93
+ # Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService
94
+ unless should_ignore_user_profile_service
95
+ user_profile, reasons_received = get_user_profile(user_id)
96
+ decide_reasons.push(*reasons_received)
97
+ saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile)
98
+ decide_reasons.push(*reasons_received)
99
+ if saved_variation_id
100
+ message = "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
101
+ @logger.log(Logger::INFO, message)
102
+ decide_reasons.push(message)
103
+ return saved_variation_id, decide_reasons
104
+ end
94
105
  end
95
106
 
96
107
  # Check audience conditions
97
- unless Audience.user_in_experiment?(project_config, experiment, attributes, @logger)
98
- @logger.log(
99
- Logger::INFO,
100
- "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
101
- )
102
- return nil
108
+ user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, experiment, attributes, @logger)
109
+ decide_reasons.push(*reasons_received)
110
+ unless user_meets_audience_conditions
111
+ message = "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
112
+ @logger.log(Logger::INFO, message)
113
+ decide_reasons.push(message)
114
+ return nil, decide_reasons
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
- return decision unless decision.nil?
150
+ decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes, decide_options)
151
+ decide_reasons.push(*reasons_received)
152
+ return decision, decide_reasons unless decision.nil?
127
153
 
128
- feature_flag_key = feature_flag['key']
129
- decision = get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes)
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
- nil
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
- @logger.log(
158
- Logger::DEBUG,
159
- "The feature flag '#{feature_flag_key}' is not used in any experiments."
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
- @logger.log(
169
- Logger::DEBUG,
170
- "Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
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
- @logger.log(
182
- Logger::INFO,
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
- @logger.log(
189
- Logger::INFO,
190
- "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
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
- bucketing_id = get_bucketing_id(user_id, attributes)
217
+ decide_reasons = []
218
+ bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
219
+ decide_reasons.push(*bucketing_id_reasons)
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
- @logger.log(
211
- Logger::DEBUG,
212
- "Feature flag '#{feature_flag_key}' is not used in a rollout."
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
- @logger.log(
220
- Logger::DEBUG,
221
- "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'"
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
- audience_id = rollout_rule['audienceIds'][0]
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 Audience.user_in_experiment?(project_config, rollout_rule, attributes, @logger)
240
- @logger.log(
241
- Logger::DEBUG,
242
- "User '#{user_id}' does not meet the conditions to be in rollout rule for audience '#{audience_name}'."
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
- return Decision.new(rollout_rule, variation, DECISION_SOURCES['ROLLOUT']) unless variation.nil?
263
+ variation, bucket_reasons = @bucketer.bucket(project_config, rollout_rule, bucketing_id, user_id)
264
+ decide_reasons.push(*bucket_reasons)
265
+ return Decision.new(rollout_rule, variation, DECISION_SOURCES['ROLLOUT']), decide_reasons unless variation.nil?
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 Audience.user_in_experiment?(project_config, everyone_else_experiment, attributes, @logger)
259
- audience_id = everyone_else_experiment['audienceIds'][0]
260
- audience = project_config.get_audience_from_id(audience_id)
261
- audience_name = audience['name']
262
- @logger.log(
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
- 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)
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
- @logger.log(Logger::DEBUG, "User '#{user_id}' is not in the forced variation map.")
323
- return nil
344
+ message = "User '#{user_id}' is not in the forced variation map."
345
+ @logger.log(Logger::DEBUG, message)
346
+ return nil, decide_reasons
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
- @logger.log(Logger::DEBUG, "No experiment '#{experiment_key}' mapped to user '#{user_id}' "\
335
- 'in the forced variation map.')
336
- return nil
357
+ message = "No experiment '#{experiment_key}' mapped to user '#{user_id}' in the forced variation map."
358
+ @logger.log(Logger::DEBUG, message)
359
+ decide_reasons.push(message)
360
+ return nil, decide_reasons
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
- @logger.log(Logger::DEBUG, "Variation '#{variation_key}' is mapped to experiment '#{experiment_key}' "\
349
- "and user '#{user_id}' in the forced variation map")
372
+ message = "Variation '#{variation_key}' is mapped to experiment '#{experiment_key}' and user '#{user_id}' in the forced variation map"
373
+ @logger.log(Logger::DEBUG, message)
374
+ decide_reasons.push(message)
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
- @logger.log(
377
- Logger::INFO,
378
- "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile."
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
- @logger.log(
384
- Logger::INFO,
385
- "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'."
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
- @logger.log(
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
- @logger.log(Logger::ERROR, "Error while looking up user profile for user ID '#{user_id}': #{e}.")
452
+ message = "Error while looking up user profile for user ID '#{user_id}': #{e}."
453
+ @logger.log(Logger::ERROR, message)
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
- @logger.log(Logger::WARN, 'Bucketing ID attribute is not a string. Defaulted to user ID.')
494
+ message = 'Bucketing ID attribute is not a string. Defaulted to user ID.'
495
+ @logger.log(Logger::WARN, message)
472
496
  end
473
- user_id
497
+ [user_id, message]
474
498
  end
475
499
  end
476
500
  end