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,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
|