abmeter 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d4ea3bdc7addec399a8644a3d8d2ee8f0329b4d85b117a56f4df51c58e04816
4
- data.tar.gz: b03e52a1fe306fe68b7dd63879ff067e59f358bdf885d4a8905e86d743ba282d
3
+ metadata.gz: 127c157ec200964f199fb170d036d9b9898f717a1634002d02dcd211c3296387
4
+ data.tar.gz: 9774017d52cf7b9925eddd837fd4972a693cfd5b76f11c4fe20bd8692d85ddec
5
5
  SHA512:
6
- metadata.gz: 18f1020d7926f3e7026441877010f730ef7ffc06078ad738a7be0121c9399465d3b056e39b6d0133f97d32fced489bec9a6f26c9d99f4f817e223b8927260eaf
7
- data.tar.gz: 9952d1a56ed63530d25200b21d88b0aaf3aec868752b72c46029e5905af5ff20af1eb45c472323afa20251694949ba9df0ff611d13a1633d0a110546d1aa41a5
6
+ metadata.gz: 6174b2a6d2308658310f5a2f35df3d3a986f0979b2a87cd318b3f051469074fa1cf2ead76a53b4a18569dd50d28181b6bdde256c9086be27612e3e12525efa32
7
+ data.tar.gz: eee251586f7279d59edbb6049e5265156afd1858383752dfb4c280d97dc32dd04543c2e44cf8e8f9348046194ec6bcf5b91c651299a0b53e17716e0fdd069ca6
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ABMeter Gem
2
2
 
