optimizely-sdk 3.10.1 → 4.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +202 -202
  3. data/lib/optimizely/audience.rb +127 -97
  4. data/lib/optimizely/bucketer.rb +156 -156
  5. data/lib/optimizely/condition_tree_evaluator.rb +123 -123
  6. data/lib/optimizely/config/datafile_project_config.rb +539 -552
  7. data/lib/optimizely/config/proxy_config.rb +34 -34
  8. data/lib/optimizely/config_manager/async_scheduler.rb +95 -95
  9. data/lib/optimizely/config_manager/http_project_config_manager.rb +330 -329
  10. data/lib/optimizely/config_manager/project_config_manager.rb +24 -24
  11. data/lib/optimizely/config_manager/static_project_config_manager.rb +53 -52
  12. data/lib/optimizely/decide/optimizely_decide_option.rb +28 -28
  13. data/lib/optimizely/decide/optimizely_decision.rb +60 -60
  14. data/lib/optimizely/decide/optimizely_decision_message.rb +26 -26
  15. data/lib/optimizely/decision_service.rb +563 -563
  16. data/lib/optimizely/error_handler.rb +39 -39
  17. data/lib/optimizely/event/batch_event_processor.rb +235 -234
  18. data/lib/optimizely/event/entity/conversion_event.rb +44 -43
  19. data/lib/optimizely/event/entity/decision.rb +38 -38
  20. data/lib/optimizely/event/entity/event_batch.rb +86 -86
  21. data/lib/optimizely/event/entity/event_context.rb +50 -50
  22. data/lib/optimizely/event/entity/impression_event.rb +48 -47
  23. data/lib/optimizely/event/entity/snapshot.rb +33 -33
  24. data/lib/optimizely/event/entity/snapshot_event.rb +48 -48
  25. data/lib/optimizely/event/entity/user_event.rb +22 -22
  26. data/lib/optimizely/event/entity/visitor.rb +36 -35
  27. data/lib/optimizely/event/entity/visitor_attribute.rb +38 -37
  28. data/lib/optimizely/event/event_factory.rb +156 -155
  29. data/lib/optimizely/event/event_processor.rb +25 -25
  30. data/lib/optimizely/event/forwarding_event_processor.rb +44 -43
  31. data/lib/optimizely/event/user_event_factory.rb +88 -88
  32. data/lib/optimizely/event_builder.rb +221 -228
  33. data/lib/optimizely/event_dispatcher.rb +71 -71
  34. data/lib/optimizely/exceptions.rb +135 -139
  35. data/lib/optimizely/helpers/constants.rb +415 -397
  36. data/lib/optimizely/helpers/date_time_utils.rb +30 -30
  37. data/lib/optimizely/helpers/event_tag_utils.rb +132 -132
  38. data/lib/optimizely/helpers/group.rb +31 -31
  39. data/lib/optimizely/helpers/http_utils.rb +65 -64
  40. data/lib/optimizely/helpers/validator.rb +183 -183
  41. data/lib/optimizely/helpers/variable_type.rb +67 -67
  42. data/lib/optimizely/logger.rb +46 -45
  43. data/lib/optimizely/notification_center.rb +174 -176
  44. data/lib/optimizely/optimizely_config.rb +271 -272
  45. data/lib/optimizely/optimizely_factory.rb +181 -181
  46. data/lib/optimizely/optimizely_user_context.rb +204 -179
  47. data/lib/optimizely/params.rb +31 -31
  48. data/lib/optimizely/project_config.rb +99 -91
  49. data/lib/optimizely/semantic_version.rb +166 -166
  50. data/lib/optimizely/{custom_attribute_condition_evaluator.rb → user_condition_evaluator.rb} +391 -369
  51. data/lib/optimizely/user_profile_service.rb +35 -35
  52. data/lib/optimizely/version.rb +21 -21
  53. data/lib/optimizely.rb +1130 -1145
  54. metadata +15 -14
