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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +76 -0
- data/CONTRIBUTING.md +9 -0
- data/LICENSE +201 -0
- data/README.md +474 -0
- data/Rakefile +8 -0
- data/examples/README.md +60 -0
- data/examples/app.rb +104 -0
- data/lib/ibm_appconfiguration_ruby_sdk/app_configuration.rb +291 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/configuration_handler.rb +828 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/constants.rb +89 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/file_manager.rb +72 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/logger.rb +98 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/background_retry_manager.rb +284 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/config_fetcher.rb +254 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/utils.rb +240 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/connection_manager.rb +501 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/connectivity.rb +30 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/driver_socket.rb +28 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/retry_policy.rb +42 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/state.rb +24 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/watchdog.rb +50 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/websocket_client.rb +43 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/feature.rb +121 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/property.rb +107 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/rule.rb +87 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/secret_property.rb +81 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/segment.rb +39 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/segment_rules.rb +57 -0
- data/lib/ibm_appconfiguration_ruby_sdk/core/api_manager.rb +269 -0
- data/lib/ibm_appconfiguration_ruby_sdk/core/metering.rb +400 -0
- data/lib/ibm_appconfiguration_ruby_sdk/core/url_builder.rb +252 -0
- data/lib/ibm_appconfiguration_ruby_sdk/version.rb +20 -0
- data/lib/ibm_appconfiguration_ruby_sdk.rb +20 -0
- data/sig/ibm_appconfiguration_ruby_sdk.rbs +4 -0
- 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
|