optimizely-sdk 5.2.1 → 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: f3f7080762c8f6eeb95b64da8d451035e5f162208f7556aed1886dfa5bce0b8c
4
- data.tar.gz: 046cf325408978dc7c0584d7a6ece61ae4795b843717164080b964a60ccd5a85
3
+ metadata.gz: 98f3ee93c9d69f15110623bfffad642326cd891607899ded857c6289e315a34f
4
+ data.tar.gz: ee719a38656735b49e65d37a261b4b709a84a76d9157d40889ce7be4a56c0029
5
5
  SHA512:
6
- metadata.gz: ff65b8f753315ac052920dd4c1bfe6f7d46d14742911c2a7cfe21d91d85fc424439724df7445eddf9d7453ca0bbd2a7306669ba255df1f811390c3d1e54d645e
7
- data.tar.gz: 4ca8a3093958b3061c38648c93494c5e36ab4b31917af95f89dc66ec420f384a4189a7e46467210283ea370a98ab71be22b63d7de90d6bb9fa02d9144ad5f1ea
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,10 +114,6 @@ 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'
@@ -127,31 +122,6 @@ module Optimizely
127
122
  holdout['layerId'] ||= ''
128
123
 
129
124
  @holdout_id_map[holdout['id']] = holdout
130
-
131
- included_flags = holdout['includedFlags'] || []
132
- excluded_flags = holdout['excludedFlags'] || []
133
-
134
- case [included_flags.empty?, excluded_flags.empty?]
135
- when [true, true]
136
- # No included or excluded flags - this is a global holdout
137
- @global_holdouts << holdout
138
-
139
- when [false, true], [false, false]
140
- # Has included flags - add to included_holdouts map
141
- included_flags.each do |flag_id|
142
- @included_holdouts[flag_id] ||= []
143
- @included_holdouts[flag_id] << holdout
144
- end
145
-
146
- when [true, false]
147
- # No included flags but has excluded flags - global with exclusions
148
- @global_holdouts << holdout
149
-
150
- excluded_flags.each do |flag_id|
151
- @excluded_holdouts[flag_id] ||= []
152
- @excluded_holdouts[flag_id] << holdout
153
- end
154
- end
155
125
  end
156
126
 
157
127
  @experiment_id_map.each_value do |exp|
@@ -180,7 +150,6 @@ module Optimizely
180
150
  @all_segments.concat Audience.get_segments(audience['conditions'])
181
151
  end
182
152
 
183
- @flag_variation_map = generate_feature_variation_map(@feature_flags)
184
153
  @all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map)
185
154
  @all_experiments.each do |id, exp|
186
155
  variations = exp.fetch('variations')
@@ -205,8 +174,33 @@ module Optimizely
205
174
  feature_flag['experimentIds'].each do |experiment_id|
206
175
  @experiment_feature_map[experiment_id] = [feature_flag['id']]
207
176
  end
177
+
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
198
+ end
208
199
  end
209
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
+
210
204
  # Adding Holdout variations in variation id and key maps
211
205
  return unless @holdouts && !@holdouts.empty?
212
206
 
@@ -634,46 +628,6 @@ module Optimizely
634
628
  @rollout_experiment_id_map.key?(experiment_id)
635
629
  end
636
630
 
637
- def get_holdouts_for_flag(flag_id)
638
- # Helper method to get holdouts from an applied feature flag
639
- #
640
- # flag_id - (REQUIRED) ID of the feature flag
641
- # This parameter is required and should not be null/nil
642
- #
643
- # Returns the holdouts that apply for a specific flag
644
-
645
- return [] if @holdouts.nil? || @holdouts.empty?
646
-
647
- # Check cache first (before validation, so we cache the validation result too)
648
- return @flag_holdouts_map[flag_id] if @flag_holdouts_map.key?(flag_id)
649
-
650
- # Validate that the flag exists in the datafile
651
- flag_exists = @feature_flags.any? { |flag| flag['id'] == flag_id }
652
- unless flag_exists
653
- # Cache the empty result for non-existent flags
654
- @flag_holdouts_map[flag_id] = []
655
- return []
656
- end
657
-
658
- # Prioritize global holdouts first
659
- excluded = @excluded_holdouts[flag_id] || []
660
-
661
- active_holdouts = if excluded.any?
662
- @global_holdouts.reject { |holdout| excluded.include?(holdout) }
663
- else
664
- @global_holdouts.dup
665
- end
666
-
667
- # Append included holdouts
668
- included = @included_holdouts[flag_id] || []
669
- active_holdouts.concat(included)
670
-
671
- # Cache the result
672
- @flag_holdouts_map[flag_id] = active_holdouts
673
-
674
- @flag_holdouts_map[flag_id] || []
675
- end
676
-
677
631
  def get_holdout(holdout_id)
678
632
  # Helper method to get holdout from holdout ID
679
633
  #
@@ -690,6 +644,38 @@ module Optimizely
690
644
 
691
645
  private
692
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
+
693
679
  def generate_feature_variation_map(feature_flags)
694
680
  flag_variation_map = {}
695
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)
@@ -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.1'
20
+ VERSION = '5.3.0'
21
21
  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.1
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-12-17 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