ibm_appconfiguration_ruby_sdk 0.1.0.pre.rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +76 -0
  4. data/CONTRIBUTING.md +9 -0
  5. data/LICENSE +201 -0
  6. data/README.md +474 -0
  7. data/Rakefile +8 -0
  8. data/examples/README.md +60 -0
  9. data/examples/app.rb +104 -0
  10. data/lib/ibm_appconfiguration_ruby_sdk/app_configuration.rb +291 -0
  11. data/lib/ibm_appconfiguration_ruby_sdk/configurations/configuration_handler.rb +828 -0
  12. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/constants.rb +89 -0
  13. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/file_manager.rb +72 -0
  14. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/logger.rb +98 -0
  15. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/background_retry_manager.rb +284 -0
  16. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/config_fetcher.rb +254 -0
  17. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/utils.rb +240 -0
  18. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/connection_manager.rb +501 -0
  19. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/connectivity.rb +30 -0
  20. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/driver_socket.rb +28 -0
  21. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/retry_policy.rb +42 -0
  22. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/state.rb +24 -0
  23. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/watchdog.rb +50 -0
  24. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/websocket_client.rb +43 -0
  25. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/feature.rb +121 -0
  26. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/property.rb +107 -0
  27. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/rule.rb +87 -0
  28. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/secret_property.rb +81 -0
  29. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/segment.rb +39 -0
  30. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/segment_rules.rb +57 -0
  31. data/lib/ibm_appconfiguration_ruby_sdk/core/api_manager.rb +269 -0
  32. data/lib/ibm_appconfiguration_ruby_sdk/core/metering.rb +400 -0
  33. data/lib/ibm_appconfiguration_ruby_sdk/core/url_builder.rb +252 -0
  34. data/lib/ibm_appconfiguration_ruby_sdk/version.rb +20 -0
  35. data/lib/ibm_appconfiguration_ruby_sdk.rb +20 -0
  36. data/sig/ibm_appconfiguration_ruby_sdk.rbs +4 -0
  37. metadata +209 -0
