optimizely-sdk 5.2.0 → 5.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3e60440f1cc553ee8c1eac5ffc7657b6f4de011a4d53a31674c0f5f68843b68
4
- data.tar.gz: 2e9f30000ddabe5118f32d4afda400f02db039b0e1e8ffca23d0bd96439ef71c
3
+ metadata.gz: 98f3ee93c9d69f15110623bfffad642326cd891607899ded857c6289e315a34f
4
+ data.tar.gz: ee719a38656735b49e65d37a261b4b709a84a76d9157d40889ce7be4a56c0029
5
5
  SHA512:
6
- metadata.gz: b563647ea0496d3c1b65d478c00918729e95b32b74e1260c34b92c2115d791101bfa0f22450b92185172773a8c16ae2cb36854f69792652da259b99499f28acd
7
- data.tar.gz: '038f0fd4bf0280b7d68a33bf7b0bb27f3efe482ef94995bb53751966e9fa7c4b3229fb62a513c2b88458557280b27e339a3222a459649d8b91e837e3e13adb00'
6
+ metadata.gz: 898908417c5e73e4136295ac92c942597cce81be13b6f2def6871c0635cf4c5ac52731140a0eaf154a6cfe75e3819e0fe7061f0d3af9c039e82e8a6ff9853592
7
+ data.tar.gz: 9938341c76b893da676e2bbcfd7406959ead3b38558bf8052261f50acde9d42f4e556af3e61cf84fbc6a8c34abf2c15bf69ce0812ca91ea9c7249a10b30bcdde
@@ -33,8 +33,7 @@ module Optimizely
33
33
  :group_id_map, :rollout_id_map, :rollout_experiment_id_map, :variation_id_map,
34
34
  :variation_id_to_variable_usage_map, :variation_key_map, :variation_id_map_by_experiment_id,
35
35
  :variation_key_map_by_experiment_id, :flag_variation_map, :integration_key_map, :integrations,
36
- :public_key_for_odp, :host_for_odp, :all_segments, :region, :holdouts, :holdout_id_map,
37
- :global_holdouts, :included_holdouts, :excluded_holdouts, :flag_holdouts_map
36
+ :public_key_for_odp, :host_for_odp, :all_segments, :region, :holdouts, :holdout_id_map
38
37
  # Boolean - denotes if Optimizely should remove the last block of visitors' IP address before storing event data
39
38
  attr_reader :anonymize_ip
40
39
 
@@ -115,32 +114,14 @@ module Optimizely
115
114
  @variation_id_to_experiment_map = {}
116
115
  @flag_variation_map = {}
117
116
  @holdout_id_map = {}
118
- @global_holdouts = {}
119
- @included_holdouts = {}
120
- @excluded_holdouts = {}
121
- @flag_holdouts_map = {}
122
117
 
123
118
  @holdouts.each do |holdout|
124
119
  next unless holdout['status'] == 'Running'
125
120
 
126
- @holdout_id_map[holdout['id']] = holdout
127
-
128
- if holdout['includedFlags'].nil? || holdout['includedFlags'].empty?
129
- @global_holdouts[holdout['id']] = holdout
121
+ # Ensure holdout has layerId field (holdouts don't have campaigns)
122
+ holdout['layerId'] ||= ''
130
123
 
131
- excluded_flags = holdout['excludedFlags']
132
- if excluded_flags && !excluded_flags.empty?
133
- excluded_flags.each do |flag_id|
134
- @excluded_holdouts[flag_id] ||= []
135
- @excluded_holdouts[flag_id] << holdout
136
- end
137
- end
138
- else
139
- holdout['includedFlags'].each do |flag_id|
140
- @included_holdouts[flag_id] ||= []
141
- @included_holdouts[flag_id] << holdout
142
- end
143
- end
124
+ @holdout_id_map[holdout['id']] = holdout
144
125
  end
145
126
 
146
127
  @experiment_id_map.each_value do |exp|
@@ -169,7 +150,6 @@ module Optimizely
169
150
  @all_segments.concat Audience.get_segments(audience['conditions'])
170
151
  end
171
152
 
172
- @flag_variation_map = generate_feature_variation_map(@feature_flags)
173
153
  @all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map)
