optimizely-sdk 3.9.0 → 4.0.0

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