Class: Metering

Inherits:
Object
  • Object
show all
Includes:
Singleton
Defined in:
lib/ibm_appconfiguration_ruby_sdk/core/metering.rb

Overview

Metering module tracks feature and property evaluation metrics and sends them to the App Configuration billing server at regular intervals (every 10 minutes).

This implementation uses Ruby’s native Mutex and Thread for thread safety.

Examples:

Basic usage

metering = Metering.instance
metering.set_metering_url(url, apikey)
metering.add_metering(guid, env_id, coll_id, entity_id, segment_id, feature_id, nil)

Defined Under Namespace

Classes: MeteringRecord

Constant Summary collapse

DELIMITER =

Delimiter for composite keys (Unit Separator character)

"\u001F"
METERING_INTERVAL =

Metering interval in seconds (10 minutes)

600

Instance Method Summary collapse

Constructor Details

#initializeMetering

Initialize the Metering singleton



59
60
61
62
63
64
65
66
67
68
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 59

def initialize
  @metering_feature_data = {}
  @metering_property_data = {}
  @data_mutex = Mutex.new
  @metering_url = nil
  @apikey = nil
  @metering_thread = nil
  @logger = Logger.instance
  start_metering_thread
end

Instance Method Details

#add_metering(guid, environment_id, collection_id, entity_id, segment_id, feature_id, property_id) ⇒ Object

Add a metering record for a feature or property evaluation

Parameters:

  • guid (String)

    The service instance GUID

  • environment_id (String)

    The environment ID

  • collection_id (String)

    The collection ID

  • entity_id (String)

    The entity ID

  • segment_id (String)

    The segment ID

  • feature_id (String, nil)

    The feature ID (nil for property evaluations)

  • property_id (String, nil)

    The property ID (nil for feature evaluations)



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 132

def add_metering(guid, environment_id, collection_id, entity_id, segment_id, feature_id, property_id)
  key = build_composite_key(
    guid,
    environment_id,
    collection_id,
    feature_id || property_id,
    entity_id,
    segment_id
  )

  data_map = feature_id ? @metering_feature_data : @metering_property_data
  evaluation_time = current_datetime

  @data_mutex.synchronize do
    if data_map.key?(key)
      data_map[key].increment(evaluation_time)
    else
      data_map[key] = MeteringRecord.new(evaluation_time)
    end
  end
end

#build_composite_key(guid, env_id, coll_id, modify_key, entity_id, segment_id) ⇒ String

Build a composite key from components Handles nil values by converting to empty strings

Parameters:

  • guid (String)

    The service instance GUID

  • env_id (String)

    The environment ID

  • coll_id (String)

    The collection ID

  • modify_key (String)

    The feature or property ID

  • entity_id (String)

    The entity ID

  • segment_id (String)

    The segment ID

Returns:

  • (String)

    The composite key



165
166
167
168
169
170
171
172
173
174
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 165

def build_composite_key(guid, env_id, coll_id, modify_key, entity_id, segment_id)
  [
    guid || "",
    env_id || "",
    coll_id || "",
    modify_key || "",
    entity_id || "",
    segment_id || ""
  ].join(DELIMITER)
end

#build_request_body(send_metering_data, result, key) ⇒ Object

Build the request body from metering data

Parameters:

  • send_metering_data (Hash)

    The metering data to process

  • result (Hash)

    The result hash to populate

  • key (String)

    Either ‘feature_id’ or ‘property_id’



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 237

def build_request_body(send_metering_data, result, key)
  send_metering_data.each do |composite_key, metering_record|
    key_parts = parse_composite_key(composite_key)
    next if key_parts.length != 6

    guid = key_parts[0]
    environment_id = key_parts[1]
    collection_id = key_parts[2]
    feature_or_property_id = key_parts[3]
    entity_id = key_parts[4]
    segment_id = key_parts[5]

    # Get or create GUID entry
    result[guid] ||= []

    # Find or create collection
    collection = find_or_create_collection(
      result[guid],
      environment_id,
      collection_id
    )

    # Create usage object
    usage = {
      key => feature_or_property_id,
      "entity_id" => entity_id == Constants::DEFAULT_ENTITY_ID ? nil : entity_id,
      "segment_id" => segment_id == Constants::DEFAULT_SEGMENT_ID ? nil : segment_id,
      "evaluation_time" => metering_record.get_latest_time,
      "count" => metering_record.get_count
    }

    collection["usages"] << usage
  end
end

#cleanupObject

Cleanup method - stops thread and sends remaining data



396
397
398
399
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 396

def cleanup
  stop_metering_thread
  send_metering # Send any remaining data
end

#current_datetimeString

Get current datetime in ISO 8601 format

Returns:

  • (String)

    Current datetime string



189
190
191
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 189

def current_datetime
  Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
end

#find_or_create_collection(guid_array, environment_id, collection_id) ⇒ Hash

Find or create a collection in the GUID array

Parameters:

  • guid_array (Array)

    Array of collections for a GUID

  • environment_id (String)

    The environment ID

  • collection_id (String)

    The collection ID

Returns:

  • (Hash)

    The collection hash



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 279

