optimizely-sdk 3.8.1 → 3.10.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5751701cecb265d27a62c72b268fcb01bb5a37f8696f97a6f56ad29776b226cc
4
- data.tar.gz: 32533f70bdd2861e2250b3c1b8e274d61f04395dd4ff5cf7ff0776d5f49c2a90
3
+ metadata.gz: f6a2a4abf7984fbba2d78c4c8cf39c39b66d3cd9e2d381b5d0e8ab0cc9ea0324
4
+ data.tar.gz: 62e9a6d0a44a5d83fcb4f574905cd64873c53d052503d135df6eb83b93c91b41
5
5
  SHA512:
6
- metadata.gz: f7258d91de18c854b91f1d938c4624bf227307a2f01aec82aa23d1f2978ca8510209171f4dc62a3d2dcc606784a741dd9f6572d7c6a81475e2b791e70b7e45be
7
- data.tar.gz: 1471cf812fbebc51fe2207e599f44b60597c3e6e15209af400d4ecabe0333bcd285269b3b421ad80f043c33012aecc54d3d41e59c53dffe64b09b93039b36254
6
+ metadata.gz: 807fe5ee0c7ab987ca2e273ea340aace86170295b0746f69b4ca5fb8e4d389093a43f1a8d625efd2e55c29960baa7dbc3b747111bd98bcf7e1e011da753cafff
7
+ data.tar.gz: 44fa786ddcc047608c8b3cac34c01c7f15822f9f33db0fd9cadedc2b4440a3b6b52177e94708223ae5e16d4ae5b46553e3c1820bb9b083dfcaee889e64ea3e8f
@@ -28,6 +28,8 @@ module Optimizely
28
28
  NOT_CONDITION => :not_evaluator
29
29
  }.freeze
30
30
 
31
+ OPERATORS = [AND_CONDITION, OR_CONDITION, NOT_CONDITION].freeze
32
+
31
33
  module_function
32
34
 
33
35
  def evaluate(conditions, leaf_evaluator)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright 2019-2020, Optimizely and contributors
3
+ # Copyright 2019-2021, Optimizely and contributors
4
4
  #
5
5
  # Licensed under the Apache License, Version 2.0 (the "License");
6
6
  # you may not use this file except in compliance with the License.
@@ -38,6 +38,8 @@ module Optimizely
38
38
  attr_reader :anonymize_ip
39
39
  attr_reader :bot_filtering
40
40
  attr_reader :revision
41
+ attr_reader :sdk_key
42
+ attr_reader :environment_key
41
43
  attr_reader :rollouts
42
44
  attr_reader :version
43
45
  attr_reader :send_flag_decisions
@@ -58,6 +60,7 @@ module Optimizely
58
60
  attr_reader :variation_key_map
59
61
  attr_reader :variation_id_map_by_experiment_id
60
62
  attr_reader :variation_key_map_by_experiment_id
63
+ attr_reader :flag_variation_map
61
64
 
62
65
  def initialize(datafile, logger, error_handler)
63
66
  # ProjectConfig init method to fetch and set project config data
@@ -85,6 +88,8 @@ module Optimizely
85
88
  @anonymize_ip = config.key?('anonymizeIP') ? config['anonymizeIP'] : false
86
89
  @bot_filtering = config['botFiltering']
87
90
  @revision = config['revision']
91
+ @sdk_key = config.fetch('sdkKey', '')
92
+ @environment_key = config.fetch('environmentKey', '')
88
93
  @rollouts = config.fetch('rollouts', [])
89
94
  @send_flag_decisions = config.fetch('sendFlagDecisions', false)
90
95
 
@@ -119,6 +124,8 @@ module Optimizely
119
124
  @variation_key_map_by_experiment_id = {}
120
125
  @variation_id_to_variable_usage_map = {}
121
126
  @variation_id_to_experiment_map = {}
127
+ @flag_variation_map = {}
128
+
122
129
  @experiment_id_map.each_value do |exp|
123
130
  # Excludes experiments from rollouts
124
131
  variations = exp.fetch('variations')
@@ -134,6 +141,8 @@ module Optimizely
134
141
  exps = rollout.fetch('experiments')
135
142
  @rollout_experiment_id_map = @rollout_experiment_id_map.merge(generate_key_map(exps, 'id'))
136
143
  end
144
+
145
+ @flag_variation_map = generate_feature_variation_map(@feature_flags)
137
146
  @all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map)
138
147
  @all_experiments.each do |id, exp|
139
148
  variations = exp.fetch('variations')
@@ -161,6 +170,24 @@ module Optimizely
161
170
  end
162
171
  end
163
172
 
173
+ def get_rules_for_flag(feature_flag)
174
+ # Retrieves rules for a given feature flag
175
+ #
176
+ # feature_flag - String key representing the feature_flag
177
+ #
178
+ # Returns rules in feature flag
179
+ rules = feature_flag['experimentIds'].map { |exp_id| @experiment_id_map[exp_id] }
180
+ rollout = feature_flag['rolloutId'].empty? ? nil : @rollout_id_map[feature_flag['rolloutId']]
181
+
182
+ if rollout
183
+ rollout_experiments = rollout.fetch('experiments')
184
+ rollout_experiments.each do |exp|
185
+ rules.push(exp)
186
+ end
187
+ end
188
+ rules
189
+ end
190
+
164
191
  def self.create(datafile, logger, error_handler, skip_json_validation)
165
192
  # Looks up and sets datafile and config based on response body.