174
154
  @all_experiments.each do |id, exp|
175
155
  variations = exp.fetch('variations')
@@ -195,19 +175,32 @@ module Optimizely
195
175
  @experiment_feature_map[experiment_id] = [feature_flag['id']]
196
176
  end
197
177
 
198
- flag_id = feature_flag['id']
199
- applicable_holdouts = []
200
-
201
- applicable_holdouts.concat(@included_holdouts[flag_id]) if @included_holdouts[flag_id]
202
-
203
- @global_holdouts.each_value do |holdout|
204
- excluded_flag_ids = holdout['excludedFlags'] || []
205
- applicable_holdouts << holdout unless excluded_flag_ids.include?(flag_id)
178
+ # Feature Rollout support: inject the "everyone else" variation
179
+ # into any experiment with type == "feature_rollout"
180
+ everyone_else_variation = get_everyone_else_variation(feature_flag)
181
+ next if everyone_else_variation.nil?
182
+
183
+ feature_flag['experimentIds'].each do |exp_id|
184
+ experiment = @experiment_id_map[exp_id]
185
+ next unless experiment && experiment['type'] == Helpers::Constants::EXPERIMENT_TYPES['fr']
186
+
187
+ experiment['variations'].push(everyone_else_variation)
188
+ experiment['trafficAllocation'].push(
189
+ 'entityId' => everyone_else_variation['id'],
190
+ 'endOfRange' => 10_000
191
+ )
192
+ @variation_key_map[experiment['key']][everyone_else_variation['key']] = everyone_else_variation
193
+ @variation_id_map[experiment['key']][everyone_else_variation['id']] = everyone_else_variation
194
+ @variation_id_map_by_experiment_id[exp_id][everyone_else_variation['id']] = everyone_else_variation
195
+ @variation_key_map_by_experiment_id[exp_id][everyone_else_variation['key']] = everyone_else_variation
196
+ variation_variables = everyone_else_variation['variables']
197
+ @variation_id_to_variable_usage_map[everyone_else_variation['id']] = generate_key_map(variation_variables, 'id') if variation_variables
206
198
  end
207
-
208
- @flag_holdouts_map[key] = applicable_holdouts unless applicable_holdouts.empty?
209
199
  end
210
200
 
201
+ # Generate flag_variation_map after injection so it includes everyone-else variations
202
+ @flag_variation_map = generate_feature_variation_map(@feature_flags)
203
+
211
204
  # Adding Holdout variations in variation id and key maps
212
205
  return unless @holdouts && !@holdouts.empty?
213
206
 
@@ -635,19 +628,6 @@ module Optimizely
635
628
  @rollout_experiment_id_map.key?(experiment_id)
636
629
  end
637
630
 
638
- def get_holdouts_for_flag(flag_id)
639
- # Helper method to get holdouts from an applied feature flag
640
- #
641
- # flag_id - (REQUIRED) ID of the feature flag
642
- # This parameter is required and should not be null/nil
643
- #
644
- # Returns the holdouts that apply for a specific flag
645
-
646
- return [] if @holdouts.nil? || @holdouts.empty?
647
-
648
- @flag_holdouts_map[flag_id] || []
649
- end
650
-
651
631
  def get_holdout(holdout_id)
652
632
  # Helper method to get holdout from holdout ID
653
633
  #
@@ -664,6 +644,38 @@ module Optimizely
664
644
 
665
645
  private
666
646
 
647
+ def get_everyone_else_variation(feature_flag)
648
+ # Get the "everyone else" variation for a feature flag.
649
+ #
650
+ # The "everyone else" rule is the last experiment in the flag's rollout,
651
+ # and its first variation is the "everyone else" variation.
652
+ #
653
+ # feature_flag - Feature flag hash
654
+ #
655
+ # Returns the "everyone else" variation hash, or nil if not available.
656
+
657
+ rollout_id = feature_flag['rolloutId']
658
+ return nil if rollout_id.nil? || rollout_id.empty?
659
+
660
+ rollout = @rollout_id_map[rollout_id]
661
+ return nil if rollout.nil?
662
+
663
+ experiments = rollout['experiments']
664
+ return nil if experiments.nil? || experiments.empty?
665
+
666
+ everyone_else_rule = experiments.last
667
+ variations = everyone_else_rule['variations']
668
+ return nil if variations.nil? || variations.empty?
669
+
670
+ variation = variations.first
671
+ {
672
+ 'id' => variation['id'],
673
+ 'key' => variation['key'],
674
+ 'featureEnabled' => variation['featureEnabled'] == true,
675
+ 'variables' => variation.fetch('variables', [])
676
+ }
677
+ end
678
+
667
679
  def generate_feature_variation_map(feature_flags)