3
- A simple A/B testing client library for Ruby applications.
3
+ [ABMeter](https://abmeter.ai) is a feature-flag and A/B-testing platform. You define parameters, experiments, and feature flags in the ABMeter Lab; this gem reads the value assigned to each user in your Ruby app and reports events back.
4
4
 
5
5
  > **Breaking change in 0.3.0:** `ABMeter.reset!` now discards queued data without any network I/O; use `ABMeter.reset` for the previous drain-on-exit behavior.
6
6
 
@@ -22,24 +22,35 @@ And then execute:
22
22
  $ bundle install
23
23
  ```
24
24
 
25
+ ## Getting an API key
26
+
27
+ Sign up at [abmeter.ai](https://abmeter.ai), then in the **Lab** open **API Keys** and create one. Expose it to your app (e.g. as `ABMETER_API_KEY`).
28
+
25
29
  ## Usage
26
30
 
31
+ Configure the client once at startup:
32
+
27
33
  ```ruby
28
- # configure the client
29
34
  ABMeter.configure do |config|
30
35
  config.api_key = ENV['ABMETER_API_KEY']
31
36
  end
37
+ ```
32
38
 
33
- # Somewhere in the renedring code:
34
- user = ABMeter.user(id: current_user.id, email: current_user.email)
35
- text = ABMeter.param('welcome_text', user)
39
+ The example below assumes a parameter `welcome_text` (in a space, with a variant, controlled by a running experiment or feature flag) and an event type `user_purchases_plan` already exist — create them through the ABMeter API or, more easily, your AI assistant over MCP. Until they do, `resolve_parameter` just returns the parameter's default and `track_event` rejects unknown event types.
36
40
 
37
- # Somewhere in the model code:
38
- current_user.plan = purchased_plan.name
39
- user = ABMeter.user(id: current_user.id, email: current_user.email)
40
- ABMeter.event(`user_purchases_plan`, user, {plan: purchased_plan.name, price: purchased_plan.price})
41
+ ```ruby
42
+ # In request handling: read the value assigned to this user. `user_id` is the
43
+ # only required field; an optional `email:` is available for predicate-based
44
+ # (email-pattern) audience targeting.
45
+ user = ABMeter::User.new(user_id: current_user.id)
46
+ text = ABMeter.resolve_parameter(user: user, parameter_slug: 'welcome_text')
47
+
48
+ # In business logic: record an action that feeds a metric.
49
+ ABMeter.track_event('user_purchases_plan', current_user.id, { plan: purchased_plan.name, price: purchased_plan.price })
41
50
  ```
42
51
 
52
+ The two calls are one loop: the experiment varies `welcome_text`, and the event feeds a metric whose effect the experiment measures across variants. Note that `resolve_parameter` is **not** a pure read — it resolves the value locally and records an exposure in the background. Full guides live at [abmeter.ai](https://abmeter.ai).
53
+
43
54
  ## Shutdown
44
55
 
45
56
  ```ruby
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Regenerates the cross-language num-utils parity fixture consumed by every
5
+ # ported SDK (sdk-python/tests/test_num_utils_parity.py,
6
+ # sdk-node/tests/num-utils-parity.test.ts).
7
+ #
8
+ # Emits 1000 [salt, user_id, expected] triples where `expected` is the integer
9
+ # returned by ABMeter::Core::Utils::NumUtils.to_percentage. Each ported
10
+ # implementation must reproduce every row. Run from the abmeter-sdk directory:
11
+ #
12
+ # bundle exec ruby bin/generate_num_utils_parity_fixture.rb
13
+ #
14
+ # Regenerate only when the assignment algorithm changes (it shouldn't).
15
+
16
+ require 'json'
17
+ require_relative '../lib/abmeter/core/utils/num_utils'
18
+
19
+ FIXTURE_PATHS = [
20
+ '../../sdk-python/tests/fixtures/num_utils_parity.json',
21
+ '../../sdk-node/tests/fixtures/num_utils_parity.json'
22
+ ].map { |path| File.expand_path(path, __dir__) }
23
+ ROW_COUNT = 1000
24
+
25
+ rows = Array.new(ROW_COUNT) do |i|
26
+ # Deterministic, varied inputs — mixed shapes exercise the hashing across
27
+ # salts and user-id formats without depending on RNG.
28
+ salt = "space-salt-#{i}-#{format('%04d', (i * 7) % 9973)}"
29
+ user_id = "user_#{((i * 31) + 5) % 100_003}"
30
+ [salt, user_id, ABMeter::Core::Utils::NumUtils.to_percentage(salt, user_id)]
31
+ end
32
+
33
+ FIXTURE_PATHS.each do |path|
34
+ File.write(path, "#{JSON.pretty_generate(rows)}\n")
35
+ puts "Wrote #{rows.size} rows to #{path}"
36
+ end
@@ -2,10 +2,16 @@ require 'active_support/core_ext/hash/indifferent_access'
2
2
 
3
3
  module ABMeter
4
4
  class APIError < StandardError
5
- attr_reader :code, :details, :status
5
+ RATE_LIMITED_STATUS = 429
6
+ DEFAULT_RETRY_AFTER = 30 # seconds, when a 429 has no usable Retry-After
7
+ MIN_RETRY_AFTER = 1 # seconds, so a zero cannot spin a drain loop
8
+ MAX_RETRY_AFTER = 600 # seconds, the API's rate-limit window
9
+
10
+ attr_reader :code, :details, :status, :retry_after
6
11
 
7
12
  def initialize(response)
8
13
  @status = response.status
14
+ @retry_after = parse_retry_after(response.headers['Retry-After']) if rate_limited?
9
15
  error_body = response.body
10
16
 
11
17
  if error_body.is_a?(Hash)
@@ -39,6 +45,10 @@ module ABMeter
39
45
  @status >= 500 || @status == 408 || @status == 429
40
46
  end
41
47
 
48
+ def rate_limited?
49
+ @status == RATE_LIMITED_STATUS
50
+ end
51
+
42
52
  def partial_failure?
43
53
  @status == 400 && @details.is_a?(Hash) && !@details[:failures].nil? && !@details[:failures].empty?
44
54
  end
@@ -46,5 +56,15 @@ module ABMeter
46
56
  def failure_count
47
57
  @details&.dig(:invalid_count) || 0
48
58
  end
59
+
60
+ private
61
+
62
+ # Retry-After as delta-seconds only; the API never sends an HTTP date.
63
+ def parse_retry_after(value)
64
+ seconds = Integer(value.to_s.strip, 10, exception: false)
65
+ return DEFAULT_RETRY_AFTER if seconds.nil? || seconds.negative?
66
+
67
+ seconds.clamp(MIN_RETRY_AFTER, MAX_RETRY_AFTER)
68
+ end
49
69
  end
50
70
  end
@@ -1,3 +1,5 @@
1
+ require 'time' # Time#iso8601
2
+
1
3
  module ABMeter
2
4
  class AsyncSubmitter
3
5
  # Private internal constants for async submitter behavior
@@ -16,6 +18,7 @@ module ABMeter
16
18
  @stopping = false
17
19
  @stop_mutex = Mutex.new
18
20
  @stop_signal = ConditionVariable.new
21
+ @paused_until = nil
19
22
 
20
23
  class << self
21
24
  attr_reader :api_client, :flush_interval, :logger, :retry_queue
@@ -46,7 +49,10 @@ module ABMeter
46
49
  })
