optimizely-sdk 5.0.1 → 5.2.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.
data/lib/optimizely.rb CHANGED
@@ -42,6 +42,9 @@ require_relative 'optimizely/optimizely_user_context'
42
42
  require_relative 'optimizely/odp/lru_cache'
43
43
  require_relative 'optimizely/odp/odp_manager'
44
44
  require_relative 'optimizely/helpers/sdk_settings'
45
+ require_relative 'optimizely/user_profile_tracker'
46
+ require_relative 'optimizely/cmab/cmab_client'
47
+ require_relative 'optimizely/cmab/cmab_service'
45
48
 
46
49
  module Optimizely
47
50
  class Project
@@ -83,7 +86,8 @@ module Optimizely
83
86
  event_processor: nil,
84
87
  default_decide_options: [],
85
88
  event_processor_options: {},
86
- settings: nil
89
+ settings: nil,
90
+ cmab_service: nil
87
91
  )
88
92
  @logger = logger || NoOpLogger.new
89
93
  @error_handler = error_handler || NoOpErrorHandler.new
@@ -130,7 +134,24 @@ module Optimizely
130
134
 
131
135
  setup_odp!(@config_manager.sdk_key)
132
136
 
133
- @decision_service = DecisionService.new(@logger, @user_profile_service)
137
+ # Initialize CMAB components if cmab service is nil
138
+ if cmab_service.nil?
139
+ @cmab_client = DefaultCmabClient.new(
140
+ http_client: nil,
141
+ retry_config: CmabRetryConfig.new,
142
+ logger: @logger
143
+ )
144
+ @cmab_cache = LRUCache.new(Optimizely::DefaultCmabCacheOptions::DEFAULT_CMAB_CACHE_SIZE, Optimizely::DefaultCmabCacheOptions::DEFAULT_CMAB_CACHE_TIMEOUT)
145
+ @cmab_service = DefaultCmabService.new(
146
+ @cmab_cache,
147
+ @cmab_client,
148
+ @logger
149
+ )
150
+ else
151
+ @cmab_service = cmab_service
152
+ end
153
+
154
+ @decision_service = DecisionService.new(@logger, @cmab_service, @user_profile_service)
134
155
 
135
156
  @event_processor = if event_processor.respond_to?(:process)
136
157
  event_processor
@@ -172,84 +193,42 @@ module Optimizely
172
193
  OptimizelyUserContext.new(self, user_id, attributes)
173
194
  end
174
195
 
175
- def decide(user_context, key, decide_options = [])
176
- # raising on user context as it is internal and not provided directly by the user.
177
- raise if user_context.class != OptimizelyUserContext
178
-
179
- reasons = []
180
-
181
- # check if SDK is ready
182
- unless is_valid
183
- @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide').message)
184
- reasons.push(OptimizelyDecisionMessage::SDK_NOT_READY)
185
- return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
186
- end
187
-
188
- # validate that key is a string
189
- unless key.is_a?(String)
190
- @logger.log(Logger::ERROR, 'Provided key is invalid')
191
- reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
192
- return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
193
- end
194
-
195
- # validate that key maps to a feature flag
196
- config = project_config
197
- feature_flag = config.get_feature_flag_from_key(key)
198
- unless feature_flag
199
- @logger.log(Logger::ERROR, "No feature flag was found for key '#{key}'.")
200
- reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
201
- return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
202
- end
203
-
204
- # merge decide_options and default_decide_options
205
- if decide_options.is_a? Array
206
- decide_options += @default_decide_options
207
- else
208
- @logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.')
209
- decide_options = @default_decide_options
210
- end
211
-
196
+ def create_optimizely_decision(user_context, flag_key, decision, reasons, decide_options, config)
212
197
  # Create Optimizely Decision Result.
213
198
  user_id = user_context.user_id
214
199
  attributes = user_context.user_attributes
215
200
  variation_key = nil
216
201
  feature_enabled = false
217
202
  rule_key = nil
218
- flag_key = key
219
203
  all_variables = {}
220
204
  decision_event_dispatched = false
205
+ feature_flag = config.get_feature_flag_from_key(flag_key)
221
206
  experiment = nil