668
680
  flag_variation_map = {}
669
681
  feature_flags.each do |flag|
@@ -132,6 +132,8 @@ module Optimizely
132
132
  return VariationResult.new(nil, true, decide_reasons, nil)
133
133
  end
134
134
 
135
+ @logger.log(Logger::DEBUG, "Skipping user profile service for CMAB experiment '#{experiment_key}'. CMAB decisions are dynamic and not stored for sticky bucketing.")
136
+ should_ignore_user_profile_service = true
135
137
  cmab_decision = cmab_decision_result.result
136
138
  variation_id = cmab_decision&.variation_id
137
139
  cmab_uuid = cmab_decision&.cmab_uuid
@@ -167,9 +169,10 @@ module Optimizely
167
169
  # user_context - Optimizely user context instance
168
170
  #
169
171
  # Returns DecisionResult struct.
170
- holdouts = project_config.get_holdouts_for_flag(feature_flag['id'])
172
+ # Get running holdouts from the holdout_id_map (all holdouts are global now)
173
+ running_holdouts = project_config.holdout_id_map.values
171
174
 
172
- if holdouts && !holdouts.empty?
175
+ if running_holdouts && !running_holdouts.empty?
173
176
  # Has holdouts - use get_decision_for_flag which checks holdouts first
174
177
  get_decision_for_flag(feature_flag, user_context, project_config, decide_options)
175
178
  else
@@ -193,8 +196,8 @@ module Optimizely
193
196
  reasons = decide_reasons ? decide_reasons.dup : []
194
197
  user_id = user_context.user_id
195
198
 
196
- # Check holdouts
197
- holdouts = project_config.get_holdouts_for_flag(feature_flag['id'])
199
+ # Check holdouts (all holdouts are global now - apply to all flags)
200
+ holdouts = project_config.holdout_id_map.values
198
201
 
199
202
  holdouts.each do |holdout|
200
203
  holdout_decision = get_variation_for_holdout(holdout, user_context, project_config)
@@ -214,6 +217,9 @@ module Optimizely
214
217
 
215
218
  return DecisionResult.new(experiment_decision.decision, experiment_decision.error, reasons) if experiment_decision.decision
216
219
 
220
+ # If there's an error (e.g., CMAB error), return immediately without falling back to rollout
221
+ return DecisionResult.new(nil, experiment_decision.error, reasons) if experiment_decision.error
222
+
217
223
  # Check if the feature flag has a rollout and the user is bucketed into that rollout
218
224
  rollout_decision = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
219
225
  reasons.push(*rollout_decision.reasons)
@@ -312,15 +318,8 @@ module Optimizely
312
318
 
313
319
  decisions = []
314
320
  feature_flags.each do |feature_flag|
315
- # check if the feature is being experiment on and whether the user is bucketed into the experiment
316
- decision_result = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options)
317
- # Only process rollout if no experiment decision was found and no error
318
- if decision_result.decision.nil? && !decision_result.error
319
- decision_result_rollout = get_variation_for_feature_rollout(project_config, feature_flag, user_context) unless decision_result.decision
320
- decision_result.decision = decision_result_rollout.decision
321
- decision_result.reasons.push(*decision_result_rollout.reasons)
322
- end
323
- decisions << decision_result
321
+ decision = get_decision_for_flag(feature_flag, user_context, project_config, decide_options, user_profile_tracker)
322
+ decisions << decision
324
323
  end
325
324
  user_profile_tracker&.save_user_profile
326
325
  decisions
@@ -172,20 +172,35 @@ module Optimizely
172
172
  return if @current_batch.empty?