47
50
  end
48
51
 
52
+ # A no-op while a 429 pause lasts: new items wait in the main queue.
49
53
  def flush
54
+ return if paused?
55
+
50
56
  items = []
51
57
 
52
58
  @mutex.synchronize do
@@ -54,7 +60,7 @@ module ABMeter
54
60
  process_retry_queue
55
61
 
56
62
  # Then process new items
57
- items << @queue.pop while !@queue.empty? && items.size < BATCH_SIZE
63
+ items << @queue.pop while !paused? && !@queue.empty? && items.size < BATCH_SIZE
58
64
  end
59
65
 
60
66
  # Group by type and submit
@@ -118,6 +124,10 @@ module ABMeter
118
124
  @queue.size
119
125
  end
120
126
 
127
+ def paused?
128
+ !@paused_until.nil? && monotonic_now < @paused_until
129
+ end
130
+
121
131
  private
122
132
 
123
133
  def start_worker
@@ -136,7 +146,10 @@ module ABMeter
136
146
  end
137
147
  # Final drain on graceful stop — bounded by the caller's join timeout.
138
148
  begin
139
- flush until @queue.empty? && @retry_queue.empty?
149
+ until @queue.empty? && @retry_queue.empty?
150
+ wait_out_pause
151
+ flush
152
+ end
140
153
  rescue StandardError => e
141
154
  log_error("Final flush error: #{e.message}")
142
155
  end
@@ -153,6 +166,11 @@ module ABMeter
153
166
  end
154
167
  end
155
168
 
169
+ def wait_out_pause
170
+ remaining = @paused_until.to_f - monotonic_now
171
+ sleep(remaining) if remaining.positive?
172
+ end
173
+
156
174
  def request_stop
157
175
  @stop_mutex.synchronize do
158
176
  @stopping = true
@@ -168,6 +186,7 @@ module ABMeter
168
186
  @flush_interval = DEFAULT_FLUSH_INTERVAL
169
187
  @logger = nil
170
188
  @stopping = false
189
+ @paused_until = nil
171
190
  end
172
191
 
173
192
  def submit_exposures(exposures)
@@ -186,10 +205,18 @@ module ABMeter
186
205
  return
187
206
  end
188
207
 
208
+ if paused?
209
+ add_to_retry_queue(type, items)
210
+ return
211
+ end
212
+
189
213
  yield items
190
214
  # Success - nothing to do
191
215
  rescue ABMeter::APIError => e
192
- if e.retryable?
216
+ if e.rate_limited?
217
+ pause_submissions(e.retry_after, type, items.size)
218
+ add_to_retry_queue(type, items)
219
+ elsif e.retryable?
193
220
  log_error("Retryable error submitting #{items.size} #{type}s: #{e.message}")
194
221
  add_to_retry_queue(type, items)
195
222
  elsif e.partial_failure?
@@ -207,21 +234,23 @@ module ABMeter
207
234
 
208
235
  def add_to_retry_queue(type, items)
209
236
  @mutex.synchronize do