@@ -1,552 +1,539 @@
1
- # frozen_string_literal: true
2
-
3
- # Copyright 2019-2021, 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
- require 'json'
18
- require 'optimizely/project_config'
19
- require 'optimizely/helpers/constants'
20
- require 'optimizely/helpers/validator'
21
- module Optimizely
22
- class DatafileProjectConfig < ProjectConfig
23
- # Representation of the Optimizely project config.
24
- RUNNING_EXPERIMENT_STATUS = ['Running'].freeze
25
- RESERVED_ATTRIBUTE_PREFIX = '$opt_'
26
-
27
- attr_reader :datafile
28
- attr_reader :account_id
29
- attr_reader :attributes
30
- attr_reader :audiences
31
- attr_reader :typed_audiences
32
- attr_reader :events
33
- attr_reader :experiments
34
- attr_reader :feature_flags
35
- attr_reader :groups
36
- attr_reader :project_id
37
- # Boolean - denotes if Optimizely should remove the last block of visitors' IP address before storing event data
38
- attr_reader :anonymize_ip
39
- attr_reader :bot_filtering
40
- attr_reader :revision
41
- attr_reader :sdk_key
42
- attr_reader :environment_key
43
- attr_reader :rollouts
44
- attr_reader :version
45
- attr_reader :send_flag_decisions
46
-
47
- attr_reader :attribute_key_map
48
- attr_reader :audience_id_map
49
- attr_reader :event_key_map
50
- attr_reader :experiment_feature_map
51
- attr_reader :experiment_id_map
52
- attr_reader :experiment_key_map
53
- attr_reader :feature_flag_key_map
54
- attr_reader :feature_variable_key_map
55
- attr_reader :group_id_map
56
- attr_reader :rollout_id_map
57
- attr_reader :rollout_experiment_id_map
58
- attr_reader :variation_id_map
59
- attr_reader :variation_id_to_variable_usage_map
60
- attr_reader :variation_key_map
61
- attr_reader :variation_id_map_by_experiment_id
62
- attr_reader :variation_key_map_by_experiment_id
63
- attr_reader :flag_variation_map
64
-
65
- def initialize(datafile, logger, error_handler)
66
- # ProjectConfig init method to fetch and set project config data
67
- #
68
- # datafile - JSON string representing the project
69
-
70
- config = JSON.parse(datafile)
71
-
72
- @datafile = datafile
73
- @error_handler = error_handler
74
- @logger = logger
75
- @version = config['version']
76
-
77
- raise InvalidDatafileVersionError, @version unless Helpers::Constants::SUPPORTED_VERSIONS.value?(@version)
78
-
79
- @account_id = config['accountId']
80
- @attributes = config.fetch('attributes', [])
81
- @audiences = config.fetch('audiences', [])
82
- @typed_audiences = config.fetch('typedAudiences', [])
83
- @events = config.fetch('events', [])
84
- @experiments = config['experiments']
85
- @feature_flags = config.fetch('featureFlags', [])
86
- @groups = config.fetch('groups', [])
87
- @project_id = config['projectId']
88
- @anonymize_ip = config.key?('anonymizeIP') ? config['anonymizeIP'] : false
89
- @bot_filtering = config['botFiltering']
90
- @revision = config['revision']
91
- @sdk_key = config.fetch('sdkKey', '')
92
- @environment_key = config.fetch('environmentKey', '')
93
- @rollouts = config.fetch('rollouts', [])
94
- @send_flag_decisions = config.fetch('sendFlagDecisions', false)
95
-
96
- # Json type is represented in datafile as a subtype of string for the sake of backwards compatibility.
97
- # Converting it to a first-class json type while creating Project Config
98
- @feature_flags.each do |feature_flag|
99
- feature_flag['variables'].each do |variable|
100
- if variable['type'] == 'string' && variable['subType'] == 'json'
101
- variable['type'] = 'json'
102
- variable.delete('subType')
103
- end
104
- end
105
- end
106
-
107
- # Utility maps for quick lookup
108
- @attribute_key_map = generate_key_map(@attributes, 'key')
109
- @event_key_map = generate_key_map(@events, 'key')
110
- @group_id_map = generate_key_map(@groups, 'id')
111
- @group_id_map.each do |key, group|
112
- exps = group.fetch('experiments')
113
- exps.each do |exp|
114
- @experiments.push(exp.merge('groupId' => key))
115
- end
116
- end
117
- @experiment_key_map = generate_key_map(@experiments, 'key')
118
- @experiment_id_map = generate_key_map(@experiments, 'id')
119
- @audience_id_map = generate_key_map(@audiences, 'id')
120
- @audience_id_map = @audience_id_map.merge(generate_key_map(@typed_audiences, 'id')) unless @typed_audiences.empty?
121
- @variation_id_map = {}
122
- @variation_key_map = {}
123
- @variation_id_map_by_experiment_id = {}
124
- @variation_key_map_by_experiment_id = {}
125
- @variation_id_to_variable_usage_map = {}
126
- @variation_id_to_experiment_map = {}
127
- @flag_variation_map = {}
128
-
129
- @experiment_id_map.each_value do |exp|
130
- # Excludes experiments from rollouts
131
- variations = exp.fetch('variations')
132
- variations.each do |variation|
133
- variation_id = variation['id']
134
- @variation_id_to_experiment_map[variation_id] = exp
135
- end
136
- end
137
- @rollout_id_map = generate_key_map(@rollouts, 'id')
138
- # split out the experiment key map for rollouts
139
- @rollout_experiment_id_map = {}
140
- @rollout_id_map.each_value do |rollout|
141
- exps = rollout.fetch('experiments')
142
- @rollout_experiment_id_map = @rollout_experiment_id_map.merge(generate_key_map(exps, 'id'))
143
- end
144
-
145
- @flag_variation_map = generate_feature_variation_map(@feature_flags)
146
- @all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map)
147
- @all_experiments.each do |id, exp|
148
- variations = exp.fetch('variations')
149
- variations.each do |variation|
150
- variation_id = variation['id']
151
- variation['featureEnabled'] = variation['featureEnabled'] == true
152
- variation_variables = variation['variables']
153
- next if variation_variables.nil?
154
-
155
- @variation_id_to_variable_usage_map[variation_id] = generate_key_map(variation_variables, 'id')
156
- end
157
- @variation_id_map[exp['key']] = generate_key_map(variations, 'id')
158
- @variation_key_map[exp['key']] = generate_key_map(variations, 'key')
159
- @variation_id_map_by_experiment_id[id] = generate_key_map(variations, 'id')
160
- @variation_key_map_by_experiment_id[id] = generate_key_map(variations, 'key')
161
- end
162
- @feature_flag_key_map = generate_key_map(@feature_flags, 'key')
163
- @experiment_feature_map = {}
164
- @feature_variable_key_map = {}
165
- @feature_flag_key_map.each do |key, feature_flag|
166
- @feature_variable_key_map[key] = generate_key_map(feature_flag['variables'], 'key')
167
- feature_flag['experimentIds'].each do |experiment_id|
168
- @experiment_feature_map[experiment_id] = [feature_flag['id']]
169
- end
170
- end
171
- end
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
-
191
- def self.create(datafile, logger, error_handler, skip_json_validation)
192
- # Looks up and sets datafile and config based on response body.
193
- #
194
- # datafile - JSON string representing the Optimizely project.
195
- # logger - Provides a logger instance.
196
- # error_handler - Provides a handle_error method to handle exceptions.
197
- # skip_json_validation - Optional boolean param which allows skipping JSON schema
198
- # validation upon object invocation. By default JSON schema validation will be performed.
199
- # Returns instance of DatafileProjectConfig, nil otherwise.
200
- if !skip_json_validation && !Helpers::Validator.datafile_valid?(datafile)
201
- default_logger = SimpleLogger.new
202
- default_logger.log(Logger::ERROR, InvalidInputError.new('datafile').message)
203
- return nil
204
- end
205
-
206
- begin
207
- config = new(datafile, logger, error_handler)
208
- rescue StandardError => e
209
- default_logger = SimpleLogger.new
210
- error_to_handle = e.class == InvalidDatafileVersionError ? e : InvalidInputError.new('datafile')
211
- error_msg = error_to_handle.message
212
-
213
- default_logger.log(Logger::ERROR, error_msg)
214
- error_handler.handle_error error_to_handle
215
- return nil
216
- end
217
-
218
- config
219
- end
220
-
221
- def experiment_running?(experiment)
222
- # Determine if experiment corresponding to given key is running
223
- #
224
- # experiment - Experiment
225
- #
226
- # Returns true if experiment is running
227
- RUNNING_EXPERIMENT_STATUS.include?(experiment['status'])
228
- end
229
-
230
- def get_experiment_from_key(experiment_key)
231
- # Retrieves experiment ID for a given key
232
- #
233
- # experiment_key - String key representing the experiment
234
- #
235
- # Returns Experiment or nil if not found
236
-
237
- experiment = @experiment_key_map[experiment_key]
238
- return experiment if experiment
239
-
240
- @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
241
- @error_handler.handle_error InvalidExperimentError
242
- nil
243
- end
244
-
245
- def get_experiment_from_id(experiment_id)
246
- # Retrieves experiment ID for a given key
247
- #
248
- # experiment_id - String id representing the experiment
249
- #
250
- # Returns Experiment or nil if not found
251
-
252
- experiment = @experiment_id_map[experiment_id]
253
- return experiment if experiment
254
-
255
- @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
256
- @error_handler.handle_error InvalidExperimentError
257
- nil
258
- end
259
-
260
- def get_experiment_key(experiment_id)
261
- # Retrieves experiment key for a given ID.
262
- #
263
- # experiment_id - String ID representing the experiment.
264
- #
265
- # Returns String key.
266
-
267
- experiment = @experiment_id_map[experiment_id]
268
- return experiment['key'] unless experiment.nil?
269
-
270
- @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
271
- @error_handler.handle_error InvalidExperimentError
272
- nil
273
- end
274
-
275
- def get_event_from_key(event_key)
276
- # Get event for the provided event key.
277
- #
278
- # event_key - Event key for which event is to be determined.
279
- #
280
- # Returns Event corresponding to the provided event key.
281
-
282
- event = @event_key_map[event_key]
283
- return event if event
284
-
285
- @logger.log Logger::ERROR, "Event '#{event_key}' is not in datafile."
286
- @error_handler.handle_error InvalidEventError
287
- nil
288
- end
289
-
290
- def get_audience_from_id(audience_id)
291
- # Get audience for the provided audience ID
292
- #
293
- # audience_id - ID of the audience
294
- #
295
- # Returns the audience
296
-
297
- audience = @audience_id_map[audience_id]
298
- return audience if audience
299
-
300
- @logger.log Logger::ERROR, "Audience '#{audience_id}' is not in datafile."
301
- @error_handler.handle_error InvalidAudienceError
302
- nil
303
- end
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
-
312
- def get_variation_from_id(experiment_key, variation_id)
313
- # Get variation given experiment key and variation ID
314
- #
315
- # experiment_key - Key representing parent experiment of variation
316
- # variation_id - ID of the variation
317
- #
318
- # Returns the variation or nil if not found
319
-
320
- variation_id_map = @variation_id_map[experiment_key]
321
- if variation_id_map
322
- variation = variation_id_map[variation_id]
323
- return variation if variation
324
-
325
- @logger.log Logger::ERROR, "Variation id '#{variation_id}' is not in datafile."
326
- @error_handler.handle_error InvalidVariationError
327
- return nil
328
- end
329
-
330
- @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
331
- @error_handler.handle_error InvalidExperimentError
332
- nil
333
- end
334
-
335
- def get_variation_from_id_by_experiment_id(experiment_id, variation_id)
336
- # Get variation given experiment ID and variation ID
337
- #
338
- # experiment_id - ID representing parent experiment of variation
339
- # variation_id - ID of the variation
340
- #
341
- # Returns the variation or nil if not found
342
-
343
- variation_id_map_by_experiment_id = @variation_id_map_by_experiment_id[experiment_id]
344
- if variation_id_map_by_experiment_id
345
- variation = variation_id_map_by_experiment_id[variation_id]
346
- return variation if variation
347
-
348
- @logger.log Logger::ERROR, "Variation id '#{variation_id}' is not in datafile."
349
- @error_handler.handle_error InvalidVariationError
350
- return nil
351
- end
352
-
353
- @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
354
- @error_handler.handle_error InvalidExperimentError
355
- nil
356
- end
357
-
358
- def get_variation_id_from_key_by_experiment_id(experiment_id, variation_key)
359
- # Get variation given experiment ID and variation key
360
- #
361
- # experiment_id - ID representing parent experiment of variation
362
- # variation_key - Key of the variation
363
- #
364
- # Returns the variation or nil if not found
365
-
366
- variation_key_map = @variation_key_map_by_experiment_id[experiment_id]
367
- if variation_key_map
368
- variation = variation_key_map[variation_key]
369
- return variation['id'] if variation
370
-
371
- @logger.log Logger::ERROR, "Variation key '#{variation_key}' is not in datafile."
372
- @error_handler.handle_error InvalidVariationError
373
- return nil
374
- end
375
-
376
- @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
377
- @error_handler.handle_error InvalidExperimentError
378
- nil
379
- end
380
-
381
- def get_variation_id_from_key(experiment_key, variation_key)
382
- # Get variation ID given experiment key and variation key
383
- #
384
- # experiment_key - Key representing parent experiment of variation
385
- # variation_key - Key of the variation
386
- #
387
- # Returns ID of the variation
388
-
389
- variation_key_map = @variation_key_map[experiment_key]
390
- if variation_key_map
391
- variation = variation_key_map[variation_key]
392
- return variation['id'] if variation
393
-
394
- @logger.log Logger::ERROR, "Variation key '#{variation_key}' is not in datafile."
395
- @error_handler.handle_error InvalidVariationError
396
- return nil
397
- end
398
-
399
- @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
400
- @error_handler.handle_error InvalidExperimentError
401
- nil
402
- end
403
-
404
- def get_whitelisted_variations(experiment_id)
405
- # Retrieves whitelisted variations for a given experiment id
406
- #
407
- # experiment_id - String id representing the experiment
408
- #
409
- # Returns whitelisted variations for the experiment or nil
410
-
411
- experiment = @experiment_id_map[experiment_id]
412
- return experiment['forcedVariations'] if experiment
413
-
414
- @logger.log Logger::ERROR, "Experiment ID '#{experiment_id}' is not in datafile."
415
- @error_handler.handle_error InvalidExperimentError
416
- end
417
-
418
- def get_attribute_id(attribute_key)
419
- # Get attribute ID for the provided attribute key.
420
- #
421
- # Args:
422
- # Attribute key for which attribute is to be fetched.
423
- #
424
- # Returns:
425
- # Attribute ID corresponding to the provided attribute key.
426
- attribute = @attribute_key_map[attribute_key]
427
- has_reserved_prefix = attribute_key.to_s.start_with?(RESERVED_ATTRIBUTE_PREFIX)
428
- unless attribute.nil?
429
- if has_reserved_prefix
430
- @logger.log(Logger::WARN, "Attribute '#{attribute_key}' unexpectedly has reserved prefix '#{RESERVED_ATTRIBUTE_PREFIX}'; "\
431
- 'using attribute ID instead of reserved attribute name.')
432
- end
433
- return attribute['id']
434
- end
435
- return attribute_key if has_reserved_prefix
436
-
437
- @logger.log Logger::ERROR, "Attribute key '#{attribute_key}' is not in datafile."
438
- @error_handler.handle_error InvalidAttributeError
439
- nil
440
- end
441
-
442
- def variation_id_exists?(experiment_id, variation_id)
443
- # Determines if a given experiment ID / variation ID pair exists in the datafile
444
- #
445
- # experiment_id - String experiment ID
446
- # variation_id - String variation ID
447
- #
448
- # Returns true if variation is in datafile
449
-
450
- experiment_key = get_experiment_key(experiment_id)
451
- variation_id_map = @variation_id_map[experiment_key]
452
- if variation_id_map
453
- variation = variation_id_map[variation_id]
454
- return true if variation
455
-
456
- @logger.log Logger::ERROR, "Variation ID '#{variation_id}' is not in datafile."
457
- @error_handler.handle_error InvalidVariationError
458
- end
459
-
460
- false
461
- end
462
-
463
- def get_feature_flag_from_key(feature_flag_key)
464
- # Retrieves the feature flag with the given key
465
- #
466
- # feature_flag_key - String feature key
467
- #
468
- # Returns feature flag if found, otherwise nil
469
- feature_flag = @feature_flag_key_map[feature_flag_key]
470
- return feature_flag if feature_flag
471
-
472
- @logger.log Logger::ERROR, "Feature flag key '#{feature_flag_key}' is not in datafile."
473
- nil
474
- end
475
-
476
- def get_feature_variable(feature_flag, variable_key)
477
- # Retrieves the variable with the given key for the given feature
478
- #
479
- # feature_flag - The feature flag for which we are retrieving the variable
480
- # variable_key - String variable key
481
- #
482
- # Returns variable if found, otherwise nil
483
- feature_flag_key = feature_flag['key']
484
- variable = @feature_variable_key_map[feature_flag_key][variable_key]
485
- return variable if variable
486
-
487
- @logger.log Logger::ERROR, "No feature variable was found for key '#{variable_key}' in feature flag "\
488
- "'#{feature_flag_key}'."
489
- nil
490
- end
491
-
492
- def get_rollout_from_id(rollout_id)
493
- # Retrieves the rollout with the given ID
494
- #
495
- # rollout_id - String rollout ID
496
- #
497
- # Returns the rollout if found, otherwise nil
498
- rollout = @rollout_id_map[rollout_id]
499
- return rollout if rollout
500
-
501
- @logger.log Logger::ERROR, "Rollout with ID '#{rollout_id}' is not in the datafile."
502
- nil
503
- end
504
-
505
- def feature_experiment?(experiment_id)
506
- # Determines if given experiment is a feature test.
507
- #
508
- # experiment_id - String experiment ID
509
- #
510
- # Returns true if experiment belongs to any feature,
511
- # false otherwise.
512
- @experiment_feature_map.key?(experiment_id)
513
- end
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
-
525
- private
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
-
541
- def generate_key_map(array, key)
542
- # Helper method to generate map from key to hash in array of hashes
543
- #
544
- # array - Array consisting of hash
545
- # key - Key in each hash which will be key in the map
546
- #
547
- # Returns map mapping key to hash
548
-
549
- Hash[array.map { |obj| [obj[key], obj] }]
550
- end
551
- end
552
- end
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019-2022, 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
+ require 'json'
18
+ require 'optimizely/project_config'
19
+ require 'optimizely/helpers/constants'
20
+ require 'optimizely/helpers/validator'
21
+ module Optimizely
22
+ class DatafileProjectConfig < ProjectConfig
23
+ # Representation of the Optimizely project config.
24
+ RUNNING_EXPERIMENT_STATUS = ['Running'].freeze
25
+ RESERVED_ATTRIBUTE_PREFIX = '$opt_'
26
+
27
+ attr_reader :datafile, :account_id, :attributes, :audiences, :typed_audiences, :events,
28
+ :experiments, :feature_flags, :groups, :project_id, :bot_filtering, :revision,
29
+ :sdk_key, :environment_key, :rollouts, :version, :send_flag_decisions,
30
+ :attribute_key_map, :audience_id_map, :event_key_map, :experiment_feature_map,
31
+ :experiment_id_map, :experiment_key_map, :feature_flag_key_map, :feature_variable_key_map,
32
+ :group_id_map, :rollout_id_map, :rollout_experiment_id_map, :variation_id_map,
33
+ :variation_id_to_variable_usage_map, :variation_key_map, :variation_id_map_by_experiment_id,
34
+ :variation_key_map_by_experiment_id, :flag_variation_map, :integration_key_map, :integrations,
35
+ :public_key_for_odp, :host_for_odp, :all_segments
36
+ # Boolean - denotes if Optimizely should remove the last block of visitors' IP address before storing event data
37
+ attr_reader :anonymize_ip
38
+
39
+ def initialize(datafile, logger, error_handler)
40
+ # ProjectConfig init method to fetch and set project config data
41
+ #
42
+ # datafile - JSON string representing the project
43
+ super()
44
+
45
+ config = JSON.parse(datafile)
46
+
47
+ @datafile = datafile
48
+ @error_handler = error_handler
49
+ @logger = logger
50
+ @version = config['version']
51
+
52
+ raise InvalidDatafileVersionError, @version unless Helpers::Constants::SUPPORTED_VERSIONS.value?(@version)
53
+
54
+ @account_id = config['accountId']
55
+ @attributes = config.fetch('attributes', [])
56
+ @audiences = config.fetch('audiences', [])
57
+ @typed_audiences = config.fetch('typedAudiences', [])
58
+ @events = config.fetch('events', [])
59
+ @experiments = config['experiments']
60
+ @feature_flags = config.fetch('featureFlags', [])
61
+ @groups = config.fetch('groups', [])
62
+ @project_id = config['projectId']
63
+ @anonymize_ip = config.key?('anonymizeIP') ? config['anonymizeIP'] : false
64
+ @bot_filtering = config['botFiltering']
65
+ @revision = config['revision']
66
+ @sdk_key = config.fetch('sdkKey', '')
67
+ @environment_key = config.fetch('environmentKey', '')
68
+ @rollouts = config.fetch('rollouts', [])
69
+ @send_flag_decisions = config.fetch('sendFlagDecisions', false)
70
+ @integrations = config.fetch('integrations', [])
71
+
72
+ # Json type is represented in datafile as a subtype of string for the sake of backwards compatibility.
73
+ # Converting it to a first-class json type while creating Project Config
74
+ @feature_flags.each do |feature_flag|
75
+ feature_flag['variables'].each do |variable|
76
+ if variable['type'] == 'string' && variable['subType'] == 'json'
77
+ variable['type'] = 'json'
78
+ variable.delete('subType')
79
+ end
80
+ end
81
+ end
82
+
83
+ # Utility maps for quick lookup
84
+ @attribute_key_map = generate_key_map(@attributes, 'key')
85
+ @event_key_map = generate_key_map(@events, 'key')
86
+ @group_id_map = generate_key_map(@groups, 'id')
87
+ @group_id_map.each do |key, group|
88
+ exps = group.fetch('experiments')
89
+ exps.each do |exp|
90
+ @experiments.push(exp.merge('groupId' => key))
91
+ end
92
+ end
93
+ @experiment_key_map = generate_key_map(@experiments, 'key')
94
+ @experiment_id_map = generate_key_map(@experiments, 'id')
95
+ @audience_id_map = generate_key_map(@audiences, 'id')
96
+ @integration_key_map = generate_key_map(@integrations, 'key')
97
+ @audience_id_map = @audience_id_map.merge(generate_key_map(@typed_audiences, 'id')) unless @typed_audiences.empty?
98
+ @variation_id_map = {}
99
+ @variation_key_map = {}
100
+ @variation_id_map_by_experiment_id = {}
101
+ @variation_key_map_by_experiment_id = {}
102
+ @variation_id_to_variable_usage_map = {}
103
+ @variation_id_to_experiment_map = {}
104
+ @flag_variation_map = {}
105
+
106
+ @experiment_id_map.each_value do |exp|
107
+ # Excludes experiments from rollouts
108
+ variations = exp.fetch('variations')
109
+ variations.each do |variation|
110
+ variation_id = variation['id']
111
+ @variation_id_to_experiment_map[variation_id] = exp
112
+ end
113
+ end
114
+ @rollout_id_map = generate_key_map(@rollouts, 'id')
115
+ # split out the experiment key map for rollouts
116
+ @rollout_experiment_id_map = {}
117
+ @rollout_id_map.each_value do |rollout|
118
+ exps = rollout.fetch('experiments')
119
+ @rollout_experiment_id_map = @rollout_experiment_id_map.merge(generate_key_map(exps, 'id'))
120
+ end
121
+
122
+ if (odp_integration = @integration_key_map&.fetch('odp', nil))
123
+ @public_key_for_odp = odp_integration['publicKey']
124
+ @host_for_odp = odp_integration['host']
125
+ end
126
+
127
+ @all_segments = []
128
+ @audience_id_map.each_value do |audience|
129
+ @all_segments.concat Audience.get_segments(audience['conditions'])
130
+ end
131
+
132
+ @flag_variation_map = generate_feature_variation_map(@feature_flags)
133
+ @all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map)
134
+ @all_experiments.each do |id, exp|
135
+ variations = exp.fetch('variations')
136
+ variations.each do |variation|
137
+ variation_id = variation['id']
138
+ variation['featureEnabled'] = variation['featureEnabled'] == true
139
+ variation_variables = variation['variables']
140
+ next if variation_variables.nil?
141
+
142
+ @variation_id_to_variable_usage_map[variation_id] = generate_key_map(variation_variables, 'id')
143
+ end
144
+ @variation_id_map[exp['key']] = generate_key_map(variations, 'id')
145
+ @variation_key_map[exp['key']] = generate_key_map(variations, 'key')
146
+ @variation_id_map_by_experiment_id[id] = generate_key_map(variations, 'id')
147
+ @variation_key_map_by_experiment_id[id] = generate_key_map(variations, 'key')
148
+ end
149
+ @feature_flag_key_map = generate_key_map(@feature_flags, 'key')
150
+ @experiment_feature_map = {}
151
+ @feature_variable_key_map = {}
152
+ @feature_flag_key_map.each do |key, feature_flag|
153
+ @feature_variable_key_map[key] = generate_key_map(feature_flag['variables'], 'key')
154
+ feature_flag['experimentIds'].each do |experiment_id|
155
+ @experiment_feature_map[experiment_id] = [feature_flag['id']]
156
+ end
157
+ end
158
+ end
159
+
160
+ def get_rules_for_flag(feature_flag)
161
+ # Retrieves rules for a given feature flag
162
+ #
163
+ # feature_flag - String key representing the feature_flag
164
+ #
165
+ # Returns rules in feature flag
166
+ rules = feature_flag['experimentIds'].map { |exp_id| @experiment_id_map[exp_id] }
167
+ rollout = feature_flag['rolloutId'].empty? ? nil : @rollout_id_map[feature_flag['rolloutId']]
168
+
169
+ if rollout
170
+ rollout_experiments = rollout.fetch('experiments')
171
+ rollout_experiments.each do |exp|
172
+ rules.push(exp)
173
+ end
174
+ end
175
+ rules
176
+ end
177
+
178
+ def self.create(datafile, logger, error_handler, skip_json_validation)
179
+ # Looks up and sets datafile and config based on response body.
180
+ #
181
+ # datafile - JSON string representing the Optimizely project.
182
+ # logger - Provides a logger instance.
183
+ # error_handler - Provides a handle_error method to handle exceptions.
184
+ # skip_json_validation - Optional boolean param which allows skipping JSON schema
185
+ # validation upon object invocation. By default JSON schema validation will be performed.
186
+ # Returns instance of DatafileProjectConfig, nil otherwise.
187
+ if !skip_json_validation && !Helpers::Validator.datafile_valid?(datafile)
188
+ default_logger = SimpleLogger.new
189
+ default_logger.log(Logger::ERROR, InvalidInputError.new('datafile').message)
190
+ return nil
191
+ end
192
+
193
+ begin
194
+ config = new(datafile, logger, error_handler)
195
+ rescue StandardError => e
196
+ default_logger = SimpleLogger.new
197
+ error_to_handle = e.instance_of?(InvalidDatafileVersionError) ? e : InvalidInputError.new('datafile')
198
+ error_msg = error_to_handle.message
199
+
200
+ default_logger.log(Logger::ERROR, error_msg)
201
+ error_handler.handle_error error_to_handle
202
+ return nil
203
+ end
204
+
205
+ config
206
+ end
207
+
208
+ def experiment_running?(experiment)
209
+ # Determine if experiment corresponding to given key is running
210
+ #
211
+ # experiment - Experiment
212
+ #
213
+ # Returns true if experiment is running
214
+ RUNNING_EXPERIMENT_STATUS.include?(experiment['status'])
215
+ end
216
+
217
+ def get_experiment_from_key(experiment_key)
218
+ # Retrieves experiment ID for a given key
219
+ #
220
+ # experiment_key - String key representing the experiment
221
+ #
222
+ # Returns Experiment or nil if not found
223
+
224
+ experiment = @experiment_key_map[experiment_key]
225
+ return experiment if experiment
226
+
227
+ @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
228
+ @error_handler.handle_error InvalidExperimentError
229
+ nil
230
+ end
231
+
232
+ def get_experiment_from_id(experiment_id)
233
+ # Retrieves experiment ID for a given key
234
+ #
235
+ # experiment_id - String id representing the experiment
236
+ #
237
+ # Returns Experiment or nil if not found
238
+
239
+ experiment = @experiment_id_map[experiment_id]
240
+ return experiment if experiment
241
+
242
+ @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
243
+ @error_handler.handle_error InvalidExperimentError
244
+ nil
245
+ end
246
+
247
+ def get_experiment_key(experiment_id)
248
+ # Retrieves experiment key for a given ID.
249
+ #
250
+ # experiment_id - String ID representing the experiment.
251
+ #
252
+ # Returns String key.
253
+
254
+ experiment = @experiment_id_map[experiment_id]
255
+ return experiment['key'] unless experiment.nil?
256
+
257
+ @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
258
+ @error_handler.handle_error InvalidExperimentError
259
+ nil
260
+ end
261
+
262
+ def get_event_from_key(event_key)
263
+ # Get event for the provided event key.
264
+ #
265
+ # event_key - Event key for which event is to be determined.
266
+ #
267
+ # Returns Event corresponding to the provided event key.
268
+
269
+ event = @event_key_map[event_key]
270
+ return event if event
271
+
272
+ @logger.log Logger::ERROR, "Event '#{event_key}' is not in datafile."
273
+ @error_handler.handle_error InvalidEventError
274
+ nil
275
+ end
276
+
277
+ def get_audience_from_id(audience_id)
278
+ # Get audience for the provided audience ID
279
+ #
280
+ # audience_id - ID of the audience
281
+ #
282
+ # Returns the audience
283
+
284
+ audience = @audience_id_map[audience_id]
285
+ return audience if audience
286
+
287
+ @logger.log Logger::ERROR, "Audience '#{audience_id}' is not in datafile."
288
+ @error_handler.handle_error InvalidAudienceError
289
+ nil
290
+ end
291
+
292
+ def get_variation_from_flag(flag_key, target_value, attribute)
293
+ variations = @flag_variation_map[flag_key]
294
+ return variations.select { |variation| variation[attribute] == target_value }.first if variations
295
+
296
+ nil
297
+ end
298
+
299
+ def get_variation_from_id(experiment_key, variation_id)
300
+ # Get variation given experiment key and variation ID
301
+ #
302
+ # experiment_key - Key representing parent experiment of variation
303
+ # variation_id - ID of the variation
304
+ #
305
+ # Returns the variation or nil if not found
306
+
307
+ variation_id_map = @variation_id_map[experiment_key]
308
+ if variation_id_map
309
+ variation = variation_id_map[variation_id]
310
+ return variation if variation
311
+
312
+ @logger.log Logger::ERROR, "Variation id '#{variation_id}' is not in datafile."
313
+ @error_handler.handle_error InvalidVariationError
314
+ return nil
315
+ end
316
+
317
+ @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
318
+ @error_handler.handle_error InvalidExperimentError
319
+ nil
320
+ end
321
+
322
+ def get_variation_from_id_by_experiment_id(experiment_id, variation_id)
323
+ # Get variation given experiment ID and variation ID
324
+ #
325
+ # experiment_id - ID representing parent experiment of variation
326
+ # variation_id - ID of the variation
327
+ #
328
+ # Returns the variation or nil if not found
329
+
330
+ variation_id_map_by_experiment_id = @variation_id_map_by_experiment_id[experiment_id]
331
+ if variation_id_map_by_experiment_id
332
+ variation = variation_id_map_by_experiment_id[variation_id]
333
+ return variation if variation
334
+
335
+ @logger.log Logger::ERROR, "Variation id '#{variation_id}' is not in datafile."
336
+ @error_handler.handle_error InvalidVariationError
337
+ return nil
338
+ end
339
+
340
+ @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
341
+ @error_handler.handle_error InvalidExperimentError
342
+ nil
343
+ end
344
+
345
+ def get_variation_id_from_key_by_experiment_id(experiment_id, variation_key)
346
+ # Get variation given experiment ID and variation key
347
+ #
348
+ # experiment_id - ID representing parent experiment of variation
349
+ # variation_key - Key of the variation
350
+ #
351
+ # Returns the variation or nil if not found
352
+
353
+ variation_key_map = @variation_key_map_by_experiment_id[experiment_id]
354
+ if variation_key_map
355
+ variation = variation_key_map[variation_key]
356
+ return variation['id'] if variation
357
+
358
+ @logger.log Logger::ERROR, "Variation key '#{variation_key}' is not in datafile."
359
+ @error_handler.handle_error InvalidVariationError
360
+ return nil
361
+ end
362
+
363
+ @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
364
+ @error_handler.handle_error InvalidExperimentError
365
+ nil
366
+ end
367
+
368
+ def get_variation_id_from_key(experiment_key, variation_key)
369
+ # Get variation ID given experiment key and variation key
370
+ #
371
+ # experiment_key - Key representing parent experiment of variation
372
+ # variation_key - Key of the variation
373
+ #
374
+ # Returns ID of the variation
375
+
376
+ variation_key_map = @variation_key_map[experiment_key]
377
+ if variation_key_map
378
+ variation = variation_key_map[variation_key]
379
+ return variation['id'] if variation
380
+
381
+ @logger.log Logger::ERROR, "Variation key '#{variation_key}' is not in datafile."
382
+ @error_handler.handle_error InvalidVariationError
383
+ return nil
384
+ end
385
+
386
+ @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
387
+ @error_handler.handle_error InvalidExperimentError
388
+ nil
389
+ end
390
+
391
+ def get_whitelisted_variations(experiment_id)
392
+ # Retrieves whitelisted variations for a given experiment id
393
+ #
394
+ # experiment_id - String id representing the experiment
395
+ #
396
+ # Returns whitelisted variations for the experiment or nil
397
+
398
+ experiment = @experiment_id_map[experiment_id]
399
+ return experiment['forcedVariations'] if experiment
400
+
401
+ @logger.log Logger::ERROR, "Experiment ID '#{experiment_id}' is not in datafile."
402
+ @error_handler.handle_error InvalidExperimentError
403
+ end
404
+
405
+ def get_attribute_id(attribute_key)
406
+ # Get attribute ID for the provided attribute key.
407
+ #
408
+ # Args:
409
+ # Attribute key for which attribute is to be fetched.
410
+ #
411
+ # Returns:
412
+ # Attribute ID corresponding to the provided attribute key.
413
+ attribute = @attribute_key_map[attribute_key]
414
+ has_reserved_prefix = attribute_key.to_s.start_with?(RESERVED_ATTRIBUTE_PREFIX)
415
+ unless attribute.nil?
416
+ if has_reserved_prefix
417
+ @logger.log(Logger::WARN, "Attribute '#{attribute_key}' unexpectedly has reserved prefix '#{RESERVED_ATTRIBUTE_PREFIX}'; "\
418
+ 'using attribute ID instead of reserved attribute name.')
419
+ end
420
+ return attribute['id']
421
+ end
422
+ return attribute_key if has_reserved_prefix
423
+
424
+ @logger.log Logger::ERROR, "Attribute key '#{attribute_key}' is not in datafile."
425
+ @error_handler.handle_error InvalidAttributeError
426
+ nil
427
+ end
428
+
429
+ def variation_id_exists?(experiment_id, variation_id)
430
+ # Determines if a given experiment ID / variation ID pair exists in the datafile
431
+ #
432
+ # experiment_id - String experiment ID
433
+ # variation_id - String variation ID
434
+ #
435
+ # Returns true if variation is in datafile
436
+
437
+ experiment_key = get_experiment_key(experiment_id)
438
+ variation_id_map = @variation_id_map[experiment_key]
439
+ if variation_id_map
440
+ variation = variation_id_map[variation_id]
441
+ return true if variation
442
+
443
+ @logger.log Logger::ERROR, "Variation ID '#{variation_id}' is not in datafile."
444
+ @error_handler.handle_error InvalidVariationError
445
+ end
446
+
447
+ false
448
+ end
449
+
450
+ def get_feature_flag_from_key(feature_flag_key)
451
+ # Retrieves the feature flag with the given key
452
+ #
453
+ # feature_flag_key - String feature key
454
+ #
455
+ # Returns feature flag if found, otherwise nil
456
+ feature_flag = @feature_flag_key_map[feature_flag_key]
457
+ return feature_flag if feature_flag
458
+
459
+ @logger.log Logger::ERROR, "Feature flag key '#{feature_flag_key}' is not in datafile."
460
+ nil
461
+ end
462
+
463
+ def get_feature_variable(feature_flag, variable_key)
464
+ # Retrieves the variable with the given key for the given feature
465
+ #
466
+ # feature_flag - The feature flag for which we are retrieving the variable
467
+ # variable_key - String variable key
468
+ #
469
+ # Returns variable if found, otherwise nil
470
+ feature_flag_key = feature_flag['key']
471
+ variable = @feature_variable_key_map[feature_flag_key][variable_key]
472
+ return variable if variable
473
+
474
+ @logger.log Logger::ERROR, "No feature variable was found for key '#{variable_key}' in feature flag "\
475
+ "'#{feature_flag_key}'."
476
+ nil
477
+ end
478
+
479
+ def get_rollout_from_id(rollout_id)
480
+ # Retrieves the rollout with the given ID
481
+ #
482
+ # rollout_id - String rollout ID
483
+ #
484
+ # Returns the rollout if found, otherwise nil
485
+ rollout = @rollout_id_map[rollout_id]
486
+ return rollout if rollout
487
+
488
+ @logger.log Logger::ERROR, "Rollout with ID '#{rollout_id}' is not in the datafile."
489
+ nil
490
+ end
491
+
492
+ def feature_experiment?(experiment_id)
493
+ # Determines if given experiment is a feature test.
494
+ #
495
+ # experiment_id - String experiment ID
496
+ #
497
+ # Returns true if experiment belongs to any feature,
498
+ # false otherwise.
499
+ @experiment_feature_map.key?(experiment_id)
500
+ end
501
+
502
+ def rollout_experiment?(experiment_id)
503
+ # Determines if given experiment is a rollout test.
504
+ #
505
+ # experiment_id - String experiment ID
506
+ #
507
+ # Returns true if experiment belongs to any rollout,
508
+ # false otherwise.
509
+ @rollout_experiment_id_map.key?(experiment_id)
510
+ end
511
+
512
+ private
513
+
514
+ def generate_feature_variation_map(feature_flags)
515
+ flag_variation_map = {}
516
+ feature_flags.each do |flag|
517
+ variations = []
518
+ get_rules_for_flag(flag).each do |rule|
519
+ rule['variations'].each do |rule_variation|
520
+ variations.push(rule_variation) if variations.select { |variation| variation['id'] == rule_variation['id'] }.empty?
521
+ end
522
+ end
523
+ flag_variation_map[flag['key']] = variations
524
+ end
525
+ flag_variation_map
526
+ end
527
+
528
+ def generate_key_map(array, key)
529
+ # Helper method to generate map from key to hash in array of hashes
530
+ #
531
+ # array - Array consisting of hash
532
+ # key - Key in each hash which will be key in the map
533
+ #
534
+ # Returns map mapping key to hash
535
+
536
+ Hash[array.map { |obj| [obj[key], obj] }]
537
+ end
538
+ end
539
+ end