166
193
  #
@@ -275,6 +302,13 @@ module Optimizely
275
302
  nil
276
303
  end
277
304
 
305
+ def get_variation_from_flag(flag_key, target_value, attribute)
306
+ variations = @flag_variation_map[flag_key]
307
+ return variations.select { |variation| variation[attribute] == target_value }.first if variations
308
+
309
+ nil
310
+ end
311
+
278
312
  def get_variation_from_id(experiment_key, variation_id)
279
313
  # Get variation given experiment key and variation ID
280
314
  #
@@ -478,8 +512,32 @@ module Optimizely
478
512
  @experiment_feature_map.key?(experiment_id)
479
513
  end
480
514
 
515
+ def rollout_experiment?(experiment_id)
516
+ # Determines if given experiment is a rollout test.
517
+ #
518
+ # experiment_id - String experiment ID
519
+ #
520
+ # Returns true if experiment belongs to any rollout,
521
+ # false otherwise.
522
+ @rollout_experiment_id_map.key?(experiment_id)
523
+ end
524
+
481
525
  private
482
526
 
527
+ def generate_feature_variation_map(feature_flags)
528
+ flag_variation_map = {}
529
+ feature_flags.each do |flag|
530
+ variations = []
531
+ get_rules_for_flag(flag).each do |rule|
532
+ rule['variations'].each do |rule_variation|
533
+ variations.push(rule_variation) if variations.select { |variation| variation['id'] == rule_variation['id'] }.empty?
534
+ end
535
+ end
536
+ flag_variation_map[flag['key']] = variations
537
+ end
538
+ flag_variation_map
539
+ end
540
+
483
541
  def generate_key_map(array, key)
484
542
  # Helper method to generate map from key to hash in array of hashes
485
543
  #
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2019-2020, Optimizely and contributors
4
+ # Copyright 2019-2020, 2022, 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.
@@ -33,7 +33,7 @@ module Optimizely
33
33
  class HTTPProjectConfigManager < ProjectConfigManager
34
34
  # Config manager that polls for the datafile and updated ProjectConfig based on an update interval.
35
35
 
36
- attr_reader :stopped, :optimizely_config
36
+ attr_reader :stopped
37
37
 
38
38
  # Initialize config manager. One of sdk_key or url has to be set to be able to use.
39
39
  #
@@ -80,8 +80,8 @@ module Optimizely
80
80
  @last_modified = nil
81
81
  @skip_json_validation = skip_json_validation
82
82
  @notification_center = notification_center.is_a?(Optimizely::NotificationCenter) ? notification_center : NotificationCenter.new(@logger, @error_handler)
83
+ @optimizely_config = nil
83
84
  @config = datafile.nil? ? nil : DatafileProjectConfig.create(datafile, @logger, @error_handler, @skip_json_validation)
84
- @optimizely_config = @config.nil? ? nil : OptimizelyConfig.new(@config).config
85
85
  @mutex = Mutex.new
86
86
  @resource = ConditionVariable.new
87
87
  @async_scheduler = AsyncScheduler.new(method(:fetch_datafile_config), @polling_interval, auto_update, @logger)
@@ -140,6 +140,12 @@ module Optimizely
140
140
  @config
141
141
  end
142
142
 
143
+ def optimizely_config
144
+ @optimizely_config = OptimizelyConfig.new(@config).config if @optimizely_config.nil?
145
+
146
+ @optimizely_config
147
+ end
148
+
143
149
  private
144
150
 
145
151
  def fetch_datafile_config
@@ -209,7 +215,9 @@ module Optimizely
209
215
  end
210
216
 
211
217
  @config = config
212
- @optimizely_config = OptimizelyConfig.new(config).config
218
+
219
+ # clearing old optimizely config so that a fresh one is generated on the next api call.
220
+ @optimizely_config = nil
213
221
 
214
222
  @notification_center.send_notifications(NotificationCenter::NOTIFICATION_TYPES[:OPTIMIZELY_CONFIG_UPDATE])
215
223
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2019-2020, Optimizely and contributors
4
+ # Copyright 2019-2020, 2022, 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.
@@ -23,7 +23,7 @@ require_relative 'project_config_manager'
23
23
  module Optimizely
24
24
  class StaticProjectConfigManager < ProjectConfigManager
25
25
  # Implementation of ProjectConfigManager interface.
26
- attr_reader :config, :optimizely_config
26
+ attr_reader :config
27
27
 
28
28
  def initialize(datafile, logger, error_handler, skip_json_validation)
29
29
  # Looks up and sets datafile and config based on response body.
@@ -40,8 +40,13 @@ module Optimizely
40
40
  error_handler,
41
41
  skip_json_validation
42
42
  )
43
+ @optimizely_config = nil
44
+ end
45
+
46
+ def optimizely_config
47
+ @optimizely_config = OptimizelyConfig.new(@config).config if @optimizely_config.nil?
43
48
 
44
- @optimizely_config = @config.nil? ? nil : OptimizelyConfig.new(@config).config
49
+ @optimizely_config
45
50
  end
46
51
  end
47
52
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2017-2021, Optimizely and contributors
4
+ # Copyright 2017-2022, 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.
@@ -52,18 +52,19 @@ module Optimizely
52
52
  @forced_variation_map = {}
53
53
  end
54
54
 
55
- def get_variation(project_config, experiment_id, user_id, attributes = nil, decide_options = [])
55
+ def get_variation(project_config, experiment_id, user_context, decide_options = [])
56
56
  # Determines variation into which user will be bucketed.