210
- items.each do |item|
211
- # Only add if we haven't exceeded max queue size
212
- if @retry_queue.size < MAX_RETRY_QUEUE_SIZE
213
- @retry_queue << {
214
- type: type,
215
- data: item,
216
- attempts: 0
217
- }
218
- else
219
- log_error("Retry queue full, dropping #{type}")
220
- end
221
- end
237
+ room = MAX_RETRY_QUEUE_SIZE - @retry_queue.size
238
+ kept = items.first([room, 0].max)
239
+ @retry_queue.concat(kept.map { |item| { type: type, data: item, attempts: 0 } })
240
+ dropped = items.size - kept.size
241
+ log_error("Retry queue full, dropping #{dropped} #{type}s") if dropped.positive?
222
242
  end
223
243
  end
224
244
 
245
+ def pause_submissions(seconds, type, count)
246
+ @paused_until = monotonic_now + seconds
247
+ log_warn("Rate limited, pausing submissions for #{seconds}s (#{count} #{type}s held for retry)")
248
+ end
249
+
250
+ def monotonic_now
251
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
252
+ end
253
+
225
254
  def process_retry_queue
226
255
  return if @retry_queue.empty?
227
256
 
@@ -247,6 +276,8 @@ module ABMeter
247
276
 
248
277
  def process_retry_batch(type, items)
249
278
  return [] if items.empty?
279
+ # Held, not attempted: a pause set earlier in this flush.
280
+ return items if paused?
250
281
 
251
282
  # Increment attempts and filter out max retries
252
283
  active_items = items.filter_map do |item|
@@ -267,7 +298,12 @@ module ABMeter
267
298
  yield data
268
299
  [] # Success - return empty array
269
300
  rescue ABMeter::APIError => e
270
- if e.retryable?
301
+ if e.rate_limited?
302
+ pause_submissions(e.retry_after, type, data.size)
303
+ # The wait is not an attempt.
304
+ active_items.each { |item| item[:attempts] -= 1 }
305
+ active_items
306
+ elsif e.retryable?
271
307
  log_error("Retry failed with retryable error for #{data.size} #{type}s: #{e.message}")
272
308
  active_items # Return all items for retry
273
309
  elsif e.partial_failure?
@@ -289,9 +325,11 @@ module ABMeter
289
325
  end
290
326
 
291
327
  def log_error(message)
292
- return unless @logger
328
+ @logger&.error("AsyncSubmitter: #{message}")
329
+ end
293
330
 
294
- @logger.error("AsyncSubmitter: #{message}")
331
+ def log_warn(message)
332
+ @logger&.warn("AsyncSubmitter: #{message}")
295
333
  end
296
334
  end
297
335
 
@@ -44,6 +44,8 @@ module ABMeter
44
44
  f.request :json
45
45
  f.response :json
46
46
  f.headers['Authorization'] = "Bearer #{@api_key}"
47
+ f.options.timeout = DEFAULT_REQUEST_TIMEOUT
48
+ f.options.open_timeout = DEFAULT_REQUEST_TIMEOUT
47
49
  f.adapter Faraday.default_adapter
48
50
  end
49
51
  end
@@ -8,4 +8,5 @@ module ABMeter
8
8
  DEFAULT_MAX_SUBMIT_ATTEMPTS = 3
9
9
  DEFAULT_MAX_RETRY_QUEUE_SIZE = 1000
10
10
  DEFAULT_LOG_LEVEL = :error
11
+ DEFAULT_REQUEST_TIMEOUT = 30 # seconds
11
12
  end
@@ -64,6 +64,8 @@ module ABMeter
64
64
  end
65
65
 
66
66
  def matches?(user)
67
+ return false if user.email.nil?
68
+
67
69
  user.email.match?(predicate)
68
70
  end
69
71
 
@@ -7,7 +7,11 @@ module ABMeter
7
7
  protected
8
8
 
9
9
  def resolve_parameter_value(parameter, variant)
10
- variant&.parameter_value(parameter.slug) || parameter.default_value
10
+ # Fall back to the default only when the variant does not set this
11
+ # parameter (nil). An explicitly-set falsy value (false, 0, "") is a
12
+ # real override and must win — `||` would wrongly drop a Boolean false.
13
+ value = variant&.parameter_value(parameter.slug)
14
+ value.nil? ? parameter.default_value : value
11
15
  end
12
16
 
