optimizely-sdk 3.8.1 → 3.10.1

Sign up to get free protection for your applications and to get access to all the features.
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