@@ -0,0 +1,828 @@
1
+ # Copyright 2026 IBM Corp. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # frozen_string_literal: true
16
+
17
+ require "singleton"
18
+ require "json"
19
+ require_relative "models/feature"
20
+ require_relative "models/property"
21
+ require_relative "models/segment"
22
+ require_relative "models/segment_rules"
23
+ require_relative "models/secret_property"
24
+ require_relative "internal/file_manager"
25
+ require_relative "internal/logger"
26
+ require_relative "internal/constants"
27
+ require_relative "internal/utils"
28
+ require_relative "internal/websocket_client/websocket_client"
29
+ require_relative "internal/retry_manager/config_fetcher"
30
+ require_relative "../core/url_builder"
31
+ require_relative "../core/metering"
32
+
33
+ ##
34
+ # Internal client to handle the configuration
35
+ class ConfigurationHandler
36
+ include Singleton
37
+
38
+ def initialize
39
+ @collection_id = nil
40
+ @environment_id = nil
41
+ @guid = nil
42
+ @is_connected = true
43
+ @live_update = true
44
+ @bootstrap_file = nil
45
+ @persistent_cache_directory = nil
46
+
47
+ @feature_map = {}
48
+ @property_map = {}
49
+ @segment_map = {}
50
+ @secret_map = {}
51
+ @rollout_config_map = {}
52
+ @all_feature_flags = []
53
+
54
+ @logger = Logger.instance
55
+ @file_manager = FileManager.instance
56
+ @websocket_client = nil
57
+
58
+ # Configuration update listener (single listener, matches Java SDK)
59
+ @configuration_update_listener = nil
60
+ end
61
+
62
+ ##
63
+ # Initialize the configuration handler
64
+ # @param region [String] The region
65
+ # @param guid [String] The GUID
66
+ # @param apikey [String] The API key
67
+ # @param use_private_endpoint [Boolean] Whether to use private endpoint
68
+ def init(region, guid, apikey, use_private_endpoint)
69
+ @guid = guid
70
+ @region = region
71
+ @apikey = apikey
72
+ @use_private_endpoint = use_private_endpoint
73
+
74
+ # Initialize UrlBuilder
75
+ url_builder = UrlBuilder.instance
76
+ url_builder.region = region
77
+ url_builder.guid = guid
78
+ url_builder.apikey = apikey
79
+ url_builder.use_private_endpoint = use_private_endpoint
80
+
81
+ # Initialize ApiManager
82
+ ApiManager.set_authenticator
83
+
84
+ # Initialize Metering
85
+ metering_url = "#{url_builder.base_service_url}/apprapp/events/v1/instances/#{guid}/usage"
86
+ Metering.instance.set_metering_url(metering_url, apikey)
87
+ end
88
+
89
+ ##
90
+ # Cleanup resources
91
+ def cleanup
92
+ Metering.instance.cleanup
93
+ @websocket_client&.disconnect
94
+ end
95
+
96
+ ##
97
+ # Load configurations to cache
98
+ # @param data [Hash] Configuration data
99
+ def load_configurations_to_cache(data)
100
+ return unless data
101
+
102
+ if data[:features]
103
+ features = data[:features]
104
+ @all_feature_flags = features
105
+ @feature_map = {}
106
+ @rollout_config_map = {}
107
+
108
+ features.each do |feature|
109
+ feature_obj = Feature.new(feature)
110
+ @feature_map[feature[:feature_id]] = feature_obj
111
+
112
+ # Parse feature-level progressive rollout
113
+ if feature_obj.rollout_configuration
114
+ @rollout_config_map[feature[:feature_id]] =
115
+ parse_rollout_configuration_phases(feature_obj.rollout_configuration)
116
+ end
117
+
118
+ # Parse segment-level progressive rollout
119
+ next unless feature[:segment_rules].is_a?(Array)
120
+
121
+ feature[:segment_rules].each do |segment_rule|
122
+ segment_rule_obj = SegmentRules.new(segment_rule)
123
+ if segment_rule_obj.rollout_configuration
124
+ key = "#{feature[:feature_id]}#{Constants::DELIMITER}#{segment_rule[:rule_id]}"
125
+ @rollout_config_map[key] = parse_rollout_configuration_phases(segment_rule_obj.rollout_configuration)
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ if data[:properties]
132
+ properties = data[:properties]
133
+ @property_map = {}
134
+ properties.each do |property|
135
+ @property_map[property[:property_id]] = Property.new(property)
136
+ end
137
+ end
138
+
139
+ if data[:segments]
140
+ segments = data[:segments]
141
+ @segment_map = {}
142
+ segments.each do |segment|
143
+ @segment_map[segment[:segment_id]] = Segment.new(segment)
144
+ end
145
+ end
146
+
147
+ # Notify listener after configurations are loaded
148
+ notify_configuration_update_listener
149
+ end
150
+
151
+ ##
152
+ # Write to persistent storage
153
+ # @param file_data [Hash] File data to persist
154
+ def write_to_persistent_storage(file_data)
155
+ return unless @persistent_cache_directory
156
+
157
+ json = JSON.generate(file_data)
158
+ file_path = File.join(@persistent_cache_directory, "appconfiguration.json")
159
+ @file_manager.store_files(json, file_path)
160
+ end
161
+
162
+ ##
163
+ # Report error
164
+ # @param error [String, Exception] Error message or exception
165
+ def report_error(error)
166
+ error_msg = error.is_a?(Exception) ? error.message : error.to_s
167
+ @logger.error(error_msg)
168
+ end
169
+
170
+ def format_config(configurations, _environment_id, _collection_id)
171
+ # TODO: Implement actual formatting logic
172
+ configurations
173
+ end
174
+
175
+ ##
176
+ # Set context for configuration
177
+ # @param collection_id [String] Collection ID
178
+ # @param environment_id [String] Environment ID
179
+ # @param options [Hash] Additional options
180
+ def set_context(collection_id, environment_id, options = {})
181
+ @collection_id = collection_id
182
+ @environment_id = environment_id
183
+ @persistent_cache_directory = options[:persistent_cache_directory]
184
+ @bootstrap_file = options[:bootstrap_file]
185
+ @live_update = options[:live_config_update_enabled]
186
+
187
+ # TODO: Initialize evaluation events and metric events
188
+ # evaluationEvents.init(@guid, @environment_id)
189
+ # metricEvents.init(@guid, @environment_id)
190
+
191
+ persistent_cache_read = false
192
+ error_reading_bootstrap_config = false
193
+
194
+ # Handle persistent cache directory
195
+ if @persistent_cache_directory
196
+ @logger.info("persistent cache directory path is: #{@persistent_cache_directory}")
197
+ file_path = File.join(@persistent_cache_directory, "appconfiguration.json")
198
+ persistent_cache = @file_manager.read_persistent_cache_configurations(file_path)
199
+
200
+ unless persistent_cache.empty?
201
+ configurations = extract_configurations(JSON.parse(persistent_cache), @environment_id, @collection_id)
202
+ load_configurations_to_cache(configurations)
203
+ persistent_cache_read = true
204
+ end
205
+
206
+ # Check write permissions
207
+ begin
208
+ # Test if directory is writable
209
+ File.write(File.join(@persistent_cache_directory, ".write_test"), "")
210
+ File.delete(File.join(@persistent_cache_directory, ".write_test"))
211
+ rescue StandardError => e
212
+ report_error("ERROR: No write permission for persistent cache directory. #{e}")
213
+ end
214
+ end
215
+
216
+ # Handle bootstrap file
217
+ if @bootstrap_file
218
+ if @persistent_cache_directory
219
+ # If persistent cache directory exists
220
+ if persistent_cache_read
221
+ # Persistent cache was read, emit event if not live update
222
+ # TODO: emit(APPCONFIGURATION_CLIENT_EMITTER) unless @live_update
223
+ else
224
+ # Only read bootstrap if persistent cache wasn't read
225
+ begin
226
+ @logger.info("reading configurations from bootstrap file: #{@bootstrap_file}")
227
+ bootstrap_config = @file_manager.read_bootstrap_configurations_from_file(@bootstrap_file)
228
+ configurations = extract_configurations(JSON.parse(bootstrap_config), @environment_id, @collection_id)
229
+ load_configurations_to_cache(configurations)
230
+ write_to_persistent_storage(format_config(configurations, @environment_id, @collection_id))
231
+ # TODO: emit event if not live update
232
+ # emit(APPCONFIGURATION_CLIENT_EMITTER) unless @live_update
233
+ rescue StandardError => e
234
+ report_error(e)
235
+ end
236
+ end
237
+ else
238
+ # No persistent cache directory, just read bootstrap
239
+ @logger.info("reading configurations from bootstrap file: #{@bootstrap_file}")
240
+ begin
241
+ bootstrap_config = @file_manager.read_bootstrap_configurations_from_file(@bootstrap_file)
242
+ configurations = extract_configurations(JSON.parse(bootstrap_config, symbolize_names: true),
243
+ @environment_id, @collection_id)
244
+ load_configurations_to_cache(configurations)
245
+ # TODO: emit(APPCONFIGURATION_CLIENT_EMITTER) unless @live_update
246
+ rescue StandardError => e
247
+ report_error(e) unless @live_update
248
+ @logger.error(e.message.to_s)
249
+ error_reading_bootstrap_config = true
250
+ end
251
+ end
252
+ end
253
+
254
+ # Implement live update logic
255
+ return unless @live_update
256
+
257
+ @logger.info("Live update enabled - fetching configurations from API...")
258
+
259
+ # Track whether to start background retry
260
+ start_background_retry = false
261
+
262
+ # Create config fetcher instance
263
+ config_fetcher = ConfigFetcher.new(
264
+ collection_id: @collection_id,
265
+ environment_id: @environment_id,
266
+ logger: @logger
267
+ )
268
+
269
+ # Fetch configurations from API
270
+ fetch_result = config_fetcher.fetch
271
+
272
+ if fetch_result[:ok]
273
+ @logger.info("✅ Successfully fetched configurations from API")
274
+
275
+ # Display raw API response
276
+ # @logger.info("=" * 80)
277
+ # @logger.info("📡 RAW API RESPONSE:")
278
+ # @logger.info("-" * 80)
279
+ require "json"
280
+ # @logger.info(JSON.pretty_generate(fetch_result[:data]))
281
+ # @logger.info("=" * 80)
282
+
283
+ # Process and load configurations
284
+ begin
285
+ # Convert string keys to symbol keys
286
+ symbolized_data = symbolize_keys(fetch_result[:data])
287
+
288
+ @logger.info("🔍 Symbolized data keys: #{symbolized_data.keys.inspect}")
289
+ @logger.info("🔍 Environments: #{symbolized_data[:environments]&.length || 0}")
290
+ @logger.info("🔍 Collections: #{symbolized_data[:collections]&.length || 0}")
291
+
292
+ # Extract configurations using utils.rb method
293
+ @logger.info("🔍 About to call extract_configurations")
294
+ @logger.info(" Environment ID: #{@environment_id}")
295
+ @logger.info(" Collection ID: #{@collection_id}")
296
+ @logger.info(" Symbolized data has #{symbolized_data[:environments]&.first&.dig(:features)&.length || 0} features in first environment")
297
+
298
+ extracted_config = extract_configurations(
299
+ symbolized_data,
300
+ @environment_id,
301
+ @collection_id
302
+ )
303
+
304
+ @logger.info("📊 Extracted config - Features: #{extracted_config[:features]&.length || 0}, Properties: #{extracted_config[:properties]&.length || 0}, Segments: #{extracted_config[:segments]&.length || 0}")
305
+
306
+ if extracted_config[:features] && extracted_config[:features].empty?
307
+ @logger.warning("⚠️ WARNING: 0 features extracted but API returned features!")
308
+ @logger.warning(" This suggests an issue in extract_configurations or validate_resource")
309
+ end
310
+
311
+ # Load to cache using existing method
312
+ load_configurations_to_cache(extracted_config)
313
+
314
+ @logger.info("📊 Loaded to cache - Features: #{@feature_map.length}, Properties: #{@property_map.length}, Segments: #{@segment_map.length}")
315
+
316
+ # Write to persistent storage if configured
317
+ if @persistent_cache_directory
318
+ formatted_config = format_config(extracted_config, @environment_id, @collection_id)
319
+ write_to_persistent_storage(formatted_config)
320
+ end
321
+
322
+ @logger.info("✅ Configurations loaded successfully")
323
+ rescue StandardError => e
324
+ @logger.error("❌ Failed to process configurations: #{e.message}")
325
+ @logger.error(e.backtrace.first(3).join("\n"))
326
+ end
327
+ else
328
+ # Failed to fetch from API
329
+ status_code = fetch_result[:status]
330
+ err_msg = "Status code: #{status_code}. Message: Failed to fetch the configurations from remote server."
331
+
332
+ # Check for client-side errors (4xx except 429)
333
+ report_error(err_msg) if status_code >= 400 && status_code < 500 && status_code != 429
334
+
335
+ # Check if we have fallback configurations (persistent cache or bootstrap)
336
+ if persistent_cache_read
337
+ message = "Loaded the configurations from the persistent cache into the application."
338
+ @logger.info("#{err_msg} #{message}")
339
+ start_background_retry = true
340
+ # TODO: emit event
341
+ elsif @bootstrap_file && !error_reading_bootstrap_config
342
+ message = "Loaded the configurations from the bootstrap file: #{@bootstrap_file} into the application."
343
+ @logger.info("#{err_msg} #{message}")
344
+ start_background_retry = true
345
+ # TODO: emit event
346
+ else
347
+ # No fallback available
348
+ @logger.error("❌ No configurations available - neither from API nor from cache/bootstrap")
349
+ report_error(err_msg)
350
+ end
351
+ end
352
+
353
+ # Start WebSocket client for live updates
354
+ @logger.info("🔌 Starting WebSocket client for live updates...")
355
+ begin
356
+ # Get required parameters from UrlBuilder
357
+ url_builder = UrlBuilder.instance
358
+
359
+ # Set @guid from url_builder if not already set
360
+ @guid ||= url_builder.guid
361
+
362
+ @websocket_client = WebSocketClient.new(
363
+ region: url_builder.region,
364
+ guid: @guid,
365
+ apikey: url_builder.apikey,
366
+ collection_id: @collection_id,
367
+ environment_id: @environment_id,
368
+ start_background_retry: start_background_retry
369
+ )
370
+
371
+ @websocket_client.connect
372
+ @logger.info("✅ WebSocket client started successfully")
373
+ rescue StandardError => e
374
+ @logger.error("❌ Failed to start WebSocket client: #{e.message}")
375
+ @logger.error(e.backtrace.first(3).join("\n"))
376
+ end
377
+ end
378
+
379
+ def track(event_key, entity_id)
380
+ # TODO: Implement tracking logic
381
+ end
382
+
383
+ ##
384
+ # Get feature by ID
385
+ # @param feature_id [String] Feature ID
386
+ # @return [Feature, nil] Feature object or nil
387
+ def get_feature(feature_id)
388
+ return @feature_map[feature_id] if @feature_map.key?(feature_id)
389
+
390
+ @logger.error("Invalid feature id - #{feature_id}")
391
+ nil
392
+ end
393
+
394
+ ##
395
+ # Get property by ID
396
+ # @param property_id [String] Property ID
397
+ # @return [Property, nil] Property object or nil
398
+ def get_property(property_id)
399
+ return @property_map[property_id] if @property_map.key?(property_id)
400
+
401
+ @logger.error("Invalid property id - #{property_id}")
402
+ nil
403
+ end
404
+
405
+ ##
406
+ # Get secret property
407
+ # @param property_id [String] Property ID
408
+ # @param secrets_manager_service [Object] Secrets manager service
409
+ # @return [SecretProperty, nil] Secret property or nil
410
+ def get_secret(property_id, secrets_manager_service)
411
+ property_obj = get_property(property_id)
412
+ if property_obj
413
+ if property_obj.get_property_data_type == Constants::SECRETREF
414
+ @secret_map[property_id] = secrets_manager_service
415
+ return SecretProperty.new(property_id)
416
+ end
417
+ @logger.error("Invalid operation: getSecret() cannot be called on a #{property_obj.get_property_data_type} property.")
418
+ return nil
419
+ end
420
+ nil
421
+ end
422
+
423
+ ##
424
+ # Get segment by ID
425
+ # @param segment_id [String] Segment ID
426
+ # @return [Segment, nil] Segment object or nil
427
+ def get_segment(segment_id)
428
+ return @segment_map[segment_id] if @segment_map.key?(segment_id)
429
+
430
+ @logger.error("Invalid segment id - #{segment_id}")
431
+ nil
432
+ end
433
+
434
+ ##
435
+ # Evaluate segment
436
+ # @param segment_key [String] Segment key
437
+ # @param entity_attributes [Hash] Entity attributes
438
+ # @return [Boolean] Evaluation result
439
+ def evaluate_segment(segment_key, entity_attributes)
440
+ if @segment_map.key?(segment_key)
441
+ segment_obj = @segment_map[segment_key]
442
+ return segment_obj.evaluate_rule(entity_attributes)
443
+ end
444
+ nil
445
+ end
446
+
447
+ ##
448
+ # Parse rules
449
+ # @param segment_rules [Array] Segment rules
450
+ # @return [Hash] Parsed rules
451
+ def parse_rules(segment_rules)
452
+ rules_map = {}
453
+ segment_rules.each do |rules|
454
+ rules_map[rules[:order]] = SegmentRules.new(rules)
455
+ end
456
+ rules_map
457
+ end
458
+
459
+ ##
460
+ # Get rollout percentage for progressive rollout
461
+ # @param feature [Feature] Feature object
462
+ # @param segment_rule [SegmentRules, nil] Segment rule object (nil for feature-level)
463
+ # @param entity_id [String] Entity ID
464
+ # @return [Integer] Rollout percentage
465
+ def get_rollout_percentage(feature, segment_rule, _entity_id)
466
+ if segment_rule
467
+ # Segment-level rollout
468
+ if segment_rule.rollout_configuration || (segment_rule.rollout_type && segment_rule.rollout_type == Constants::PROGRESSIVE)
469
+ rollout_hash = if segment_rule.rollout_percentage == Constants::DEFAULT_ROLLOUT_PERCENTAGE
470
+ @rollout_config_map[feature.feature_id]
471
+ else
472
+ @rollout_config_map["#{feature.feature_id}#{Constants::DELIMITER}#{segment_rule.rule_id}"]
473
+ end
474
+
475
+ return 0 unless rollout_hash
476
+
477
+ segment_rule.rollout_configuration[:start_at]
478
+ current_time_ms = (Time.now.to_f * 1000).to_i
479
+ # Find the entry with timestamp <= current time (sorted hash)
480
+ percentage = 0
481
+ rollout_hash.each do |timestamp, pct|
482
+ break if timestamp > current_time_ms
483
+
484
+ percentage = pct
485
+ end
486
+ percentage
487
+
488
+ else
489
+ # Manual rollout
490
+ segment_rule.rollout_percentage == Constants::DEFAULT_ROLLOUT_PERCENTAGE ? feature.rollout_percentage : segment_rule.rollout_percentage
491
+ end
492
+ else
493
+ # Feature-level rollout
494
+ return feature.rollout_percentage || 100 unless feature.rollout_configuration
495
+
496
+ rollout_hash = @rollout_config_map[feature.feature_id]
497
+ return 0 unless rollout_hash
498
+
499
+ feature.rollout_configuration[:start_at]
500
+ current_time_ms = (Time.now.to_f * 1000).to_i
501
+ # Find the entry with timestamp <= current time (sorted hash)
502
+ percentage = 0
503
+ rollout_hash.each do |timestamp, pct|
504
+ break if timestamp > current_time_ms
505
+
506
+ percentage = pct
507
+ end
508
+ percentage
509
+
510
+ end
511
+ end
512
+
513
+ ##
514
+ # Evaluate rules
515
+ # @param rules_map [Hash] Rules map
516
+ # @param entity_attributes [Hash] Entity attributes
517
+ # @param feature [Feature, nil] Feature object
518
+ # @param property [Property, nil] Property object
519
+ # @param entity_id [String] Entity ID
520
+ # @return [Hash] Evaluation result
521
+ def evaluate_rules(rules_map, entity_attributes, feature, property, entity_id = nil)
522
+ result_dict = {
523
+ evaluated_segment_id: Constants::DEFAULT_SEGMENT_ID,
524
+ value: nil,
525
+ is_enabled: false,
526
+ details: {}
527
+ }
528
+
529
+ begin
530
+ # For each rule in the targeting
531
+ (1..rules_map.keys.length).each do |index|
532
+ segment_rule = rules_map[index]
533
+
534
+ next unless segment_rule.get_rules.length.positive?
535
+
536
+ segment_rule.get_rules.each do |rule|
537
+ segments = rule[:segments]
538
+
539
+ next unless segments&.length&.positive?
540
+
541
+ # For each segment in a rule
542
+ segments.each do |segment_key|
543
+ # Check whether the entityAttributes satisfies all the rules of that segment
544
+ next unless evaluate_segment(segment_key, entity_attributes)
545
+
546
+ segment_name = @segment_map[segment_key].name
547
+ result_dict[:evaluated_segment_id] = segment_key
548
+ result_dict[:details][:segment_name] = segment_name
549
+
550
+ if feature
551
+ # evaluateRules was called for feature flag
552
+ segment_level_rollout_percentage = get_rollout_percentage(feature, segment_rule, entity_id)
553
+
554
+ # Check whether the entityId is eligible for segment rollout
555
+ if segment_level_rollout_percentage == 100 ||
556
+ (entity_id && get_normalized_value("#{entity_id}:#{feature.feature_id}") < segment_level_rollout_percentage)
557
+ # Since the entityId is eligible for segment rollout, return inherited or overridden value
558
+ result_dict[:value] = if segment_rule.get_value == Constants::DEFAULT_FEATURE_VALUE
559
+ feature.enabled_value # Return the inherited value
560
+ else
561
+ segment_rule.get_value # Return the overridden value
562
+ end
563
+ result_dict[:details][:value_type] = "SEGMENT_VALUE"
564
+ result_dict[:is_enabled] = true
565
+ result_dict[:details][:rollout_percentage_applied] = true
566
+ else
567
+ result_dict[:value] = feature.disabled_value
568
+ result_dict[:is_enabled] = false
569
+ result_dict[:details][:value_type] = "DISABLED_VALUE"
570
+ result_dict[:details][:rollout_percentage_applied] = false
571
+ end
572
+ else
573
+ # evaluateRules was called for property
574
+ result_dict[:value] = if segment_rule.get_value == Constants::DEFAULT_PROPERTY_VALUE
575
+ property.value
576
+ else
577
+ segment_rule.get_value
578
+ end
579
+ result_dict[:details][:value_type] = "SEGMENT_VALUE"
580
+ end
581
+ return result_dict
582
+ end
583
+ end
584
+ end
585
+ rescue StandardError => e
586
+ @logger.error("RuleEvaluation #{e}")
587
+ result_dict[:value] = nil
588
+ result_dict[:is_enabled] = false
589
+ result_dict[:details][:value_type] = "ERROR"
590
+ result_dict[:details][:error_type] = e.message
591
+ return result_dict
592
+ end
593
+
594
+ # Since entityAttributes did not satisfy any of the targeting rules
595
+ if feature
596
+ # evaluateRules was called for feature flag
597
+ # Check whether the entityId is eligible for default rollout
598
+ rollout_percentage = get_rollout_percentage(feature, nil, entity_id)
599
+
600
+ if rollout_percentage == 100 ||
601
+ (entity_id && get_normalized_value("#{entity_id}:#{feature.feature_id}") < rollout_percentage)
602
+ result_dict[:value] = feature.enabled_value
603
+ result_dict[:is_enabled] = true
604
+ result_dict[:details][:value_type] = "ENABLED_VALUE"
605
+ result_dict[:details][:rollout_percentage_applied] = true
606
+ else
607
+ result_dict[:value] = feature.disabled_value
608
+ result_dict[:is_enabled] = false
609
+ result_dict[:details][:value_type] = "DISABLED_VALUE"
610
+ result_dict[:details][:rollout_percentage_applied] = false
611
+ end
612
+ else
613
+ # evaluateRules was called for property
614
+ result_dict[:value] = property.value
615
+ result_dict[:details][:value_type] = "DEFAULT_VALUE"
616
+ end
617
+
618
+ result_dict
619
+ end
620
+
621
+ ##
622
+ # Record evaluation
623
+ # @param feature_id [String] Feature ID
624
+ # @param property_id [String] Property ID
625
+ # @param entity_id [String] Entity ID
626
+ # @param segment_id [String] Segment ID
627
+ def record_evaluation(feature_id, property_id, entity_id, segment_id)
628
+ Metering.instance.add_metering(
629
+ @guid,
630
+ @environment_id,
631
+ @collection_id,
632
+ entity_id || Constants::DEFAULT_ENTITY_ID,
633
+ segment_id || Constants::DEFAULT_SEGMENT_ID,
634
+ feature_id,
635
+ property_id
636
+ )
637
+ end
638
+
639
+ ##
640
+ # Feature evaluation
641
+ # @param feature [Feature] Feature object
642
+ # @param entity_id [String] Entity ID
643
+ # @param entity_attributes [Hash] Entity attributes
644
+ # @return [Hash] Evaluation result with keys: value, is_enabled, details
645
+ def feature_evaluation(feature, entity_id, entity_attributes)
646
+ result_dict = {
647
+ evaluated_segment_id: Constants::DEFAULT_SEGMENT_ID,
648
+ value: nil,
649
+ is_enabled: false,
650
+ details: {}
651
+ }
652
+
653
+ begin
654
+ # Step 1: Check if feature flag is enabled
655
+ unless feature.enabled
656
+ result_dict[:details][:value_type] = "DISABLED_VALUE"
657
+ return {
658
+ value: feature.disabled_value,
659
+ is_enabled: false,
660
+ details: result_dict[:details]
661
+ }
662
+ end
663
+
664
+ # Step 2: Check if feature has segment rules (targeting) and valid entity attributes
665
+ if feature.segment_rules&.length&.positive? &&
666
+ entity_attributes.is_a?(Hash) && entity_attributes.keys.length.positive?
667
+ # Evaluate targeting rules
668
+ rules_map = parse_rules(feature.segment_rules)
669
+ result_dict = evaluate_rules(rules_map, entity_attributes, feature, nil, entity_id)
670
+ return {
671
+ value: result_dict[:value],
672
+ is_enabled: result_dict[:is_enabled],
673
+ details: result_dict[:details]
674
+ }
675
+ end
676
+
677
+ # Step 3: No targeting rules - apply default rollout percentage
678
+ # Check if entity_id qualifies for rollout
679
+ rollout_percentage = get_rollout_percentage(feature, nil, entity_id)
680
+ normalized_value = get_normalized_value("#{entity_id}:#{feature.feature_id}")
681
+
682
+ if rollout_percentage == 100 ||
683
+ normalized_value < rollout_percentage
684
+ result_dict[:details][:value_type] = "ENABLED_VALUE"
685
+ result_dict[:details][:rollout_percentage_applied] = true
686
+ return {
687
+ value: feature.enabled_value,
688
+ is_enabled: true,
689
+ details: result_dict[:details]
690
+ }
691
+ end
692
+
693
+ # Step 4: Entity doesn't qualify for rollout
694
+ result_dict[:details][:value_type] = "DISABLED_VALUE"
695
+ result_dict[:details][:rollout_percentage_applied] = false
696
+ {
697
+ value: feature.disabled_value,
698
+ is_enabled: false,
699
+ details: result_dict[:details]
700
+ }
701
+ ensure
702
+ # Always record evaluation for metering
703
+ record_evaluation(feature.feature_id, nil, entity_id, result_dict[:evaluated_segment_id])
704
+ end
705
+ end
706
+
707
+ ##
708
+ # Property evaluation
709
+ # @param property [Property] Property object
710
+ # @param entity_id [String] Entity ID
711
+ # @param entity_attributes [Hash] Entity attributes
712
+ # @return [Hash] Evaluation result
713
+ def property_evaluation(property, entity_id, entity_attributes)
714
+ result_dict = {
715
+ evaluated_segment_id: Constants::DEFAULT_SEGMENT_ID,
716
+ value: nil,
717
+ details: {}
718
+ }
719
+
720
+ begin
721
+ # Check whether the property is configured with any targeting definition
722
+ # and then check whether the user has passed valid entityAttributes JSON before we evaluate
723
+ if property.segment_rules&.length&.positive? &&
724
+ entity_attributes.is_a?(Hash) && entity_attributes.keys.length.positive?
725
+ rules_map = parse_rules(property.segment_rules)
726
+ result_dict = evaluate_rules(rules_map, entity_attributes, nil, property, entity_id)
727
+ return {
728
+ value: result_dict[:value],
729
+ details: result_dict[:details]
730
+ }
731
+ end
732
+
733
+ result_dict[:details][:value_type] = "DEFAULT_VALUE"
734
+ {
735
+ value: property.value,
736
+ details: result_dict[:details]
737
+ }
738
+ ensure
739
+ # Record evaluation for metering
740
+ record_evaluation(nil, property.property_id, entity_id, result_dict[:evaluated_segment_id])
741
+ end
742
+ end
743
+
744
+ ##
745
+ # Get features
746
+ # @return [Hash] Hash of features
747
+ def get_features
748
+ @feature_map
749
+ end
750
+
751
+ ##
752
+ # Get properties
753
+ # @return [Hash] Hash of properties
754
+ def get_properties
755
+ @property_map
756
+ end
757
+
758
+ ##
759
+ # Check if connected
760
+ # @return [Boolean] Connection status
761
+ def connected?
762
+ @is_connected
763
+ end
764
+
765
+ ##
766
+ # Set live update status
767
+ # @param live_update [Boolean] Live update status
768
+ def set_live_update(live_update)
769
+ @live_update = live_update
770
+ end
771
+
772
+ ##
773
+ # Set bootstrap file
774
+ # @param bootstrap_file [String] Path to bootstrap file
775
+ def set_bootstrap_file(bootstrap_file)
776
+ @bootstrap_file = bootstrap_file
777
+ end
778
+
779
+ ##
780
+ # Set persistent cache directory
781
+ # @param directory [String] Cache directory path
782
+ def set_persistent_cache_directory(directory)
783
+ @persistent_cache_directory = directory
784
+ end
785
+
786
+ ##
787
+ # Register configuration update listener
788
+ # Registers a callback block that will be invoked when configurations are updated.
789
+ # Only one listener can be registered at a time (matches Java SDK behavior).
790
+ # Calling this method multiple times will replace the previous listener.
791
+ # @param block [Proc] Callback block to be invoked on configuration updates
792
+ # @example
793
+ # handler.register_configuration_update_listener do
794
+ # puts "Configurations updated!"
795
+ # end
796
+ def register_configuration_update_listener(&block)
797
+ if block_given?
798
+ @configuration_update_listener = block
799
+ @logger.log("Configuration update listener registered")
800
+ else
801
+ @logger.warning("No block provided to register_configuration_update_listener")
802
+ end
803
+ end
804
+
805
+ ##
806
+ # Notify the registered configuration update listener
807
+ # This method is called internally when configurations are updated.
808
+ # The listener is invoked safely - exceptions are caught to prevent breaking the update flow.
809
+ # @private
810
+ def notify_configuration_update_listener
811
+ return unless @configuration_update_listener
812
+
813
+ begin
814
+ @logger.log("Notifying configuration update listener")
815
+ @configuration_update_listener.call
816
+ rescue StandardError => e
817
+ @logger.error("Error in configuration update listener: #{e.message}")
818
+ @logger.error(e.backtrace.first(3).join("\n"))
819
+ end
820
+ end
821
+
822
+ ##
823
+ # Get the secrets map
824
+ # @return [Hash] Hash of secret manager instances mapped by property_id
825
+ def get_secrets_map
826
+ @secret_map
827
+ end
828
+ end