13
17
  def validate_expose_parameter_args!(user_id, parameter, audience)
@@ -5,7 +5,7 @@ module ABMeter
5
5
  class User
6
6
  attr_reader :user_id, :email
7
7
 
8
- def initialize(user_id:, email:)
8
+ def initialize(user_id:, email: nil)
9
9
  @user_id = user_id
10
10
  @email = email
11
11
  end
@@ -43,7 +43,6 @@ module ABMeter
43
43
 
44
44
  def validate_user!(user)
45
45
  raise ArgumentError, 'User must have user_id' unless user.respond_to?(:user_id)
46
- raise ArgumentError, 'User must have email' unless user.respond_to?(:email)
47
46
  end
48
47
 
49
48
  def find_matching_feature_flag(user, parameter_slug)
@@ -1,16 +1,22 @@
1
+ require_relative 'error_safety'
2
+
1
3
  module ABMeter
2
4
  class ResolverProvider
5
+ include ErrorSafety
6
+
3
7
  attr_reader :last_fetched_at
4
8
 
5
9
  def initialize(config:, api_client: nil)
6
10
  raise 'Either api_key or static_config must be provided' unless config.api_key || config.static_config
7
11
 
12
+ @config = config
8
13
  @api_client = api_client
9
14
  @json_config = config.static_config
10
15
  @fetch_interval = config.fetch_interval
11
16
 
12
17
  @resolver = nil
13
18
  @last_fetched_at = nil
19
+ @last_attempted_at = nil
14
20
 
15
21
  # Build resolver immediately if using JSON config
16
22
  @resolver = ABMeter::Core.build_resolver_from_json(@json_config) if @json_config
@@ -41,14 +47,23 @@ module ABMeter
41
47
  def refresh_config_if_needed
42
48
  return if @json_config # No refresh needed for JSON config
43
49
 
44
- update_resolver if !@resolver || time_to_refresh_config?
50
+ return unless !@resolver || time_to_refresh_config?
51
+
52
+ begin
53
+ update_resolver
54
+ rescue StandardError => e
55
+ raise unless @resolver
56
+
57
+ serve_last_known_config(e)
58
+ end
45
59
  end
46
60
 
47
61
  def time_to_refresh_config?
48
- @last_fetched_at.nil? || (Time.now - @last_fetched_at > @fetch_interval)
62
+ @last_attempted_at.nil? || (Time.now - @last_attempted_at > @fetch_interval)
49
63
  end
50
64
 
51
65
  def update_resolver
66
+ @last_attempted_at = Time.now
52
67
  response = @api_client.get_assignment_config
53
68
  config_data = response[:config]
54
69
 
@@ -56,6 +71,11 @@ module ABMeter
56
71
  @last_fetched_at = Time.now
57
72
  end
58
73
 
74
+ def serve_last_known_config(error)
75
+ log_error('Failed to refresh assignment config, serving last known config', error)
76
+ call_error_callback(error)
77
+ end
78
+
59
79
  def queue_exposure(exposure)
60
80
  AsyncSubmitter.queue_exposure(exposure)
61
81
  end
@@ -1,3 +1,3 @@
1
1
  module ABMeter
2
- VERSION = '0.3.0'.freeze
2
+ VERSION = '0.5.0'.freeze
3
3
  end
data/lib/abmeter.rb CHANGED
@@ -14,6 +14,12 @@ require_relative 'abmeter/async_submitter'
14
14
  require_relative 'abmeter/error_safety'
15
15
 
16
16
  module ABMeter
17
+ # Top-level alias for the canonical user value object. Lets callers write
18
+ # `ABMeter::User.new(user_id: ...)` instead of reaching into `Core::User`,
19
+ # mirroring `abmeter.User(...)` in the Python SDK. Same class object, so
20
+ # `is_a?`/`===`/`instance_of?` keep working; `Core::User` stays canonical.
21
+ User = Core::User
22
+
17
23
  class << self
18
24
  include ErrorSafety
19
25
 
@@ -42,7 +48,6 @@ module ABMeter
42
48
  config.base_url = DEFAULT_BASE_URL
43
49
  yield(config)
44
50
  config.validate!
