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.
@@ -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