def find_or_create_collection(guid_array, environment_id, collection_id)
  # Look for existing collection
  collection = guid_array.find do |coll|
    coll["environment_id"] == environment_id &&
      coll["collection_id"] == collection_id
  end

  # Create new if not found
  unless collection
    collection = {
      "collection_id" => collection_id,
      "environment_id" => environment_id,
      "usages" => []
    }
    guid_array << collection
  end

  collection
end

#parse_composite_key(composite_key) ⇒ Array<String>

Parse a composite key into its components

Parameters:

  • composite_key (String)

    The composite key to parse

Returns:

  • (Array<String>)

    Array of key components



181
182
183
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 181

def parse_composite_key(composite_key)
  composite_key.split(DELIMITER, -1)
end

#send_meteringHash

Send metering data to the server Atomically swaps data maps to avoid blocking new evaluations

Returns:

  • (Hash)

    The request body that was sent



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 198

def send_metering
  # Atomic swap of data maps
  current_feature_data = nil
  current_property_data = nil

  @data_mutex.synchronize do
    current_feature_data = @metering_feature_data
    current_property_data = @metering_property_data
    @metering_feature_data = {}
    @metering_property_data = {}
  end

  return {} if current_feature_data.empty? && current_property_data.empty?

  result = {}

  build_request_body(current_feature_data, result, "feature_id") unless current_feature_data.empty?
  build_request_body(current_property_data, result, "property_id") unless current_property_data.empty?

  result.each_value do |data_array|
    data_array.each do |json|
      count = json["usages"].length
      if count > 25
        send_split_metering(json, count)
      else
        send_to_server(json)
      end
    end
  end

  result
end

#send_split_metering(data, count) ⇒ Object

Send split metering data for large payloads Splits payloads with >25 usages into chunks of 10

Parameters:

  • data (Hash)

    The collection data to split

  • count (Integer)

    Total number of usages



305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 305

def send_split_metering(data, count)
  lim = 0
  sub_usages_array = data["usages"]

  while lim < count
    end_index = [lim + Constants::DEFAULT_USAGE_LIMIT, count].min
    collections_map = {
      "collection_id" => data["collection_id"],
      "environment_id" => data["environment_id"],
      "usages" => []
    }

    (lim...end_index).each do |i|
      collections_map["usages"] << sub_usages_array[i]
    end

    send_to_server(collections_map)
    lim += Constants::DEFAULT_USAGE_LIMIT
  end
end

#send_to_server(data, retry_count = 0) ⇒ Object

Send metering data to the server Retries on 429 and 5xx errors with exponential backoff

Parameters:

  • data (Hash)

    The metering data to send

  • retry_count (Integer) (defaults to: 0)

    Current retry attempt (for exponential backoff)



332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 332

def send_to_server(data, retry_count = 0)
  return unless @metering_url && @apikey

  begin
    response = ApiManager.post_metering(@metering_url, data, @apikey)

    # Success - no logging needed for normal operation
    @logger.warning("Metering response status: #{response.status}") if response.status != Constants::STATUS_CODE_ACCEPTED
  rescue StandardError => e
    @logger.error("Exception occurred while sending metering data: #{e.message}")

    # Extract status code from the error
    status = nil
    if e.respond_to?(:status)
      status = e.status
    elsif e.message =~ /status_code.*=>.*(\d{3})/
      status = ::Regexp.last_match(1).to_i
    end

    # Retry on 429 (rate limit) or 5xx (server errors) with exponential backoff
    # Don't retry on 4xx client errors (except 429)
    max_retries = 3
    if (status == 429 || (status && status >= 500 && status <= 599)) && retry_count < max_retries
      # Exponential backoff: 30s, 60s, 120s
      backoff_time = 30 * (2**retry_count)
      @logger.info("Retrying metering request in #{backoff_time}s (attempt #{retry_count + 1}/#{max_retries})")

      Thread.new do
        sleep(backoff_time)
        send_to_server(data, retry_count + 1)
      end
    elsif retry_count >= max_retries
      @logger.error("Max retries (#{max_retries}) reached for metering data. Giving up.")
    end
  end
end

#set_metering_url(url, apikey) ⇒ Object

Set the metering URL and API key

Parameters:

  • url (String)

    The metering endpoint URL

  • apikey (String)

    The API key for authentication



117
118
119
120
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 117

def set_metering_url(url, apikey)
  @metering_url = url
  @apikey = apikey
end

#start_metering_threadObject

Start the background metering thread Sends metering data every 10 minutes



372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 372

def start_metering_thread
  @metering_thread = Thread.new do
    loop do
      sleep(METERING_INTERVAL)
      begin
        send_metering
      rescue StandardError => e
        @logger.error("Error in metering thread: #{e.message}")
      end
    end
  end

  @metering_thread.abort_on_exception = false
end

#stop_metering_threadObject

Stop the metering thread



389
390
391
392
# File 'lib/ibm_appconfiguration_ruby_sdk/core/metering.rb', line 389

def stop_metering_thread
  @metering_thread&.kill
  @metering_thread = nil
end