57
57
  #
58
58
  # project_config - project_config - Instance of ProjectConfig
59
59
  # experiment_id - Experiment for which visitor variation needs to be determined
60
- # user_id - String ID for user
61
- # attributes - Hash representing user attributes
60
+ # user_context - Optimizely user context instance
62
61
  #
63
62
  # Returns variation ID where visitor will be bucketed
64
63
  # (nil if experiment is inactive or user does not meet audience conditions)
65
64
 
66
65
  decide_reasons = []
66
+ user_id = user_context.user_id
67
+ attributes = user_context.user_attributes
67
68
  # By default, the bucketing ID should be the user ID
68
69
  bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
69
70
  decide_reasons.push(*bucketing_id_reasons)
@@ -134,40 +135,39 @@ module Optimizely
134
135
  [variation_id, decide_reasons]
135
136
  end
136
137
 
137
- def get_variation_for_feature(project_config, feature_flag, user_id, attributes = nil, decide_options = [])
138
+ def get_variation_for_feature(project_config, feature_flag, user_context, decide_options = [])
138
139
  # Get the variation the user is bucketed into for the given FeatureFlag.
139
140
  #
140
141
  # project_config - project_config - Instance of ProjectConfig
141
142
  # feature_flag - The feature flag the user wants to access
142
- # user_id - String ID for the user
143
- # attributes - Hash representing user attributes
143
+ # user_context - Optimizely user context instance
144
144
  #
145
145
  # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
146
146
 
147
147
  decide_reasons = []
148
148
 
149
149
  # check if the feature is being experiment on and whether the user is bucketed into the experiment
150
- decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes, decide_options)
150
+ decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options)
151
151
  decide_reasons.push(*reasons_received)
152
152
  return decision, decide_reasons unless decision.nil?
153
153
 
154
- decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes)
154
+ decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
155
155
  decide_reasons.push(*reasons_received)
156
156
 
157
157
  [decision, decide_reasons]
158
158
  end
159
159
 
160
- def get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes = nil, decide_options = [])
160
+ def get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options = [])
161
161
  # Gets the variation the user is bucketed into for the feature flag's experiment.
162
162
  #
163
163
  # project_config - project_config - Instance of ProjectConfig
164
164
  # feature_flag - The feature flag the user wants to access
165
- # user_id - String ID for the user
166
- # attributes - Hash representing user attributes
165
+ # user_context - Optimizely user context instance
167
166
  #
168
167
  # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
169
168
  # or nil if the user is not bucketed into any of the experiments on the feature
170
169
  decide_reasons = []
170
+ user_id = user_context.user_id
171
171
  feature_flag_key = feature_flag['key']
172
172
  if feature_flag['experimentIds'].empty?
173
173
  message = "The feature flag '#{feature_flag_key}' is not used in any experiments."
@@ -187,12 +187,13 @@ module Optimizely
187
187
  end
188
188
 
189
189
  experiment_id = experiment['id']
190
- variation_id, reasons_received = get_variation(project_config, experiment_id, user_id, attributes, decide_options)
190
+ variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, decide_options)
191
191
  decide_reasons.push(*reasons_received)
192
192
 
193
193
  next unless variation_id
194
194
 
195
195
  variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
196
+ variation = project_config.get_variation_from_flag(feature_flag['key'], variation_id, 'id') if variation.nil?
196
197
 
197
198
  return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST']), decide_reasons
198
199
  end
@@ -204,22 +205,20 @@ module Optimizely
204
205
  [nil, decide_reasons]
205
206
  end
206
207
 
207
- def get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes = nil)
208
+ def get_variation_for_feature_rollout(project_config, feature_flag, user_context)
208
209
  # Determine which variation the user is in for a given rollout.
209
210
  # Returns the variation of the first experiment the user qualifies for.
210
211
  #
211
212
  # project_config - project_config - Instance of ProjectConfig
212
213
  # feature_flag - The feature flag the user wants to access
213
- # user_id - String ID for the user
214
- # attributes - Hash representing user attributes
214
+ # user_context - Optimizely user context instance
215
215
  #
216
216
  # Returns the Decision struct or nil if not bucketed into any of the targeting rules
217
217
  decide_reasons = []
218
- bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
219
- decide_reasons.push(*bucketing_id_reasons)
218
+
220
219
  rollout_id = feature_flag['rolloutId']
220
+ feature_flag_key = feature_flag['key']
221
221
  if rollout_id.nil? || rollout_id.empty?
222
- feature_flag_key = feature_flag['key']
223
222
  message = "Feature flag '#{feature_flag_key}' is not used in a rollout."
224
223
  @logger.log(Logger::DEBUG, message)
225
224
  decide_reasons.push(message)
@@ -236,60 +235,102 @@ module Optimizely
236
235
 
237
236
  return nil, decide_reasons if rollout['experiments'].empty?
238
237
 
238
+ index = 0
239
239
  rollout_rules = rollout['experiments']
240
- number_of_rules = rollout_rules.length - 1
241
-
242
- # Go through each experiment in order and try to get the variation for the user
243
- number_of_rules.times do |index|
244
- rollout_rule = rollout_rules[index]
245
- logging_key = index + 1
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)
240
+ while index < rollout_rules.length
241
+ variation, skip_to_everyone_else, reasons_received = get_variation_from_delivery_rule(project_config, feature_flag_key, rollout_rules, index, user_context)
248
242
  decide_reasons.push(*reasons_received)