222
207
  decision_source = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']
223
- context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(key, nil)
224
- variation, reasons_received = @decision_service.validated_forced_decision(config, context, user_context)
225
- reasons.push(*reasons_received)
226
-
227
- if variation
228
- decision = Optimizely::DecisionService::Decision.new(nil, variation, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'])
229
- else
230
- decision, reasons_received = @decision_service.get_variation_for_feature(config, feature_flag, user_context, decide_options)
231
- reasons.push(*reasons_received)
232
- end
208
+ experiment_id = nil
209
+ variation_id = nil
233
210
 
234
211
  # Send impression event if Decision came from a feature test and decide options doesn't include disableDecisionEvent
235
212
  if decision.is_a?(Optimizely::DecisionService::Decision)
236
213
  experiment = decision.experiment
237
214
  rule_key = experiment ? experiment['key'] : nil
238
- variation = decision['variation']
215
+ experiment_id = experiment ? experiment['id'] : nil
216
+ variation = decision.variation
239
217
  variation_key = variation ? variation['key'] : nil
218
+ variation_id = variation ? variation['id'] : nil
240
219
  feature_enabled = variation ? variation['featureEnabled'] : false
241
220
  decision_source = decision.source
242
221
  end
243
222
 
244
223
  if !decide_options.include?(OptimizelyDecideOption::DISABLE_DECISION_EVENT) && (decision_source == Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] || config.send_flag_decisions)
245
- send_impression(config, experiment, variation_key || '', flag_key, rule_key || '', feature_enabled, decision_source, user_id, attributes)
224
+ send_impression(config, experiment, variation_key || '', flag_key, rule_key || '', feature_enabled, decision_source, user_id, attributes, decision&.cmab_uuid)
246
225
  decision_event_dispatched = true
247
226
  end
248
227
 
249
228
  # Generate all variables map if decide options doesn't include excludeVariables
250
229
  unless decide_options.include? OptimizelyDecideOption::EXCLUDE_VARIABLES
251
230
  feature_flag['variables'].each do |variable|
252
- variable_value = get_feature_variable_for_variation(key, feature_enabled, variation, variable, user_id)
231
+ variable_value = get_feature_variable_for_variation(flag_key, feature_enabled, variation, variable, user_id)
253
232
  all_variables[variable['key']] = Helpers::VariableType.cast_value_to_type(variable_value, variable['type'], @logger)
254
233
  end
255
234
  end
@@ -260,14 +239,16 @@ module Optimizely
260
239
  @notification_center.send_notifications(
261
240
  NotificationCenter::NOTIFICATION_TYPES[:DECISION],
262
241
  Helpers::Constants::DECISION_NOTIFICATION_TYPES['FLAG'],
263
- user_id, (attributes || {}),
242
+ user_id, attributes || {},
264
243
  flag_key: flag_key,
265
244
  enabled: feature_enabled,
266
245
  variables: all_variables,
267
246
  variation_key: variation_key,
268
247
  rule_key: rule_key,
269
248
  reasons: should_include_reasons ? reasons : [],
270
- decision_event_dispatched: decision_event_dispatched
249
+ decision_event_dispatched: decision_event_dispatched,
250
+ experiment_id: experiment_id,
251
+ variation_id: variation_id
271
252
  )
272
253
 
273
254
  OptimizelyDecision.new(
@@ -281,6 +262,47 @@ module Optimizely
281
262
  )
282
263
  end
283
264
 
