Class: IbmAppconfigurationRubySdk::Metering

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

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



46
47
48
49
50
51
52
53
54
55
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 46

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)



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 119

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



152
153
154
155
156
157
158
159
160
161
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 152

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'



224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 224

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



429
430
431
432
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 429

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



176
177
178
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 176

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



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 266

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

#metering_compute_base_delay_msObject

Compute base delay with jitter (~2–2.9 minutes), matches Node's computeBaseDelayMs



384
385
386
387
388
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 384

def metering_compute_base_delay_ms
  base_ms   = 2 * 60 * 1000            # 2 minutes
  jitter_ms = (0.9 * 60 * 1000 * rand).floor # 0–54 seconds
  base_ms + jitter_ms
end

#metering_compute_cap_delay_msObject

Compute cap delay with jitter (~60–60.59 minutes), matches Node's computeCapDelayMs



391
392
393
394
395
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 391

def metering_compute_cap_delay_ms
  base_ms        = 60 * 60 * 1000      # 1 hour
  jitter_seconds = rand(60)            # 0–59 seconds
  base_ms + (jitter_seconds * 1000)
end

#metering_compute_next_delay_ms(attempt, cap_ms) ⇒ Object

Exponential backoff capped at cap_ms, matches Node's computeNextDelayMs



398
399
400
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 398

def metering_compute_next_delay_ms(attempt, cap_ms)
  [metering_compute_base_delay_ms * (2**attempt), cap_ms].min
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



168
169
170
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 168

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



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 185

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 > Constants::DEFAULT_USAGE_LIMIT
        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 exceeding DEFAULT_USAGE_LIMIT usages into chunks of DEFAULT_USAGE_LIMIT

Parameters:

  • data (Hash)

    The collection data to split

  • count (Integer)

    Total number of usages



292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 292

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, attempt = 0, cap_ms = nil) ⇒ Object

Send metering data to the server. Retries indefinitely with exponential backoff + jitter, capped at ~1 hour — matching the Node SDK's Metering.js behaviour exactly.

Both the initial call and every retry thread are fully wrapped in rescue so that a connection-level crash never silently kills the retry chain — each thread stays alive and schedules the next attempt regardless of how it failed.

Parameters:

  • data (Hash)

    The metering data to send

  • attempt (Integer) (defaults to: 0)

    Current attempt number (0-based, for backoff calculation)

  • cap_ms (Integer, nil) (defaults to: nil)

    Cap delay in ms (computed once per payload on first call)



325
326
327
328
329
330
331
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
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 325

def send_to_server(data, attempt = 0, cap_ms = nil)
  return unless @metering_url && @apikey

  # Compute cap once per payload (with jitter), reuse on retries
  cap_ms ||= metering_compute_cap_delay_ms

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

    if response.status == Constants::STATUS_CODE_ACCEPTED
      @logger.info(Constants::SUCCESSFULLY_POSTED_METERING_DATA)
    else
      @logger.warning("Metering response status: #{response.status}")
    end
  rescue StandardError => e
    @logger.error("#{Constants::ERROR_POSTING_METERING_DATA}. #{e.class.name} - #{e.message}")

    # Extract HTTP status from the exception (nil for connection-level errors)
    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

    # Mirrors Node SDK: retryable = 5xx || 429 || status undefined (connection/server-down error)
    # Do NOT retry on 4xx client errors (except 429)
    retryable = status.nil? || status == 429 || (status >= 500 && status <= 599)

    unless retryable
      @logger.error("Non-retryable metering error (status #{status}) — giving up.")
      return
    end

    # Exponential backoff with jitter, capped at ~1 hour (same as Node SDK)
    delay_ms  = metering_compute_next_delay_ms(attempt, cap_ms)
    delay_min = (delay_ms / 60_000.0).round(2)
    @logger.info("Retrying metering POST in #{delay_min} min (attempt ##{attempt + 1}, cap #{(cap_ms / 60_000.0).round(2)} min)")

    # Capture locals so the retry thread closes over immutable values, not mutable state.
    next_attempt = attempt + 1
    frozen_cap   = cap_ms
    frozen_data  = data

    Thread.new do
      begin
        sleep(delay_ms / 1000.0)
        send_to_server(frozen_data, next_attempt, frozen_cap)
      rescue StandardError => retry_err
        # If the retry thread itself crashes (e.g. interrupted sleep, unexpected raise),
        # schedule one more attempt so the payload is never silently dropped.
        @logger.error("Metering retry thread error: #{retry_err.class.name} - #{retry_err.message}")
        send_to_server(frozen_data, next_attempt, frozen_cap)
      end
    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



104
105
106
107
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 104

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



405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 405

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.class.name} - #{e.message}")
      end
    end
  end

  @metering_thread.abort_on_exception = false
end

#stop_metering_threadObject

Stop the metering thread



422
423
424
425
# File 'lib/ibm_appconfiguration_ruby_sdk/metering.rb', line 422

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