249
- # Check that user meets audience conditions for targeting rule
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)
254
- # move onto the next targeting rule
255
- next
243
+ if variation
244
+ rule = rollout_rules[index]
245
+ feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'])
246
+ return [feature_decision, decide_reasons]
256
247
  end
257
248
 
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)
249
+ index = skip_to_everyone_else ? (rollout_rules.length - 1) : (index + 1)
250
+ end
261
251
 
262
- # Evaluate if user satisfies the traffic allocation for this rollout rule
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?
252
+ [nil, decide_reasons]
253
+ end
266
254
 
267
- break
268
- end
255
+ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, options = [])
256
+ # Determine which variation the user is in for a given rollout.
257
+ # Returns the variation from experiment rules.
258
+ #
259
+ # project_config - project_config - Instance of ProjectConfig
260
+ # flag_key - The feature flag the user wants to access
261
+ # rule - An experiment rule key
262
+ # user - Optimizely user context instance
263
+ #
264
+ # Returns variation_id and reasons
265
+ reasons = []
269
266
 
270
- # get last rule which is the everyone else rule
271
- everyone_else_experiment = rollout_rules[number_of_rules]
272
- logging_key = 'Everyone Else'
267
+ context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
268
+ variation, forced_reasons = validated_forced_decision(project_config, context, user)
269
+ reasons.push(*forced_reasons)
273
270
 
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)
276
- # Check that user meets audience conditions for last rule
271
+ return [variation['id'], reasons] if variation
272
+
273
+ variation_id, response_reasons = get_variation(project_config, rule['id'], user, options)
274
+ reasons.push(*response_reasons)
275
+
276
+ [variation_id, reasons]
277
+ end
278
+
279
+ def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user)
280
+ # Determine which variation the user is in for a given rollout.
281
+ # Returns the variation from delivery rules.
282
+ #
283
+ # project_config - project_config - Instance of ProjectConfig
284
+ # flag_key - The feature flag the user wants to access
285
+ # rule - An experiment rule key
286
+ # user - Optimizely user context instance
287
+ #
288
+ # Returns variation, boolean to skip for eveyone else rule and reasons
289
+ reasons = []
290
+ skip_to_everyone_else = false
291
+ rule = rules[rule_index]
292
+ context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
293
+ variation, forced_reasons = validated_forced_decision(project_config, context, user)
294
+ reasons.push(*forced_reasons)
295
+
296
+ return [variation, skip_to_everyone_else, reasons] if variation
297
+
298
+ user_id = user.user_id
299
+ attributes = user.user_attributes
300
+ bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
301
+ reasons.push(*bucketing_id_reasons)
302
+
303
+ everyone_else = (rule_index == rules.length - 1)
304
+
305
+ logging_key = everyone_else ? 'Everyone Else' : (rule_index + 1).to_s
306
+
307
+ user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, rule, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
308
+ reasons.push(*reasons_received)
277
309
  unless user_meets_audience_conditions
278
- message = "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'."
310
+ message = "User '#{user_id}' does not meet the conditions for targeting rule '#{logging_key}'."
279
311
  @logger.log(Logger::DEBUG, message)
280
- decide_reasons.push(message)
281
- return nil, decide_reasons
312
+ reasons.push(message)
313
+ return [nil, skip_to_everyone_else, reasons]
282
314
  end
283
315
 
284
316
  message = "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
285
317
  @logger.log(Logger::DEBUG, message)
286
- decide_reasons.push(message)
318
+ reasons.push(message)
319
+ bucket_variation, bucket_reasons = @bucketer.bucket(project_config, rule, bucketing_id, user_id)
287
320
 
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?
321
+ reasons.push(*bucket_reasons)
291
322
 
292
- [nil, decide_reasons]
323
+ if bucket_variation
324
+ message = "User '#{user_id}' is in the traffic group of targeting rule '#{logging_key}'."
325
+ @logger.log(Logger::DEBUG, message)
326
+ reasons.push(message)
327
+ elsif !everyone_else
328
+ message = "User '#{user_id}' is not in the traffic group for targeting rule '#{logging_key}'."
329
+ @logger.log(Logger::DEBUG, message)
330
+ reasons.push(message)
331
+ skip_to_everyone_else = true
332
+ end
333
+ [bucket_variation, skip_to_everyone_else, reasons]
293
334
  end
294
335
 
295
336
  def set_forced_variation(project_config, experiment_key, user_id, variation_key)
@@ -376,6 +417,28 @@ module Optimizely
376
417
  [variation, decide_reasons]
377
418
  end
378
419
 
420
+ def validated_forced_decision(project_config, context, user_context)
421
+ decision = user_context.get_forced_decision(context)
422
+ flag_key = context[:flag_key]
423
+ rule_key = context[:rule_key]
424
+ variation_key = decision ? decision[:variation_key] : decision
425
+ reasons = []
426
+ target = rule_key ? "flag (#{flag_key}), rule (#{rule_key})" : "flag (#{flag_key})"
427
+ if variation_key
428
+ variation = project_config.get_variation_from_flag(flag_key, variation_key, 'key')
429
+ if variation
430
+ reason = "Variation (#{variation_key}) is mapped to #{target} and user (#{user_context.user_id}) in the forced decision map."
431
+ reasons.push(reason)
432
+ return variation, reasons
433
+ else
434
+ reason = "Invalid variation is mapped to #{target} and user (#{user_context.user_id}) in the forced decision map."
435
+ reasons.push(reason)
436
+ end
437
+ end
438
+
439
+ [nil, reasons]
440
+ end
441
+
379
442
  private