45
- # Set logger level if both logger and log_level are configured
46
51
  config.send(:set_logger_level, config.log_level)
47
52
  @config = config
48
53
 
@@ -120,13 +125,13 @@ module ABMeter
120
125
 
121
126
  # Logging and error handling
122
127
  attr_accessor :logger # Logger instance (auto-detected by default)
123
- attr_accessor :log_level # Log level (:debug, :info, :warn, :error)
128
+ attr_accessor :log_level # Level for the SDK's own logger; a logger you pass in keeps its level
124
129
  attr_accessor :error_callback # Proc called on errors
125
130
 
126
131
  def initialize
127
132
  @flush_interval = DEFAULT_FLUSH_INTERVAL
128
133
  @fetch_interval = DEFAULT_FETCH_INTERVAL
129
- @logger = detect_logger
134
+ @logger = host_logger || (@own_logger = build_own_logger)
130
135
  end
131
136
 
132
137
  def validate!
@@ -155,13 +160,13 @@ module ABMeter
155
160
  private
156
161
 
157
162
  def set_logger_level(log_level)
158
- return unless @logger
163
+ return unless @own_logger && @logger.equal?(@own_logger)
159
164
 
160
- @logger.level = logger_level_for(@logger, log_level || DEFAULT_LOG_LEVEL) if @logger.respond_to?(:level=)
165
+ @own_logger.level = logger_level_for(@own_logger, log_level || DEFAULT_LOG_LEVEL)
161
166
  end
162
167
 
163
168
  def logger_level_for(logger, log_level)
164
- if defined?(Logger) && (logger.is_a?(Logger) || (defined?(Rails) && logger == Rails.logger))
169
+ if defined?(Logger) && logger.is_a?(Logger)
165
170
  return case log_level
166
171
  when :debug then Logger::DEBUG
167
172
  when :info then Logger::INFO
@@ -175,18 +180,15 @@ module ABMeter
175
180
  @log_level
176
181
  end
177
182
 
178
- def detect_logger
179
- # Try Rails logger first
180
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
181
- Rails.logger
182
- # Try Semantic Logger
183
- elsif defined?(SemanticLogger)
184
- SemanticLogger['ABMeter']
185
- # Default to Ruby's Logger
186
- else
187
- require 'logger'
188
- Logger.new($stdout, progname: 'ABMeter')
189
- end
183
+ def host_logger
184
+ Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
185
+ end
186
+
187
+ def build_own_logger
188
+ return SemanticLogger['ABMeter'] if defined?(SemanticLogger)
189
+
190
+ require 'logger'
191
+ Logger.new($stdout, progname: 'ABMeter')
190
192
  end
191
193
  end
192
194
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: abmeter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ABMeter
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-03 00:00:00.000000000 Z
11
+ date: 2026-11-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -41,7 +41,6 @@ dependencies:
41
41
  description: ABMeter SDK is a client library for interacting with the ABMeter testing
42
42
  service
43
43
  email:
44
- - info@abmeter.com
45
44
  executables: []
46
45
  extensions: []
47
46
  extra_rdoc_files: []
@@ -49,6 +48,7 @@ files:
49
48
  - LICENSE.txt
50
49
  - README.md
51
50
  - bin/console
51
+ - bin/generate_num_utils_parity_fixture.rb
52
52
  - bin/rspec
53
53
  - lib/abmeter.rb
54
54
  - lib/abmeter/api_error.rb
@@ -71,11 +71,11 @@ files:
71
71
  - lib/abmeter/error_safety.rb
72
72
  - lib/abmeter/resolver_provider.rb
73
73
  - lib/abmeter/version.rb
74
- homepage: https://github.com/abmeter/abmeter-ruby
74
+ homepage: https://abmeter.ai
75
75
  licenses:
76
76
  - MIT
77
77
  metadata:
78
- homepage_uri: https://github.com/abmeter/abmeter-ruby
78
+ homepage_uri: https://abmeter.ai
79
79
  source_code_uri: https://github.com/abmeter/abmeter-ruby
80
80
  rubygems_mfa_required: 'true'
81
81
  post_install_message: