optimizely-sdk 3.9.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 -508
  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 -321
  10. data/lib/optimizely/config_manager/project_config_manager.rb +24 -24
  11. data/lib/optimizely/config_manager/static_project_config_manager.rb +53 -47
  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 -500
  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 -107
  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 -1117
  54. metadata +13 -13
@@ -1,508 +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
-
64
- def initialize(datafile, logger, error_handler)
65
- # ProjectConfig init method to fetch and set project config data
66
- #
67
- # datafile - JSON string representing the project
68
-
69
- config = JSON.parse(datafile)
70
-
71
- @datafile = datafile
72
- @error_handler = error_handler
73
- @logger = logger
74
- @version = config['version']
75
-
76
- raise InvalidDatafileVersionError, @version unless Helpers::Constants::SUPPORTED_VERSIONS.value?(@version)
77
-
78
- @account_id = config['accountId']
79
- @attributes = config.fetch('attributes', [])
80
- @audiences = config.fetch('audiences', [])
81
- @typed_audiences = config.fetch('typedAudiences', [])
82
- @events = config.fetch('events', [])
83
- @experiments = config['experiments']
84
- @feature_flags = config.fetch('featureFlags', [])
85
- @groups = config.fetch('groups', [])
86
- @project_id = config['projectId']
87
- @anonymize_ip = config.key?('anonymizeIP') ? config['anonymizeIP'] : false
88
- @bot_filtering = config['botFiltering']
89
- @revision = config['revision']
90
- @sdk_key = config.fetch('sdkKey', '')
91
- @environment_key = config.fetch('environmentKey', '')
92
- @rollouts = config.fetch('rollouts', [])
93
- @send_flag_decisions = config.fetch('sendFlagDecisions', false)
94
-
95
- # Json type is represented in datafile as a subtype of string for the sake of backwards compatibility.
96
- # Converting it to a first-class json type while creating Project Config
97
- @feature_flags.each do |feature_flag|
98
- feature_flag['variables'].each do |variable|
99
- if variable['type'] == 'string' && variable['subType'] == 'json'
100
- variable['type'] = 'json'
101
- variable.delete('subType')
102
- end
103
- end
104
- end
105
-
106
- # Utility maps for quick lookup
107
- @attribute_key_map = generate_key_map(@attributes, 'key')
108
- @event_key_map = generate_key_map(@events, 'key')
109
- @group_id_map = generate_key_map(@groups, 'id')
110
- @group_id_map.each do |key, group|
111
- exps = group.fetch('experiments')
112
- exps.each do |exp|
113
- @experiments.push(exp.merge('groupId' => key))
114
- end
115
- end
116
- @experiment_key_map = generate_key_map(@experiments, 'key')
117
- @experiment_id_map = generate_key_map(@experiments, 'id')
118
- @audience_id_map = generate_key_map(@audiences, 'id')
119
- @audience_id_map = @audience_id_map.merge(generate_key_map(@typed_audiences, 'id')) unless @typed_audiences.empty?
120
- @variation_id_map = {}
121
- @variation_key_map = {}
122
- @variation_id_map_by_experiment_id = {}
123
- @variation_key_map_by_experiment_id = {}
124
- @variation_id_to_variable_usage_map = {}
125
- @variation_id_to_experiment_map = {}
126
- @experiment_id_map.each_value do |exp|
127
- # Excludes experiments from rollouts
128
- variations = exp.fetch('variations')
129
- variations.each do |variation|
130
- variation_id = variation['id']
131
- @variation_id_to_experiment_map[variation_id] = exp
132
- end
133
- end
134
- @rollout_id_map = generate_key_map(@rollouts, 'id')
135
- # split out the experiment key map for rollouts
136
- @rollout_experiment_id_map = {}
137
- @rollout_id_map.each_value do |rollout|
138
- exps = rollout.fetch('experiments')
139
- @rollout_experiment_id_map = @rollout_experiment_id_map.merge(generate_key_map(exps, 'id'))
140
- end
141
- @all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map)
142
- @all_experiments.each do |id, exp|
143
- variations = exp.fetch('variations')
144
- variations.each do |variation|
145
- variation_id = variation['id']
146
- variation['featureEnabled'] = variation['featureEnabled'] == true
147
- variation_variables = variation['variables']
148
- next if variation_variables.nil?
149
-
150
- @variation_id_to_variable_usage_map[variation_id] = generate_key_map(variation_variables, 'id')
151
- end
152
- @variation_id_map[exp['key']] = generate_key_map(variations, 'id')
153
- @variation_key_map[exp['key']] = generate_key_map(variations, 'key')
154
- @variation_id_map_by_experiment_id[id] = generate_key_map(variations, 'id')
155
- @variation_key_map_by_experiment_id[id] = generate_key_map(variations, 'key')
156
- end
157
- @feature_flag_key_map = generate_key_map(@feature_flags, 'key')
158
- @experiment_feature_map = {}
159
- @feature_variable_key_map = {}
160
- @feature_flag_key_map.each do |key, feature_flag|
161
- @feature_variable_key_map[key] = generate_key_map(feature_flag['variables'], 'key')
162
- feature_flag['experimentIds'].each do |experiment_id|
163
- @experiment_feature_map[experiment_id] = [feature_flag['id']]
164
- end
165
- end
166
- end
167
-
168
- def self.create(datafile, logger, error_handler, skip_json_validation)
169
- # Looks up and sets datafile and config based on response body.
170
- #
171
- # datafile - JSON string representing the Optimizely project.
172
- # logger - Provides a logger instance.
173
- # error_handler - Provides a handle_error method to handle exceptions.
174
- # skip_json_validation - Optional boolean param which allows skipping JSON schema
175
- # validation upon object invocation. By default JSON schema validation will be performed.
176
- # Returns instance of DatafileProjectConfig, nil otherwise.
177
- if !skip_json_validation && !Helpers::Validator.datafile_valid?(datafile)
178
- default_logger = SimpleLogger.new
179
- default_logger.log(Logger::ERROR, InvalidInputError.new('datafile').message)
180
- return nil
181
- end
182
-
183
- begin
184
- config = new(datafile, logger, error_handler)
185
- rescue StandardError => e
186
- default_logger = SimpleLogger.new
187
- error_to_handle = e.class == InvalidDatafileVersionError ? e : InvalidInputError.new('datafile')
188
- error_msg = error_to_handle.message
189
-
190
- default_logger.log(Logger::ERROR, error_msg)
191
- error_handler.handle_error error_to_handle
192
- return nil
193
- end
194
-
195
- config
196
- end
197
-
198
- def experiment_running?(experiment)
199
- # Determine if experiment corresponding to given key is running
200
- #
201
- # experiment - Experiment
202
- #
203
- # Returns true if experiment is running
204
- RUNNING_EXPERIMENT_STATUS.include?(experiment['status'])
205
- end
206
-
207
- def get_experiment_from_key(experiment_key)
208
- # Retrieves experiment ID for a given key
209
- #
210
- # experiment_key - String key representing the experiment
211
- #
212
- # Returns Experiment or nil if not found
213
-
214
- experiment = @experiment_key_map[experiment_key]
215
- return experiment if experiment
216
-
217
- @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
218
- @error_handler.handle_error InvalidExperimentError
219
- nil
220
- end
221
-
222
- def get_experiment_from_id(experiment_id)
223
- # Retrieves experiment ID for a given key
224
- #
225
- # experiment_id - String id representing the experiment
226
- #
227
- # Returns Experiment or nil if not found
228
-
229
- experiment = @experiment_id_map[experiment_id]
230
- return experiment if experiment
231
-
232
- @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
233
- @error_handler.handle_error InvalidExperimentError
234
- nil
235
- end
236
-
237
- def get_experiment_key(experiment_id)
238
- # Retrieves experiment key for a given ID.
239
- #
240
- # experiment_id - String ID representing the experiment.
241
- #
242
- # Returns String key.
243
-
244
- experiment = @experiment_id_map[experiment_id]
245
- return experiment['key'] unless experiment.nil?
246
-
247
- @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
248
- @error_handler.handle_error InvalidExperimentError
249
- nil
250
- end
251
-
252
- def get_event_from_key(event_key)
253
- # Get event for the provided event key.
254
- #
255
- # event_key - Event key for which event is to be determined.
256
- #
257
- # Returns Event corresponding to the provided event key.
258
-
259
- event = @event_key_map[event_key]
260
- return event if event
261
-
262
- @logger.log Logger::ERROR, "Event '#{event_key}' is not in datafile."
263
- @error_handler.handle_error InvalidEventError
264
- nil
265
- end
266
-
267
- def get_audience_from_id(audience_id)
268
- # Get audience for the provided audience ID
269
- #
270
- # audience_id - ID of the audience
271
- #
272
- # Returns the audience
273
-
274
- audience = @audience_id_map[audience_id]
275
- return audience if audience
276
-
277
- @logger.log Logger::ERROR, "Audience '#{audience_id}' is not in datafile."
278
- @error_handler.handle_error InvalidAudienceError
279
- nil
280
- end
281
-
282
- def get_variation_from_id(experiment_key, variation_id)
283
- # Get variation given experiment key and variation ID
284
- #
285
- # experiment_key - Key representing parent experiment of variation
286
- # variation_id - ID of the variation
287
- #
288
- # Returns the variation or nil if not found
289
-
290
- variation_id_map = @variation_id_map[experiment_key]
291
- if variation_id_map
292
- variation = variation_id_map[variation_id]
293
- return variation if variation
294
-
295
- @logger.log Logger::ERROR, "Variation id '#{variation_id}' is not in datafile."
296
- @error_handler.handle_error InvalidVariationError
297
- return nil
298
- end
299
-
300
- @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
301
- @error_handler.handle_error InvalidExperimentError
302
- nil
303
- end
304
-
305
- def get_variation_from_id_by_experiment_id(experiment_id, variation_id)
306
- # Get variation given experiment ID and variation ID
307
- #
308
- # experiment_id - ID representing parent experiment of variation
309
- # variation_id - ID of the variation
310
- #
311
- # Returns the variation or nil if not found
312
-
313
- variation_id_map_by_experiment_id = @variation_id_map_by_experiment_id[experiment_id]
314
- if variation_id_map_by_experiment_id
315
- variation = variation_id_map_by_experiment_id[variation_id]
316
- return variation if variation
317
-
318
- @logger.log Logger::ERROR, "Variation id '#{variation_id}' is not in datafile."
319
- @error_handler.handle_error InvalidVariationError
320
- return nil
321
- end
322
-
323
- @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
324
- @error_handler.handle_error InvalidExperimentError
325
- nil
326
- end
327
-
328
- def get_variation_id_from_key_by_experiment_id(experiment_id, variation_key)
329
- # Get variation given experiment ID and variation key
330
- #
331
- # experiment_id - ID representing parent experiment of variation
332
- # variation_key - Key of the variation
333
- #
334
- # Returns the variation or nil if not found
335
-
336
- variation_key_map = @variation_key_map_by_experiment_id[experiment_id]
337
- if variation_key_map
338
- variation = variation_key_map[variation_key]
339
- return variation['id'] if variation
340
-
341
- @logger.log Logger::ERROR, "Variation key '#{variation_key}' is not in datafile."
342
- @error_handler.handle_error InvalidVariationError
343
- return nil
344
- end
345
-
346
- @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile."
347
- @error_handler.handle_error InvalidExperimentError
348
- nil
349
- end
350
-
351
- def get_variation_id_from_key(experiment_key, variation_key)
352
- # Get variation ID given experiment key and variation key
353
- #
354
- # experiment_key - Key representing parent experiment of variation
355
- # variation_key - Key of the variation
356
- #
357
- # Returns ID of the variation
358
-
359
- variation_key_map = @variation_key_map[experiment_key]
360
- if variation_key_map
361
- variation = variation_key_map[variation_key]
362
- return variation['id'] if variation
363
-
364
- @logger.log Logger::ERROR, "Variation key '#{variation_key}' is not in datafile."
365
- @error_handler.handle_error InvalidVariationError
366
- return nil
367
- end
368
-
369
- @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
370
- @error_handler.handle_error InvalidExperimentError
371
- nil
372
- end
373
-
374
- def get_whitelisted_variations(experiment_id)
375
- # Retrieves whitelisted variations for a given experiment id
376
- #
377
- # experiment_id - String id representing the experiment
378
- #
379
- # Returns whitelisted variations for the experiment or nil
380
-
381
- experiment = @experiment_id_map[experiment_id]
382
- return experiment['forcedVariations'] if experiment
383
-
384
- @logger.log Logger::ERROR, "Experiment ID '#{experiment_id}' is not in datafile."
385
- @error_handler.handle_error InvalidExperimentError
386
- end
387
-
388
- def get_attribute_id(attribute_key)
389
- # Get attribute ID for the provided attribute key.
390
- #
391
- # Args:
392
- # Attribute key for which attribute is to be fetched.
393
- #
394
- # Returns:
395
- # Attribute ID corresponding to the provided attribute key.
396
- attribute = @attribute_key_map[attribute_key]
397
- has_reserved_prefix = attribute_key.to_s.start_with?(RESERVED_ATTRIBUTE_PREFIX)
398
- unless attribute.nil?
399
- if has_reserved_prefix
400
- @logger.log(Logger::WARN, "Attribute '#{attribute_key}' unexpectedly has reserved prefix '#{RESERVED_ATTRIBUTE_PREFIX}'; "\
401
- 'using attribute ID instead of reserved attribute name.')
402
- end
403
- return attribute['id']
404
- end
405
- return attribute_key if has_reserved_prefix
406
-
407
- @logger.log Logger::ERROR, "Attribute key '#{attribute_key}' is not in datafile."
408
- @error_handler.handle_error InvalidAttributeError
409
- nil
410
- end
411
-
412
- def variation_id_exists?(experiment_id, variation_id)
413
- # Determines if a given experiment ID / variation ID pair exists in the datafile
414
- #
415
- # experiment_id - String experiment ID
416
- # variation_id - String variation ID
417
- #
418
- # Returns true if variation is in datafile
419
-
420
- experiment_key = get_experiment_key(experiment_id)
421
- variation_id_map = @variation_id_map[experiment_key]
422
- if variation_id_map
423
- variation = variation_id_map[variation_id]
424
- return true if variation
425
-
426
- @logger.log Logger::ERROR, "Variation ID '#{variation_id}' is not in datafile."
427
- @error_handler.handle_error InvalidVariationError
428
- end
429
-
430
- false
431
- end
432
-
433
- def get_feature_flag_from_key(feature_flag_key)
434
- # Retrieves the feature flag with the given key
435
- #
436
- # feature_flag_key - String feature key
437
- #
438
- # Returns feature flag if found, otherwise nil
439
- feature_flag = @feature_flag_key_map[feature_flag_key]
440
- return feature_flag if feature_flag
441
-
442
- @logger.log Logger::ERROR, "Feature flag key '#{feature_flag_key}' is not in datafile."
443
- nil
444
- end
445
-
446
- def get_feature_variable(feature_flag, variable_key)
447
- # Retrieves the variable with the given key for the given feature
448
- #
449
- # feature_flag - The feature flag for which we are retrieving the variable
450
- # variable_key - String variable key
451
- #
452
- # Returns variable if found, otherwise nil
453
- feature_flag_key = feature_flag['key']
454
- variable = @feature_variable_key_map[feature_flag_key][variable_key]
455
- return variable if variable
456
-
457
- @logger.log Logger::ERROR, "No feature variable was found for key '#{variable_key}' in feature flag "\
458
- "'#{feature_flag_key}'."
459
- nil
460
- end
461
-
462
- def get_rollout_from_id(rollout_id)
463
- # Retrieves the rollout with the given ID
464
- #
465
- # rollout_id - String rollout ID
466
- #
467
- # Returns the rollout if found, otherwise nil
468
- rollout = @rollout_id_map[rollout_id]
469
- return rollout if rollout
470
-
471
- @logger.log Logger::ERROR, "Rollout with ID '#{rollout_id}' is not in the datafile."
472
- nil
473
- end
474
-
475
- def feature_experiment?(experiment_id)
476
- # Determines if given experiment is a feature test.
477
- #
478
- # experiment_id - String experiment ID
479
- #
480
- # Returns true if experiment belongs to any feature,
481
- # false otherwise.
482
- @experiment_feature_map.key?(experiment_id)
483
- end
484
-
485
- def rollout_experiment?(experiment_id)
486
- # Determines if given experiment is a rollout test.
487
- #
488
- # experiment_id - String experiment ID
489
- #
490
- # Returns true if experiment belongs to any rollout,
491
- # false otherwise.
492
- @rollout_experiment_id_map.key?(experiment_id)
493
- end
494
-
495
- private
496
-
497
- def generate_key_map(array, key)
498
- # Helper method to generate map from key to hash in array of hashes
499
- #
500
- # array - Array consisting of hash
501
- # key - Key in each hash which will be key in the map
502
- #
503
- # Returns map mapping key to hash
504
-
505
- Hash[array.map { |obj| [obj[key], obj] }]
506
- end
507
- end
508
- 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