380
443
 
381
444
  def get_whitelisted_variation_id(project_config, experiment_id, user_id)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright 2019-2020, Optimizely and contributors
3
+ # Copyright 2019-2021, Optimizely and contributors
4
4
  #
5
5
  # Licensed under the Apache License, Version 2.0 (the "License");
6
6
  # you may not use this file except in compliance with the License.
@@ -16,53 +16,109 @@
16
16
  #
17
17
 
18
18
  module Optimizely
19
+ require 'json'
19
20
  class OptimizelyConfig
21
+ include Optimizely::ConditionTreeEvaluator
20
22
  def initialize(project_config)
21
23
  @project_config = project_config
24
+ @rollouts = @project_config.rollouts
25
+ @audiences = []
26
+ audience_id_lookup_dict = {}
27
+
28
+ @project_config.typed_audiences.each do |typed_audience|
29
+ @audiences.push(
30
+ 'id' => typed_audience['id'],
31
+ 'name' => typed_audience['name'],
32
+ 'conditions' => typed_audience['conditions'].to_json
33
+ )
34
+ audience_id_lookup_dict[typed_audience['id']] = typed_audience['id']
35
+ end
36
+
37
+ @project_config.audiences.each do |audience|
38
+ next unless !audience_id_lookup_dict.key?(audience['id']) && (audience['id'] != '$opt_dummy_audience')
39
+
40
+ @audiences.push(
41
+ 'id' => audience['id'],
42
+ 'name' => audience['name'],
43
+ 'conditions' => audience['conditions']
44
+ )
45
+ end
22
46
  end
23
47
 
24
48
  def config
25
49
  experiments_map_object = experiments_map