173
173
 
174
174
  log_event = Optimizely::EventFactory.create_log_event(@current_batch, @logger)
175
- begin
176
- @logger.log(
177
- Logger::INFO,
178
- 'Flushing Queue.'
179
- )
180
-
181
- @event_dispatcher.dispatch_event(log_event)
182
- @notification_center&.send_notifications(
183
- NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT],
184
- log_event
185
- )
186
- rescue StandardError => e
187
- @logger.log(Logger::ERROR, "Error dispatching event: #{log_event} #{e.message}.")
175
+ @logger.log(
176
+ Logger::INFO,
177
+ 'Flushing Queue.'
178
+ )
179
+
180
+ retry_count = 0
181
+ max_retries = Optimizely::Helpers::Constants::EVENT_DISPATCH_CONFIG[:MAX_RETRIES]
182
+
183
+ while retry_count < max_retries
184
+ begin
185
+ @event_dispatcher.dispatch_event(log_event)
186
+ @notification_center&.send_notifications(
187
+ NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT],
188
+ log_event
189
+ )
190
+ # Success - break out of retry loop
191
+ break
192
+ rescue StandardError => e
193
+ @logger.log(Logger::ERROR, "Error dispatching event: #{log_event} #{e.message}.")
194
+ retry_count += 1
195
+
196
+ if retry_count < max_retries
197
+ delay = calculate_retry_interval(retry_count - 1)
198
+ @logger.log(Logger::DEBUG, "Retrying event dispatch (attempt #{retry_count + 1} of #{max_retries}) after #{delay}s")
199
+ sleep(delay)
200
+ end
201
+ end
188
202
  end
203
+
189
204
  @current_batch = []
190
205
  end
191
206
 
@@ -231,5 +246,16 @@ module Optimizely
231
246
  # false otherwise.
232
247
  Helpers::Validator.finite_number?(value) && value.positive?
233
248
  end
249
+
250
+ # Calculate exponential backoff interval: 200ms, 400ms, 800ms, ... capped at 1s
251
+ #
252
+ # @param retry_count - Zero-based retry count
253
+ # @return [Float] - Delay in seconds
254
+ def calculate_retry_interval(retry_count)
255
+ initial_interval = Helpers::Constants::EVENT_DISPATCH_CONFIG[:INITIAL_RETRY_INTERVAL]
256
+ max_interval = Helpers::Constants::EVENT_DISPATCH_CONFIG[:MAX_RETRY_INTERVAL]
257
+ interval = initial_interval * (2**retry_count)
258
+ [interval, max_interval].min
259
+ end
234
260
  end
235
261
  end
@@ -18,6 +18,14 @@
18
18
  module Optimizely
19
19
  module Helpers
20
20
  module Constants
21
+ EXPERIMENT_TYPES = {
22
+ 'ab' => 'ab',
23
+ 'mab' => 'mab',
24
+ 'cmab' => 'cmab',
25
+ 'td' => 'td',
26
+ 'fr' => 'fr'
27
+ }.freeze
28
+
21
29
  JSON_SCHEMA_V2 = {
22
30
  'type' => 'object',
23
31
  'properties' => {
@@ -205,6 +213,9 @@ module Optimizely
205
213
  'cmab' => {
206
214
  'type' => 'object'
207
215
  },
216
+ 'type' => {
217
+ 'type' => %w[string null]
218
+ },
208
219
  'holdouts' => {
209
220
  'type' => 'array'
210
221
  }
@@ -335,14 +346,6 @@ module Optimizely
335
346
  },
336
347
  'status' => {
337
348
  'type' => 'string'
338
- },
339
- 'includedFlags' => {
340
- 'type' => 'array',
341
- 'items' => {'type' => 'string'}
342
- },
343
- 'excludedFlags' => {
344
- 'type' => 'array',
345
- 'items' => {'type' => 'string'}
346
349
  }
347
350
  }
348
351
  }
@@ -459,7 +462,10 @@ module Optimizely
459
462
  }.freeze
460
463
 