265
+ def decide(user_context, key, decide_options = [])
266
+ # raising on user context as it is internal and not provided directly by the user.
267
+ raise if user_context.class != OptimizelyUserContext
268
+
269
+ reasons = []
270
+
271
+ # check if SDK is ready
272
+ unless is_valid
273
+ @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide').message)
274
+ reasons.push(OptimizelyDecisionMessage::SDK_NOT_READY)
275
+ return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
276
+ end
277
+
278
+ # validate that key is a string
279
+ unless key.is_a?(String)
280
+ @logger.log(Logger::ERROR, 'Provided key is invalid')
281
+ reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
282
+ return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
283
+ end
284
+
285
+ # validate that key maps to a feature flag
286
+ config = project_config
287
+ feature_flag = config.get_feature_flag_from_key(key)
288
+ unless feature_flag
289
+ @logger.log(Logger::ERROR, "No feature flag was found for key '#{key}'.")
290
+ reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
291
+ return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
292
+ end
293
+
294
+ # merge decide_options and default_decide_options
295
+ if decide_options.is_a? Array
296
+ decide_options += @default_decide_options
297
+ else
298
+ @logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.')
299
+ decide_options = @default_decide_options
300
+ end
301
+
302
+ decide_options.delete(OptimizelyDecideOption::ENABLED_FLAGS_ONLY) if decide_options.include?(OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
303
+ decide_for_keys(user_context, [key], decide_options, true)[key]
304
+ end
305
+
284
306
  def decide_all(user_context, decide_options = [])
285
307
  # raising on user context as it is internal and not provided directly by the user.
286
308
  raise if user_context.class != OptimizelyUserContext
@@ -298,7 +320,7 @@ module Optimizely
298
320
  decide_for_keys(user_context, keys, decide_options)
299
321
  end
300
322
 
301
- def decide_for_keys(user_context, keys, decide_options = [])
323
+ def decide_for_keys(user_context, keys, decide_options = [], ignore_default_options = false) # rubocop:disable Style/OptionalBooleanParameter
302
324
  # raising on user context as it is internal and not provided directly by the user.
303
325
  raise if user_context.class != OptimizelyUserContext
304
326
 
@@ -308,13 +330,87 @@ module Optimizely
308
330
  return {}
309
331
  end
310
332
 
311
- enabled_flags_only = (!decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)) || (@default_decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
333
+ # merge decide_options and default_decide_options
334
+ unless ignore_default_options
335
+ if decide_options.is_a?(Array)
336
+ decide_options += @default_decide_options
337
+ else
338
+ @logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.')
339
+ decide_options = @default_decide_options
340
+ end
341
+ end
342
+
343
+ # enabled_flags_only = (!decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)) || (@default_decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
312
344
 
313
345
  decisions = {}
346
+ valid_keys = []
347
+ decision_reasons_dict = {}
348
+ config = project_config
349
+ return decisions unless config
350
+
351
+ flags_without_forced_decision = []
352
+ flag_decisions = {}
353
+
314
354
  keys.each do |key|
315
- decision = decide(user_context, key, decide_options)
316
- decisions[key] = decision unless enabled_flags_only && !decision.enabled
355
+ # Retrieve the feature flag from the project's feature flag key map
356
+ feature_flag = config.feature_flag_key_map[key]
357
+
358
+ # If the feature flag is nil, create a default OptimizelyDecision and move to the next key
359
+ if feature_flag.nil?
360
+ decisions[key] = OptimizelyDecision.new(variation_key: nil, enabled: false, variables: nil, rule_key: nil, flag_key: key, user_context: user_context, reasons: [])
361
+ next
362
+ end
363
+ valid_keys.push(key)
364
+ decision_reasons = []
365
+ decision_reasons_dict[key] = decision_reasons
366
+
367
+ config = project_config
368
+ context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(key, nil)
369
+ variation, reasons_received = @decision_service.validated_forced_decision(config, context, user_context)
370
+ decision_reasons_dict[key].push(*reasons_received)
371
+ if variation
372
+ decision = Optimizely::DecisionService::Decision.new(nil, variation, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'])
373
+ flag_decisions[key] = decision
374
+ else
375
+ flags_without_forced_decision.push(feature_flag)
376
+ end
377
+ end
378
+ decision_list = @decision_service.get_variations_for_feature_list(config, flags_without_forced_decision, user_context, decide_options)
379
+
380
+ flags_without_forced_decision.each_with_index do |flag, i|
381
+ decision = decision_list[i].decision
382
+ reasons = decision_list[i].reasons
383
+ error = decision_list[i].error
384
+ flag_key = flag['key']
385
+ # store error decision against key and remove key from valid keys
386
+ if error
387
+ optimizely_decision = OptimizelyDecision.new_error_decision(flag_key, user_context, reasons)
388
+ decisions[flag_key] = optimizely_decision
389
+ valid_keys.delete(flag_key) if valid_keys.include?(flag_key)
390
+ next
391
+ end
392
+ flag_decisions[flag_key] = decision
393
+ decision_reasons_dict[flag_key] ||= []
394
+ decision_reasons_dict[flag_key].push(*reasons)
317
395
  end
396
+ valid_keys.each do |key|
397
+ flag_decision = flag_decisions[key]
398
+ decision_reasons = decision_reasons_dict[key]
399
+ optimizely_decision = create_optimizely_decision(
400
+ user_context,
401
+ key,
402
+ flag_decision,
403
+ decision_reasons,
404
+ decide_options,
405
+ config
406
+ )
407
+
408
+ enabled_flags_only_missing = !decide_options.include?(OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
409
+ is_enabled = optimizely_decision.enabled
410
+
411
+ decisions[key] = optimizely_decision if enabled_flags_only_missing || is_enabled
412
+ end
413
+
318
414
  decisions
319
415
  end
320
416
 
@@ -531,14 +627,14 @@ module Optimizely
531
627
  end
532
628
 
533
629
  user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
534
- decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
535
-
630
+ decision_result = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
631
+ decision = decision_result.decision
536
632
  feature_enabled = false
537
633
  source_string = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']
538
634
  if decision.is_a?(Optimizely::DecisionService::Decision)
539
635
  variation = decision['variation']
540
636
  feature_enabled = variation['featureEnabled']
541
- if decision.source == Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST']
637
+ if decision.source == Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] || decision.source == Optimizely::DecisionService::DECISION_SOURCES['HOLDOUT']
542
638
  source_string = Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST']
543
639
  source_info = {
544
640
  experiment_key: decision.experiment['key'],
@@ -564,7 +660,7 @@ module Optimizely
564
660
  @notification_center.send_notifications(
565
661
  NotificationCenter::NOTIFICATION_TYPES[:DECISION],
566
662
  Helpers::Constants::DECISION_NOTIFICATION_TYPES['FEATURE'],
567
- user_id, (attributes || {}),
663
+ user_id, attributes || {},
568
664
  feature_key: feature_flag_key,
569
665
  feature_enabled: feature_enabled,
570
666
  source: source_string,
@@ -771,7 +867,8 @@ module Optimizely
771
867
  end
772
868
 
773
869
  user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
774
- decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
870
+ decision_result = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
871
+ decision = decision_result.decision
775
872
  variation = decision ? decision['variation'] : nil
776
873
  feature_enabled = variation ? variation['featureEnabled'] : false
777
874
  all_variables = {}
@@ -792,7 +889,7 @@ module Optimizely
792
889
 
793
890
  @notification_center.send_notifications(
794
891
  NotificationCenter::NOTIFICATION_TYPES[:DECISION],
795
- Helpers::Constants::DECISION_NOTIFICATION_TYPES['ALL_FEATURE_VARIABLES'], user_id, (attributes || {}),
892
+ Helpers::Constants::DECISION_NOTIFICATION_TYPES['ALL_FEATURE_VARIABLES'], user_id, attributes || {},
796
893
  feature_key: feature_flag_key,
797
894
  feature_enabled: feature_enabled,
798
895
  source: source_string,
@@ -959,7 +1056,11 @@ module Optimizely
959
1056
  return nil unless user_inputs_valid?(attributes)
960
1057
 
961
1058
  user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
962
- variation_id, = @decision_service.get_variation(config, experiment_id, user_context)
1059
+ user_profile_tracker = UserProfileTracker.new(user_id, @user_profile_service, @logger)
1060
+ user_profile_tracker.load_user_profile
1061
+ variation_result = @decision_service.get_variation(config, experiment_id, user_context, user_profile_tracker)
1062
+ variation_id = variation_result.variation_id
1063
+ user_profile_tracker.save_user_profile
963
1064
  variation = config.get_variation_from_id(experiment_key, variation_id) unless variation_id.nil?
964
1065
  variation_key = variation['key'] if variation
965
1066
  decision_notification_type = if config.feature_experiment?(experiment_id)
@@ -969,7 +1070,7 @@ module Optimizely
969
1070
  end
970
1071
  @notification_center.send_notifications(
971
1072
  NotificationCenter::NOTIFICATION_TYPES[:DECISION],
972
- decision_notification_type, user_id, (attributes || {}),
1073
+ decision_notification_type, user_id, attributes || {},
973
1074
  experiment_key: experiment_key,
974
1075
  variation_key: variation_key
975
1076
  )
@@ -1026,7 +1127,8 @@ module Optimizely
1026
1127
  end
1027
1128
 
1028
1129
  user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
1029
- decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
1130
+ decision_result = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
1131
+ decision = decision_result.decision
1030
1132
  variation = decision ? decision['variation'] : nil
1031
1133
  feature_enabled = variation ? variation['featureEnabled'] : false
1032
1134
 
@@ -1044,7 +1146,7 @@ module Optimizely
1044
1146
 
1045
1147
  @notification_center.send_notifications(
1046
1148
  NotificationCenter::NOTIFICATION_TYPES[:DECISION],
1047
- Helpers::Constants::DECISION_NOTIFICATION_TYPES['FEATURE_VARIABLE'], user_id, (attributes || {}),
1149
+ Helpers::Constants::DECISION_NOTIFICATION_TYPES['FEATURE_VARIABLE'], user_id, attributes || {},
1048
1150
  feature_key: feature_flag_key,
1049
1151
  feature_enabled: feature_enabled,
1050
1152
  source: source_string,
@@ -1144,7 +1246,7 @@ module Optimizely
1144
1246
  raise InvalidInputError, 'event_dispatcher'
1145
1247
  end
1146
1248
 
1147
- def send_impression(config, experiment, variation_key, flag_key, rule_key, enabled, rule_type, user_id, attributes = nil)
1249
+ def send_impression(config, experiment, variation_key, flag_key, rule_key, enabled, rule_type, user_id, attributes = nil, cmab_uuid = nil)
1148
1250
  if experiment.nil?
1149
1251
  experiment = {
1150
1252
  'id' => '',
@@ -1176,6 +1278,7 @@ module Optimizely
1176
1278
  variation_key: variation_key,
1177
1279
  enabled: enabled
1178
1280
  }
1281
+ metadata[:cmab_uuid] = cmab_uuid unless cmab_uuid.nil?
1179
1282
 
1180
1283
  user_event = UserEventFactory.create_impression_event(config, experiment, variation_id, metadata, user_id, attributes)
1181
1284
  @event_processor.process(user_event)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: optimizely-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.1
4
+ version: 5.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Optimizely
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-02-08 00:00:00.000000000 Z
11
+ date: 2025-11-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -134,6 +134,8 @@ files:
134
134
  - lib/optimizely.rb
135
135
  - lib/optimizely/audience.rb
136
136
  - lib/optimizely/bucketer.rb
137
+ - lib/optimizely/cmab/cmab_client.rb
138
+ - lib/optimizely/cmab/cmab_service.rb
137
139
  - lib/optimizely/condition_tree_evaluator.rb
138
140
  - lib/optimizely/config/datafile_project_config.rb
139
141
  - lib/optimizely/config/proxy_config.rb
@@ -191,6 +193,7 @@ files:
191
193
  - lib/optimizely/semantic_version.rb
192
194
  - lib/optimizely/user_condition_evaluator.rb
193
195
  - lib/optimizely/user_profile_service.rb
196
+ - lib/optimizely/user_profile_tracker.rb
194
197
  - lib/optimizely/version.rb
195
198
  homepage: https://github.com/optimizely/ruby-sdk
196
199
  licenses:
@@ -213,7 +216,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
213
216
  - !ruby/object:Gem::Version
214
217
  version: '0'
215
218
  requirements: []
216
- rubygems_version: 3.4.10
219
+ rubygems_version: 3.4.19
217
220
  signing_key:
218
221
  specification_version: 4
219
222
  summary: Ruby SDK for Optimizely's testing framework