26
- features_map = get_features_map(experiments_map_object)
27
- {
50
+ features_map = get_features_map(experiments_id_map)
51
+ config = {
52
+ 'sdkKey' => @project_config.sdk_key,
28
53
  'datafile' => @project_config.datafile,
54
+ # This experimentsMap is for experiments of legacy projects only.
55
+ # For flag projects, experiment keys are not guaranteed to be unique
56
+ # across multiple flags, so this map may not include all experiments
57
+ # when keys conflict. Use experimentRules and deliveryRules instead.
29
58
  'experimentsMap' => experiments_map_object,
30
59
  'featuresMap' => features_map,
31
- 'revision' => @project_config.revision
60
+ 'revision' => @project_config.revision,
61
+ 'attributes' => get_attributes_list(@project_config.attributes),
62
+ 'audiences' => @audiences,
63
+ 'events' => get_events_list(@project_config.events),
64
+ 'environmentKey' => @project_config.environment_key
32
65
  }
66
+ config
33
67
  end
34
68
 
35
69
  private
36
70
 
37
- def experiments_map
38
- feature_variables_map = @project_config.feature_flags.reduce({}) do |result_map, feature|
39
- result_map.update(feature['id'] => feature['variables'])
40
- end
71
+ def experiments_id_map
72
+ feature_variables_map = feature_variable_map
73
+ audiences_id_map = audiences_map
41
74
  @project_config.experiments.reduce({}) do |experiments_map, experiment|
75
+ feature_id = @project_config.experiment_feature_map.fetch(experiment['id'], []).first
42
76
  experiments_map.update(
43
- experiment['key'] => {
77
+ experiment['id'] => {
44
78
  'id' => experiment['id'],
45
79
  'key' => experiment['key'],
46
- 'variationsMap' => experiment['variations'].reduce({}) do |variations_map, variation|
47
- variation_object = {
48
- 'id' => variation['id'],
49
- 'key' => variation['key'],
50
- 'variablesMap' => get_merged_variables_map(variation, experiment['id'], feature_variables_map)
51
- }
52
- variation_object['featureEnabled'] = variation['featureEnabled'] if @project_config.feature_experiment?(experiment['id'])
53
- variations_map.update(variation['key'] => variation_object)
54
- end
80
+ 'variationsMap' => get_variation_map(feature_id, experiment, feature_variables_map),
81
+ 'audiences' => replace_ids_with_names(experiment.fetch('audienceConditions', []), audiences_id_map) || ''
55
82
  }
56
83
  )
57
84
  end
58
85
  end
59
86
 
87
+ def audiences_map
88
+ @audiences.reduce({}) do |audiences_map, optly_audience|
89
+ audiences_map.update(optly_audience['id'] => optly_audience['name'])
90
+ end
91
+ end
92
+
93
+ def experiments_map
94
+ experiments_id_map.values.reduce({}) do |experiments_key_map, experiment|
95
+ experiments_key_map.update(experiment['key'] => experiment)
96
+ end
97
+ end
98
+
99
+ def feature_variable_map
100
+ @project_config.feature_flags.reduce({}) do |result_map, feature|
101
+ result_map.update(feature['id'] => feature['variables'])
102
+ end
103
+ end
104
+
105
+ def get_variation_map(feature_id, experiment, feature_variables_map)
106
+ experiment['variations'].reduce({}) do |variations_map, variation|
107
+ variation_object = {
108
+ 'id' => variation['id'],
109
+ 'key' => variation['key'],
110
+ 'featureEnabled' => variation['featureEnabled'],
111
+ 'variablesMap' => get_merged_variables_map(variation, feature_id, feature_variables_map)
112
+ }
113
+ variations_map.update(variation['key'] => variation_object)
114
+ end
115
+ end
116
+
60
117
  # Merges feature key and type from feature variables to variation variables.
61
- def get_merged_variables_map(variation, experiment_id, feature_variables_map)
62
- feature_ids = @project_config.experiment_feature_map[experiment_id]
63
- return {} unless feature_ids
118
+ def get_merged_variables_map(variation, feature_id, feature_variables_map)
119
+ return {} unless feature_id
64
120
 
65
- experiment_feature_variables = feature_variables_map[feature_ids[0]]
121
+ feature_variables = feature_variables_map[feature_id]
66
122
  # temporary variation variables map to get values to merge.
67
123
  temp_variables_id_map = {}
68
124
  if variation['variables']
@@ -75,7 +131,7 @@ module Optimizely
75
131
  )
76
132
  end
77
133
  end
78
- experiment_feature_variables.reduce({}) do |variables_map, feature_variable|
134
+ feature_variables.reduce({}) do |variables_map, feature_variable|
79
135
  variation_variable = temp_variables_id_map[feature_variable['id']]
80
136
  variable_value = variation['featureEnabled'] && variation_variable ? variation_variable['value'] : feature_variable['defaultValue']
81
137
  variables_map.update(
@@ -91,13 +147,15 @@ module Optimizely
91
147
 
92
148
  def get_features_map(all_experiments_map)
93
149
  @project_config.feature_flags.reduce({}) do |features_map, feature|
150
+ delivery_rules = get_delivery_rules(@rollouts, feature['rolloutId'], feature['id'])
94
151
  features_map.update(
95
152
  feature['key'] => {
96
153
  'id' => feature['id'],
97
154
  'key' => feature['key'],
155
+ # This experimentsMap is deprecated. Use experimentRules and deliveryRules instead.
98
156
  'experimentsMap' => feature['experimentIds'].reduce({}) do |experiments_map, experiment_id|
99
157
  experiment_key = @project_config.experiment_id_map[experiment_id]['key']
100
- experiments_map.update(experiment_key => all_experiments_map[experiment_key])
158
+ experiments_map.update(experiment_key => experiments_id_map[experiment_id])
101
159
  end,
102
160
  'variablesMap' => feature['variables'].reduce({}) do |variables, variable|
103
161
  variables.update(
@@ -108,10 +166,107 @@ module Optimizely
108
166
  'value' => variable['defaultValue']
109
167
  }
110
168
  )
111
- end
169
+ end,
170
+ 'experimentRules' => feature['experimentIds'].reduce([]) do |experiments_map, experiment_id|
171
+ experiments_map.push(all_experiments_map[experiment_id])
172
+ end,
173
+ 'deliveryRules' => delivery_rules
112
174
  }
113
175
  )
114
176
  end
115
177
  end
178
+
179
+ def get_attributes_list(attributes)
180
+ attributes.map do |attribute|
181
+ {
182
+ 'id' => attribute['id'],
183
+ 'key' => attribute['key']
184
+ }
185
+ end
186
+ end
187
+
188
+ def get_events_list(events)
189
+ events.map do |event|
190
+ {
191
+ 'id' => event['id'],
192
+ 'key' => event['key'],
193
+ 'experimentIds' => event['experimentIds']
194
+ }
195
+ end
196
+ end
197
+
198
+ def lookup_name_from_id(audience_id, audiences_map)
199
+ audiences_map[audience_id] || audience_id
200
+ end
201
+
202
+ def stringify_conditions(conditions, audiences_map)
203
+ operand = 'OR'
204
+ conditions_str = ''
205
+ length = conditions.length()
206
+ return '' if length.zero?
207
+ return '"' + lookup_name_from_id(conditions[0], audiences_map) + '"' if length == 1 && !OPERATORS.include?(conditions[0])
208
+
209
+ # Edge cases for lengths 0, 1 or 2
210
+ if length == 2 && OPERATORS.include?(conditions[0]) && !conditions[1].is_a?(Array) && !OPERATORS.include?(conditions[1])
211
+ return '"' + lookup_name_from_id(conditions[1], audiences_map) + '"' if conditions[0] != 'not'
212
+
213
+ return conditions[0].upcase + ' "' + lookup_name_from_id(conditions[1], audiences_map) + '"'
214
+
215
+ end
216
+ if length > 1
217
+ (0..length - 1).each do |n|
218
+ # Operand is handled here and made Upper Case
219
+ if OPERATORS.include?(conditions[n])
220
+ operand = conditions[n].upcase
221
+ # Check if element is a list or not
222
+ elsif conditions[n].is_a?(Array)
223
+ # Check if at the end or not to determine where to add the operand
224
+ # Recursive call to call stringify on embedded list
225
+ conditions_str += if n + 1 < length
226
+ '(' + stringify_conditions(conditions[n], audiences_map) + ') '
227
+ else
228
+ operand + ' (' + stringify_conditions(conditions[n], audiences_map) + ')'
229
+ end
230
+ # If the item is not a list, we process as an audience ID and retrieve the name
231
+ else
232
+ audience_name = lookup_name_from_id(conditions[n], audiences_map)
233
+ unless audience_name.nil?
234
+ # Below handles all cases for one ID or greater
235
+ conditions_str += if n + 1 < length - 1
236
+ '"' + audience_name + '" ' + operand + ' '
237
+ elsif n + 1 == length
238
+ operand + ' "' + audience_name + '"'
239
+ else
240
+ '"' + audience_name + '" '
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
246
+ conditions_str || ''
247
+ end
248
+
249
+ def replace_ids_with_names(conditions, audiences_map)
250
+ !conditions.empty? ? stringify_conditions(conditions, audiences_map) : ''
251
+ end
252
+
253
+ def get_delivery_rules(rollouts, rollout_id, feature_id)
254
+ audiences_id_map = audiences_map
255
+ feature_variables_map = feature_variable_map
256
+ rollout = rollouts.select { |selected_rollout| selected_rollout['id'] == rollout_id }
257
+ if rollout.any?
258
+ rollout = rollout[0]
259
+ experiments = rollout['experiments']
260
+ return experiments.map do |experiment|
261
+ {
262
+ 'id' => experiment['id'],
263
+ 'key' => experiment['key'],
264
+ 'variationsMap' => get_variation_map(feature_id, experiment, feature_variables_map),
265
+ 'audiences' => replace_ids_with_names(experiment.fetch('audienceConditions', []), audiences_id_map) || ''
266
+ }
267
+ end
268
+ end
269
+ []
270
+ end
116
271
  end
117
272
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2020, Optimizely and contributors
4
+ # Copyright 2020-2022, 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.
@@ -23,16 +23,26 @@ module Optimizely
23
23
  # Representation of an Optimizely User Context using which APIs are to be called.
24
24
 
25
25
  attr_reader :user_id
26
+ attr_reader :forced_decisions
27
+ attr_reader :OptimizelyDecisionContext
28
+ attr_reader :OptimizelyForcedDecision
29
+ attr_reader :optimizely_client
26
30
 
31
+ OptimizelyDecisionContext = Struct.new(:flag_key, :rule_key)
32
+ OptimizelyForcedDecision = Struct.new(:variation_key)
27
33
  def initialize(optimizely_client, user_id, user_attributes)
28
34
  @attr_mutex = Mutex.new
35
+ @forced_decision_mutex = Mutex.new
29
36
  @optimizely_client = optimizely_client
30
37
  @user_id = user_id
31
38
  @user_attributes = user_attributes.nil? ? {} : user_attributes.clone
39
+ @forced_decisions = {}
32
40
  end
33
41
 
34
42
  def clone
35
- OptimizelyUserContext.new(@optimizely_client, @user_id, user_attributes)
43
+ user_context = OptimizelyUserContext.new(@optimizely_client, @user_id, user_attributes)
44
+ @forced_decision_mutex.synchronize { user_context.instance_variable_set('@forced_decisions', @forced_decisions.dup) unless @forced_decisions.empty? }
45
+ user_context
36
46
  end
37
47
 
38
48
  def user_attributes
@@ -85,6 +95,68 @@ module Optimizely
85
95
  @optimizely_client&.decide_all(clone, options)
86
96
  end
87
97
 
98
+ # Sets the forced decision (variation key) for a given flag and an optional rule.
99
+ #
100
+ # @param context - An OptimizelyDecisionContext object containg flag key and rule key.
101
+ # @param decision - An OptimizelyForcedDecision object containing variation key
102
+ #
103
+ # @return - true if the forced decision has been set successfully.
104
+
105
+ def set_forced_decision(context, decision)
106
+ flag_key = context[:flag_key]
107
+ return false if flag_key.nil?
108
+
109
+ @forced_decision_mutex.synchronize { @forced_decisions[context] = decision }
110
+
111
+ true
112
+ end
113
+
114
+ def find_forced_decision(context)
115
+ return nil if @forced_decisions.empty?
116
+
117
+ decision = nil
118
+ @forced_decision_mutex.synchronize { decision = @forced_decisions[context] }
119
+ decision
120
+ end
121
+
122
+ # Returns the forced decision for a given flag and an optional rule.
123
+ #
124
+ # @param context - An OptimizelyDecisionContext object containg flag key and rule key.
125
+ #
126
+ # @return - A variation key or nil if forced decisions are not set for the parameters.
127
+
128
+ def get_forced_decision(context)
129
+ find_forced_decision(context)
130
+ end
131
+
132
+ # Removes the forced decision for a given flag and an optional rule.
133
+ #
134
+ # @param context - An OptimizelyDecisionContext object containg flag key and rule key.
135
+ #
136
+ # @return - true if the forced decision has been removed successfully.
137
+
138
+ def remove_forced_decision(context)
139
+ deleted = false
140
+ @forced_decision_mutex.synchronize do
141
+ if @forced_decisions.key?(context)
142
+ @forced_decisions.delete(context)
143
+ deleted = true
144
+ end
145
+ end
146
+ deleted
147
+ end
148
+
149
+ # Removes all forced decisions bound to this user context.
150
+ #
151
+ # @return - true if forced decisions have been removed successfully.
152
+
153
+ def remove_all_forced_decisions
154
+ return false if @optimizely_client&.get_optimizely_config.nil?
155
+
156
+ @forced_decision_mutex.synchronize { @forced_decisions.clear }
157
+ true
158
+ end
159
+
88
160
  # Track an event
89
161
  #
90
162
  # @param event_key - Event key representing the event which needs to be recorded.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright 2016-2020, Optimizely and contributors
3
+ # Copyright 2016-2021, Optimizely and contributors
4
4
  #
5
5
  # Licensed under the Apache License, Version 2.0 (the "License");
6
6
  # you may not use this file except in compliance with the License.
@@ -46,6 +46,10 @@ module Optimizely
46
46
 
47
47
  def revision; end
48
48
 
49
+ def sdk_key; end
50
+
51
+ def environment_key; end
52
+
49
53
  def send_flag_decisions; end
50
54
 
51
55
  def rollouts; end
@@ -17,5 +17,5 @@
17
17
  #
18
18
  module Optimizely
19
19
  CLIENT_ENGINE = 'ruby-sdk'
20
- VERSION = '3.8.1'
20
+ VERSION = '3.10.1'
21
21
  end
data/lib/optimizely.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #
4
- # Copyright 2016-2021, Optimizely and contributors
4
+ # Copyright 2016-2022, 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.
@@ -198,17 +198,24 @@ module Optimizely
198
198
  decision_event_dispatched = false
199
199
  experiment = nil
200
200
  decision_source = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']
201
-
202
- decision, reasons_received = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes, decide_options)
201
+ context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(key, nil)
202
+ variation, reasons_received = @decision_service.validated_forced_decision(config, context, user_context)
203
203
  reasons.push(*reasons_received)
