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,269 @@
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 "ibm_cloud_sdk_core"
18
+ require "uri"
19
+ require_relative "url_builder"
20
+ require_relative "../configurations/internal/constants"
21
+ require_relative "../version"
22
+
23
+ ##
24
+ # This module provides the methods to facilitate the API requests to the App Configuration service.
25
+ #
26
+ # The ApiManager class handles:
27
+ # - IAM authentication using IBM Cloud SDK Core
28
+ # - HTTP request headers construction
29
+ # - BaseService client management with retry logic
30
+ # - Bearer token retrieval for WebSocket connections
31
+ #
32
+ # @example Basic usage
33
+ # ApiManager.set_authenticator
34
+ # client = ApiManager.base_service_client
35
+ # token = ApiManager.token
36
+ #
37
+ ##
38
+ # ApiManager facilitates API requests to IBM App Configuration service.
39
+ # Manages authentication, HTTP clients, and request headers.
40
+ #
41
+ class ApiManager
42
+ # SDK version for User-Agent header
43
+ SDK_VERSION = IbmAppconfigurationRubySdk::VERSION
44
+
45
+ # Class variables to store singleton instances
46
+ @iam_authenticator = nil
47
+ @base_service_client = nil
48
+ @url_builder = nil
49
+
50
+ class << self
51
+ ##
52
+ # Get the request headers for API calls
53
+ #
54
+ # @param is_post [Boolean] Whether this is a POST request (adds Content-Type header)
55
+ # @return [Hash] Hash containing required headers
56
+ #
57
+ # @example GET request headers
58
+ # headers = ApiManager.headers
59
+ # # => { 'Accept' => 'application/json', 'User-Agent' => 'appconfiguration-ruby-sdk/0.1.0' }
60
+ #
61
+ # @example POST request headers
62
+ # headers = ApiManager.headers(true)
63
+ # # => { 'Accept' => 'application/json', 'User-Agent' => '...', 'Content-Type' => 'application/json' }
64
+ #
65
+ def headers(is_post = false)
66
+ headers = {
67
+ "Accept" => "application/json",
68
+ "User-Agent" => "appconfiguration-ruby-sdk/#{SDK_VERSION}"
69
+ }
70
+ headers["Content-Type"] = "application/json" if is_post
71
+ headers
72
+ end
73
+
74
+ ##
75
+ # Sets the IAM Authenticator using the API key from UrlBuilder
76
+ #
77
+ # This method initializes the IBM Cloud IAM authenticator with the
78
+ # API key and IAM URL configured in the UrlBuilder singleton.
79
+ #
80
+ # @return [void]
81
+ # @raise [StandardError] If UrlBuilder is not properly configured
82
+ #
83
+ # @example
84
+ # url_builder = UrlBuilder.instance
85
+ # url_builder.apikey = 'your-api-key'
86
+ # url_builder.region = 'us-south'
87
+ # ApiManager.set_authenticator
88
+ #
89
+ def set_authenticator
90
+ @url_builder = UrlBuilder.instance
91
+
92
+ # Create authenticator with apikey and optional URL
93
+ authenticator_options = {
94
+ apikey: @url_builder.apikey
95
+ }
96
+
97
+ # Add URL if it's not the default production URL
98
+ # Check for test/staging environment (iam.test.cloud.ibm.com) or custom URLs
99
+ iam_url = @url_builder.iam_url
100
+ default_prod_url = "#{UrlBuilder::HTTPS_PROTOCOL}#{UrlBuilder::IAM_PROD_URL}"
101
+
102
+ if iam_url && iam_url != default_prod_url
103
+ authenticator_options[:url] = iam_url
104
+ puts "🔧 Using custom IAM URL: #{iam_url}"
105
+ else
106
+ puts "🔧 Using default IAM URL: #{default_prod_url}"
107
+ end
108
+
109
+ @iam_authenticator = IBMCloudSdkCore::IamAuthenticator.new(authenticator_options)
110
+ end
111
+
112
+ ##
113
+ # Get the BaseService client with retry configuration
114
+ #
115
+ # Creates a new BaseService client if one doesn't exist, configured with:
116
+ # - The IAM authenticator
117
+ # - Retry logic (max 3 retries with exponential backoff)
118
+ # - Base service URL from UrlBuilder
119
+ #
120
+ # @return [IBMCloudSdkCore::BaseService] The configured BaseService client
121
+ # @raise [StandardError] If authenticator is not set
122
+ #
123
+ # @example
124
+ # client = ApiManager.base_service_client
125
+ # response = client.request(
126
+ # method: 'GET',
127
+ # url: '/apprapp/feature/v1/instances/guid/config',
128
+ # headers: ApiManager.headers
129
+ # )
130
+ #
131
+ def base_service_client
132
+ if @base_service_client.nil?
133
+ raise "Authenticator not set. Call set_authenticator first." if @iam_authenticator.nil?
134
+
135
+ @url_builder ||= UrlBuilder.instance
136
+
137
+ @base_service_client = IBMCloudSdkCore::BaseService.new(
138
+ service_name: "app_configuration",
139
+ authenticator: @iam_authenticator,
140
+ service_url: @url_builder.base_service_url
141
+ )
142
+
143
+ # Configure retry settings
144
+ # Note: Ruby SDK Core v1.3.0 uses configure_http_client for retry settings
145
+ @base_service_client.configure_http_client(
146
+ timeout: { connect: 60, read: 60, write: 60 }
147
+ )
148
+ end
149
+
150
+ @base_service_client
151
+ end
152
+
153
+ ##
154
+ # Get the IAM bearer token for WebSocket authentication
155
+ #
156
+ # This method authenticates with IAM and retrieves the bearer token
157
+ # that can be used for WebSocket connections.
158
+ #
159
+ # @return [String] The Bearer token (format: "Bearer <token>")
160
+ # @raise [StandardError] If authentication fails
161
+ #
162
+ # @example
163
+ # token = ApiManager.token
164
+ # # => "Bearer eyJraWQiOiIyMDIxMDQyNjE4..."
165
+ #
166
+ # # Use with WebSocket connection
167
+ # headers = { 'Authorization' => token }
168
+ #
169
+ def token
170
+ raise "Authenticator not set. Call set_authenticator first." if @iam_authenticator.nil?
171
+
172
+ begin
173
+ # Create an empty request hash - the SDK will populate it
174
+ request = {}
175
+
176
+ # Force token refresh by setting force_refresh option
177
+ # This ensures we get a fresh token, especially important for reconnections
178
+ # The IBM Cloud SDK Core will check token expiration and refresh if needed
179
+ @iam_authenticator.authenticate(request)
180
+
181
+ # The Ruby SDK puts the Authorization header directly in the request hash
182
+ # Try both string and symbol keys for compatibility
183
+ authorization = request["Authorization"] || request[:Authorization]
184
+
185
+ raise StandardError.new("Authentication succeeded but no Authorization header was set. Request: #{request.inspect}") if authorization.nil?
186
+
187
+ # Log token info for debugging (first 20 chars only for security)
188
+ token_preview = authorization[0..19] if authorization
189
+ puts "🔑 Token obtained: #{token_preview}..."
190
+
191
+ authorization
192
+ rescue StandardError => e
193
+ error_msg = "Failed to get authentication token for websocket connect. Error: #{e.message}"
194
+ puts "❌ Token error details: #{e.class.name} - #{e.message}"
195
+ puts " Backtrace: #{e.backtrace.first(3).join("\n ")}" if e.backtrace
196
+ raise StandardError.new(error_msg)
197
+ end
198
+ end
199
+
200
+ ##
201
+ # Post metering data to the App Configuration service
202
+ #
203
+ # Sends usage metrics for feature and property evaluations to the billing server.
204
+ #
205
+ # @param url [String] The full metering endpoint URL
206
+ # @param data [Hash] The metering data to send
207
+ # @param apikey [String] The API key for authentication
208
+ # @return [IBMCloudSdkCore::DetailedResponse] The HTTP response
209
+ # @raise [StandardError] If the request fails
210
+ #
211
+ # @example
212
+ # data = {
213
+ # 'collection_id' => 'coll-1',
214
+ # 'environment_id' => 'env-prod',
215
+ # 'usages' => [...]
216
+ # }
217
+ # response = ApiManager.post_metering(url, data, apikey)
218
+ #
219
+ def post_metering(url, metering_data, _apikey)
220
+ require "json"
221
+
222
+ # Extract the path from the full URL
223
+ uri = URI.parse(url)
224
+ path = uri.path
225
+
226
+ # The IBM Cloud Ruby SDK's BaseService.request method signature:
227
+ # request(method:, url:, headers: nil, params: nil, json: nil, data: nil)
228
+ # For POST with JSON body, we should use the 'json' parameter (not 'body')
229
+ client = base_service_client
230
+
231
+ client.request(
232
+ method: "POST",
233
+ url: path,
234
+ headers: headers(true),
235
+ json: metering_data # Use 'json' parameter for JSON body
236
+ )
237
+ end
238
+
239
+ ##
240
+ # Get the IAM Authenticator instance
241
+ #
242
+ # @return [IBMCloudSdkCore::IamAuthenticator, nil] The IAM authenticator or nil if not set
243
+ #
244
+ # @example
245
+ # authenticator = ApiManager.iam_authenticator
246
+ # if authenticator
247
+ # puts "Authenticator is configured"
248
+ # end
249
+ #
250
+ attr_reader :iam_authenticator
251
+
252
+ ##
253
+ # Reset the ApiManager state (useful for testing)
254
+ #
255
+ # Clears all cached instances, forcing re-initialization on next use.
256
+ #
257
+ # @return [void]
258
+ #
259
+ # @example
260
+ # ApiManager.reset!
261
+ # # All instances cleared, will be recreated on next access
262
+ #
263
+ def reset!
264
+ @iam_authenticator = nil
265
+ @base_service_client = nil
266
+ @url_builder = nil
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,400 @@
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 "../configurations/internal/logger"
20
+ require_relative "../configurations/internal/constants"
21
+ require_relative "api_manager"
22
+
23
+ # Copyright 2021 IBM Corp. All Rights Reserved.
24
+ #
25
+ # Licensed under the Apache License, Version 2.0 (the "License");
26
+ # you may not use this file except in compliance with the License.
27
+ # You may obtain a copy of the License at
28
+ #
29
+ # http://www.apache.org/licenses/LICENSE-2.0
30
+ #
31
+ # Unless required by applicable law or agreed to in writing, software
32
+ # distributed under the License is distributed on an "AS IS" BASIS,
33
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
34
+ # See the License for the specific language governing permissions and
35
+ # limitations under the License.
36
+
37
+ ##
38
+ # Metering module tracks feature and property evaluation metrics and sends them
39
+ # to the App Configuration billing server at regular intervals (every 10 minutes).
40
+ #
41
+ # This implementation uses Ruby's native Mutex and Thread for thread safety.
42
+ #
43
+ # @example Basic usage
44
+ # metering = Metering.instance
45
+ # metering.set_metering_url(url, apikey)
46
+ # metering.add_metering(guid, env_id, coll_id, entity_id, segment_id, feature_id, nil)
47
+ #
48
+ class Metering
49
+ include Singleton
50
+
51
+ # Delimiter for composite keys (Unit Separator character)
52
+ DELIMITER = "\u001F"
53
+
54
+ # Metering interval in seconds (10 minutes)
55
+ METERING_INTERVAL = 600
56
+
57
+ ##
58
+ # Initialize the Metering singleton
59
+ def initialize
60
+ @metering_feature_data = {}
61
+ @metering_property_data = {}
62
+ @data_mutex = Mutex.new
63
+ @metering_url = nil
64
+ @apikey = nil
65
+ @metering_thread = nil
66
+ @logger = Logger.instance
67
+ start_metering_thread
68
+ end
69
+
70
+ ##
71
+ # Inner class for storing metering records
72
+ # Tracks count and latest evaluation time with thread-safe operations
73
+ class MeteringRecord
74
+ ##
75
+ # Initialize a new metering record
76
+ #
77
+ # @param time [String] Initial evaluation time in ISO 8601 format
78
+ def initialize(time)
79
+ @count = 1
80
+ @latest_time = time
81
+ @mutex = Mutex.new
82
+ end
83
+
84
+ ##
85
+ # Increment the count and update latest time if newer
86
+ #
87
+ # @param new_time [String] New evaluation time in ISO 8601 format
88
+ def increment(new_time)
89
+ @mutex.synchronize do
90
+ @count += 1
91
+ @latest_time = new_time if new_time > @latest_time
92
+ end
93
+ end
94
+
95
+ ##
96
+ # Get the current count (thread-safe)
97
+ #
98
+ # @return [Integer] The evaluation count
99
+ def get_count
100
+ @mutex.synchronize { @count }
101
+ end
102
+
103
+ ##
104
+ # Get the latest evaluation time (thread-safe)
105
+ #
106
+ # @return [String] The latest evaluation time
107
+ def get_latest_time
108
+ @mutex.synchronize { @latest_time }
109
+ end
110
+ end
111
+
112
+ ##
113
+ # Set the metering URL and API key
114
+ #
115
+ # @param url [String] The metering endpoint URL
116
+ # @param apikey [String] The API key for authentication
117
+ def set_metering_url(url, apikey)
118
+ @metering_url = url
119
+ @apikey = apikey
120
+ end
121
+
122
+ ##
123
+ # Add a metering record for a feature or property evaluation
124
+ #
125
+ # @param guid [String] The service instance GUID
126
+ # @param environment_id [String] The environment ID
127
+ # @param collection_id [String] The collection ID
128
+ # @param entity_id [String] The entity ID
129
+ # @param segment_id [String] The segment ID
130
+ # @param feature_id [String, nil] The feature ID (nil for property evaluations)
131
+ # @param property_id [String, nil] The property ID (nil for feature evaluations)
132
+ def add_metering(guid, environment_id, collection_id, entity_id, segment_id, feature_id, property_id)
133
+ key = build_composite_key(
134
+ guid,
135
+ environment_id,
136
+ collection_id,
137
+ feature_id || property_id,
138
+ entity_id,
139
+ segment_id
140
+ )
141
+
142
+ data_map = feature_id ? @metering_feature_data : @metering_property_data
143
+ evaluation_time = current_datetime
144
+
145
+ @data_mutex.synchronize do
146
+ if data_map.key?(key)
147
+ data_map[key].increment(evaluation_time)
148
+ else
149
+ data_map[key] = MeteringRecord.new(evaluation_time)
150
+ end
151
+ end
152
+ end
153
+
154
+ ##
155
+ # Build a composite key from components
156
+ # Handles nil values by converting to empty strings
157
+ #
158
+ # @param guid [String] The service instance GUID
159
+ # @param env_id [String] The environment ID
160
+ # @param coll_id [String] The collection ID
161
+ # @param modify_key [String] The feature or property ID
162
+ # @param entity_id [String] The entity ID
163
+ # @param segment_id [String] The segment ID
164
+ # @return [String] The composite key
165
+ def build_composite_key(guid, env_id, coll_id, modify_key, entity_id, segment_id)
166
+ [
167
+ guid || "",
168
+ env_id || "",
169
+ coll_id || "",
170
+ modify_key || "",
171
+ entity_id || "",
172
+ segment_id || ""
173
+ ].join(DELIMITER)
174
+ end
175
+
176
+ ##
177
+ # Parse a composite key into its components
178
+ #
179
+ # @param composite_key [String] The composite key to parse
180
+ # @return [Array<String>] Array of key components
181
+ def parse_composite_key(composite_key)
182
+ composite_key.split(DELIMITER, -1)
183
+ end
184
+
185
+ ##
186
+ # Get current datetime in ISO 8601 format
187
+ #
188
+ # @return [String] Current datetime string
189
+ def current_datetime
190
+ Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
191
+ end
192
+
193
+ ##
194
+ # Send metering data to the server
195
+ # Atomically swaps data maps to avoid blocking new evaluations
196
+ #
197
+ # @return [Hash] The request body that was sent
198
+ def send_metering
199
+ # Atomic swap of data maps
200
+ current_feature_data = nil
201
+ current_property_data = nil
202
+
203
+ @data_mutex.synchronize do
204
+ current_feature_data = @metering_feature_data
205
+ current_property_data = @metering_property_data
206
+ @metering_feature_data = {}
207
+ @metering_property_data = {}
208
+ end
209
+
210
+ return {} if current_feature_data.empty? && current_property_data.empty?
211
+
212
+ result = {}
213
+
214
+ build_request_body(current_feature_data, result, "feature_id") unless current_feature_data.empty?
215
+ build_request_body(current_property_data, result, "property_id") unless current_property_data.empty?
216
+
217
+ result.each_value do |data_array|
218
+ data_array.each do |json|
219
+ count = json["usages"].length
220
+ if count > 25
221
+ send_split_metering(json, count)
222
+ else
223
+ send_to_server(json)
224
+ end
225
+ end
226
+ end
227
+
228
+ result
229
+ end
230
+
231
+ ##
232
+ # Build the request body from metering data
233
+ #
234
+ # @param send_metering_data [Hash] The metering data to process
235
+ # @param result [Hash] The result hash to populate
236
+ # @param key [String] Either 'feature_id' or 'property_id'
237
+ def build_request_body(send_metering_data, result, key)
238
+ send_metering_data.each do |composite_key, metering_record|
239
+ key_parts = parse_composite_key(composite_key)
240
+ next if key_parts.length != 6
241
+
242
+ guid = key_parts[0]
243
+ environment_id = key_parts[1]
244
+ collection_id = key_parts[2]
245
+ feature_or_property_id = key_parts[3]
246
+ entity_id = key_parts[4]
247
+ segment_id = key_parts[5]
248
+
249
+ # Get or create GUID entry
250
+ result[guid] ||= []
251
+
252
+ # Find or create collection
253
+ collection = find_or_create_collection(
254
+ result[guid],
255
+ environment_id,
256
+ collection_id
257
+ )
258
+
259
+ # Create usage object
260
+ usage = {
261
+ key => feature_or_property_id,
262
+ "entity_id" => entity_id == Constants::DEFAULT_ENTITY_ID ? nil : entity_id,
263
+ "segment_id" => segment_id == Constants::DEFAULT_SEGMENT_ID ? nil : segment_id,
264
+ "evaluation_time" => metering_record.get_latest_time,
265
+ "count" => metering_record.get_count
266
+ }
267
+
268
+ collection["usages"] << usage
269
+ end
270
+ end
271
+
272
+ ##
273
+ # Find or create a collection in the GUID array
274
+ #
275
+ # @param guid_array [Array] Array of collections for a GUID
276
+ # @param environment_id [String] The environment ID
277
+ # @param collection_id [String] The collection ID
278
+ # @return [Hash] The collection hash
279
+ def find_or_create_collection(guid_array, environment_id, collection_id)
280
+ # Look for existing collection
281
+ collection = guid_array.find do |coll|
282
+ coll["environment_id"] == environment_id &&
283
+ coll["collection_id"] == collection_id
284
+ end
285
+
286
+ # Create new if not found
287
+ unless collection
288
+ collection = {
289
+ "collection_id" => collection_id,
290
+ "environment_id" => environment_id,
291
+ "usages" => []
292
+ }
293
+ guid_array << collection
294
+ end
295
+
296
+ collection
297
+ end
298
+
299
+ ##
300
+ # Send split metering data for large payloads
301
+ # Splits payloads with >25 usages into chunks of 10
302
+ #
303
+ # @param data [Hash] The collection data to split
304
+ # @param count [Integer] Total number of usages
305
+ def send_split_metering(data, count)
306
+ lim = 0
307
+ sub_usages_array = data["usages"]
308
+
309
+ while lim < count
310
+ end_index = [lim + Constants::DEFAULT_USAGE_LIMIT, count].min
311
+ collections_map = {
312
+ "collection_id" => data["collection_id"],
313
+ "environment_id" => data["environment_id"],
314
+ "usages" => []
315
+ }
316
+
317
+ (lim...end_index).each do |i|
318
+ collections_map["usages"] << sub_usages_array[i]
319
+ end
320
+
321
+ send_to_server(collections_map)
322
+ lim += Constants::DEFAULT_USAGE_LIMIT
323
+ end
324
+ end
325
+
326
+ ##
327
+ # Send metering data to the server
328
+ # Retries on 429 and 5xx errors with exponential backoff
329
+ #
330
+ # @param data [Hash] The metering data to send
331
+ # @param retry_count [Integer] Current retry attempt (for exponential backoff)
332
+ def send_to_server(data, retry_count = 0)
333
+ return unless @metering_url && @apikey
334
+
335
+ begin
336
+ response = ApiManager.post_metering(@metering_url, data, @apikey)
337
+
338
+ # Success - no logging needed for normal operation
339
+ @logger.warning("Metering response status: #{response.status}") if response.status != Constants::STATUS_CODE_ACCEPTED
340
+ rescue StandardError => e
341
+ @logger.error("Exception occurred while sending metering data: #{e.message}")
342
+
343
+ # Extract status code from the error
344
+ status = nil
345
+ if e.respond_to?(:status)
346
+ status = e.status
347
+ elsif e.message =~ /status_code.*=>.*(\d{3})/
348
+ status = ::Regexp.last_match(1).to_i
349
+ end
350
+
351
+ # Retry on 429 (rate limit) or 5xx (server errors) with exponential backoff
352
+ # Don't retry on 4xx client errors (except 429)
353
+ max_retries = 3
354
+ if (status == 429 || (status && status >= 500 && status <= 599)) && retry_count < max_retries
355
+ # Exponential backoff: 30s, 60s, 120s
356
+ backoff_time = 30 * (2**retry_count)
357
+ @logger.info("Retrying metering request in #{backoff_time}s (attempt #{retry_count + 1}/#{max_retries})")
358
+
359
+ Thread.new do
360
+ sleep(backoff_time)
361
+ send_to_server(data, retry_count + 1)
362
+ end
363
+ elsif retry_count >= max_retries
364
+ @logger.error("Max retries (#{max_retries}) reached for metering data. Giving up.")
365
+ end
366
+ end
367
+ end
368
+
369
+ ##
370
+ # Start the background metering thread
371
+ # Sends metering data every 10 minutes
372
+ def start_metering_thread
373
+ @metering_thread = Thread.new do
374
+ loop do
375
+ sleep(METERING_INTERVAL)
376
+ begin
377
+ send_metering
378
+ rescue StandardError => e
379
+ @logger.error("Error in metering thread: #{e.message}")
380
+ end
381
+ end
382
+ end
383
+
384
+ @metering_thread.abort_on_exception = false
385
+ end
386
+
387
+ ##
388
+ # Stop the metering thread
389
+ def stop_metering_thread
390
+ @metering_thread&.kill
391
+ @metering_thread = nil
392
+ end
393
+
394
+ ##
395
+ # Cleanup method - stops thread and sends remaining data
396
+ def cleanup
397
+ stop_metering_thread
398
+ send_metering # Send any remaining data
399
+ end
400
+ end