461
464
  EVENT_DISPATCH_CONFIG = {
462
- REQUEST_TIMEOUT: 10
465
+ REQUEST_TIMEOUT: 10,
466
+ MAX_RETRIES: 3,
467
+ INITIAL_RETRY_INTERVAL: 0.2, # 200ms in seconds
468
+ MAX_RETRY_INTERVAL: 1.0 # 1 second
463
469
  }.freeze
464
470
 
465
471
  ODP_GRAPHQL_API_CONFIG = {
@@ -490,7 +496,9 @@ module Optimizely
490
496
  DEFAULT_QUEUE_CAPACITY: 10_000,
491
497
  DEFAULT_BATCH_SIZE: 10,
492
498
  DEFAULT_FLUSH_INTERVAL_SECONDS: 1,
493
- DEFAULT_RETRY_COUNT: 3
499
+ DEFAULT_RETRY_COUNT: 3,
500
+ INITIAL_RETRY_INTERVAL: 0.2, # 200ms in seconds
501
+ MAX_RETRY_INTERVAL: 1.0 # 1 second
494
502
  }.freeze
495
503
 
496
504
  HTTP_HEADERS = {
@@ -239,7 +239,12 @@ module Optimizely
239
239
  end
240
240
  break unless should_retry
241
241
 
242
- @logger.log(Logger::DEBUG, 'Error dispatching ODP events, scheduled to retry.') if i < @retry_count
242
+ if i < @retry_count - 1
243
+ # Exponential backoff: 200ms, 400ms, 800ms, ... capped at 1s
244
+ delay = calculate_retry_interval(i)
245
+ @logger.log(Logger::DEBUG, "Error dispatching ODP events, retrying (attempt #{i + 2} of #{@retry_count}) after #{delay}s")
246
+ sleep(delay)
247
+ end
243
248
  i += 1
244
249
  end
245
250
 
@@ -282,5 +287,16 @@ module Optimizely
282
287
  @api_key = @odp_config&.api_key
283
288
  @api_host = @odp_config&.api_host
284
289
  end
290
+
291
+ # Calculate exponential backoff interval: 200ms, 400ms, 800ms, ... capped at 1s
292
+ #
293
+ # @param retry_count - Zero-based retry count
294
+ # @return [Float] - Delay in seconds
295
+ def calculate_retry_interval(retry_count)
296
+ initial_interval = Helpers::Constants::ODP_EVENT_MANAGER[:INITIAL_RETRY_INTERVAL]
297
+ max_interval = Helpers::Constants::ODP_EVENT_MANAGER[:MAX_RETRY_INTERVAL]
298
+ interval = initial_interval * (2**retry_count)
299
+ [interval, max_interval].min
300
+ end
285
301
  end
286
302
  end
@@ -17,5 +17,5 @@
17
17
  #
18
18
  module Optimizely
19
19
  CLIENT_ENGINE = 'ruby-sdk'
20
- VERSION = '5.2.0'
20
+ VERSION = '5.3.0'
21
21
  end
data/lib/optimizely.rb CHANGED
@@ -220,7 +220,7 @@ module Optimizely
220
220
  decision_source = decision.source
221
221
  end
222
222
 
223
- if !decide_options.include?(OptimizelyDecideOption::DISABLE_DECISION_EVENT) && (decision_source == Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] || config.send_flag_decisions)
223
+ if !decide_options.include?(OptimizelyDecideOption::DISABLE_DECISION_EVENT) && (decision_source == Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] || decision_source == Optimizely::DecisionService::DECISION_SOURCES['HOLDOUT'] || config.send_flag_decisions)
224
224
  send_impression(config, experiment, variation_key || '', flag_key, rule_key || '', feature_enabled, decision_source, user_id, attributes, decision&.cmab_uuid)
225
225
  decision_event_dispatched = true
226
226
  end
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.2.0
4
+ version: 5.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Optimizely
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-11-13 00:00:00.000000000 Z
11
+ date: 2026-05-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -216,7 +216,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
216
216
  - !ruby/object:Gem::Version
217
217
  version: '0'
218
218
  requirements: []
219
- rubygems_version: 3.4.19
219
+ rubygems_version: 3.4.10
220
220
  signing_key:
221
221
  specification_version: 4
222
222
  summary: Ruby SDK for Optimizely's testing framework