204
204
 
205
+ if variation
206
+ decision = Optimizely::DecisionService::Decision.new(nil, variation, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'])
207
+ else
208
+ decision, reasons_received = @decision_service.get_variation_for_feature(config, feature_flag, user_context, decide_options)
209
+ reasons.push(*reasons_received)
210
+ end
211
+
205
212
  # Send impression event if Decision came from a feature test and decide options doesn't include disableDecisionEvent
206
213
  if decision.is_a?(Optimizely::DecisionService::Decision)
207
214
  experiment = decision.experiment
208
- rule_key = experiment['key']
215
+ rule_key = experiment ? experiment['key'] : nil
209
216
  variation = decision['variation']
210
- variation_key = variation['key']
211
- feature_enabled = variation['featureEnabled']
217
+ variation_key = variation ? variation['key'] : nil
218
+ feature_enabled = variation ? variation['featureEnabled'] : false
212
219
  decision_source = decision.source
213
220
  end
214
221
 
@@ -291,6 +298,19 @@ module Optimizely
291
298
  decisions
292
299
  end
293
300
 
301
+ # Gets variation using variation key or id and flag key.
302
+ #
303
+ # @param flag_key - flag key from which the variation is required.
304
+ # @param target_value - variation value either id or key that will be matched.
305
+ # @param attribute - string representing variation attribute.
306
+ #
307
+ # @return [variation]
308
+ # @return [nil] if no variation found in flag_variation_map.
309
+
310
+ def get_flag_variation(flag_key, target_value, attribute)
311
+ project_config.get_variation_from_flag(flag_key, target_value, attribute)
312
+ end
313
+
294
314
  # Buckets visitor and sends impression event to Optimizely.
295
315
  #
296
316
  # @param experiment_key - Experiment which needs to be activated.
@@ -490,7 +510,8 @@ module Optimizely
490
510
  return false
491
511
  end
492
512
 
493
- decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes)
513
+ user_context = create_user_context(user_id, attributes)
514
+ decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
494
515
 
495
516
  feature_enabled = false
496
517
  source_string = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']
@@ -739,7 +760,8 @@ module Optimizely
739
760
  return nil
740
761
  end
741
762
 
742
- decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes)
763
+ user_context = create_user_context(user_id, attributes)
764
+ decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
743
765
  variation = decision ? decision['variation'] : nil
744
766
  feature_enabled = variation ? variation['featureEnabled'] : false
745
767
  all_variables = {}
@@ -881,7 +903,8 @@ module Optimizely
881
903
 
882
904
  return nil unless user_inputs_valid?(attributes)
883
905
 
884
- variation_id, = @decision_service.get_variation(config, experiment_id, user_id, attributes)
906
+ user_context = create_user_context(user_id, attributes)
907
+ variation_id, = @decision_service.get_variation(config, experiment_id, user_context)
885
908
  variation = config.get_variation_from_id(experiment_key, variation_id) unless variation_id.nil?
886
909
  variation_key = variation['key'] if variation
887
910
  decision_notification_type = if config.feature_experiment?(experiment_id)
@@ -947,7 +970,8 @@ module Optimizely
947
970
  return nil
948
971
  end
949
972
 
950
- decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes)
973
+ user_context = create_user_context(user_id, attributes)
974
+ decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
951
975
  variation = decision ? decision['variation'] : nil
952
976
  feature_enabled = variation ? variation['featureEnabled'] : false
953
977
 
@@ -1083,8 +1107,12 @@ module Optimizely
1083
1107
  experiment_id = experiment['id']
1084
1108
  experiment_key = experiment['key']
1085
1109
 
1086
- variation_id = ''
1087
- variation_id = config.get_variation_id_from_key_by_experiment_id(experiment_id, variation_key) if experiment_id != ''
1110
+ variation_id = config.get_variation_id_from_key_by_experiment_id(experiment_id, variation_key) unless experiment_id.empty?
1111
+
1112
+ unless variation_id
1113
+ variation = !flag_key.empty? ? get_flag_variation(flag_key, variation_key, 'key') : nil
1114
+ variation_id = variation ? variation['id'] : ''
1115
+ end
1088
1116
 
1089
1117
  metadata = {
1090
1118
  flag_key: flag_key,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: optimizely-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.8.1
4
+ version: 3.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Optimizely
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-08-02 00:00:00.000000000 Z
11
+ date: 2022-02-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler