abmeter 0.2.4 → 0.4.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: 3de305080a86006a94e25c375c97e3f00130132ff44e4f1c51d7fb8719d0a18f
4
- data.tar.gz: 2c318d9008614bb7b7ad561c7587cecbf009fa8e68331d6fc35974b8b1cd1ccd
3
+ metadata.gz: f33d4944ddd5acac49f58a672004aa3460a7d8435a651ad6172d88960f02ebdb
4
+ data.tar.gz: b800e19818cc9d408380c14ae7e7fc2bad8d421839f970f14e259b327acb314c
5
5
  SHA512:
6
- metadata.gz: dc3b45f859392cb95ca923aa89f813b876ddb6d086852da649c8a7d7c53ab2f7c0564761d0eefb19525578395882af92d4af101e5de8e1210a2f26decbd12188
7
- data.tar.gz: b4ac66c59f3d601e3f7b88bcdcbc0b64ac8daf7a48c0d96ed8d76dad0a5804644f53bbef3f848de6676f57ee18caa626bd0efd9035ba9db634bcc506091f727f
6
+ metadata.gz: 83e08361797d68f6367b1651358557b25635e8a8554a9fb3e7dd79169f08b0c7443a52da385535cda59a25b4276cf9424c696dc4130c2a42f75e114412555f8b
7
+ data.tar.gz: e60a8479335b24cc3f57b24a5af553ad77b5a8451c3b8801650ca13721e3281cda29d9c54717f064393f1b6d816157bc6b84a83290e56bb4abcef4edb8c2bbf0
data/README.md CHANGED
@@ -1,6 +1,8 @@
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
+
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.
4
6
 
5
7
  ## Supported Ruby versions
6
8
 
@@ -20,22 +22,45 @@ And then execute:
20
22
  $ bundle install
21
23
  ```
22
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
+
23
29
  ## Usage
24
30
 
31
+ Configure the client once at startup:
32
+
25
33
  ```ruby
26
- # configure the client
27
34
  ABMeter.configure do |config|
28
35
  config.api_key = ENV['ABMETER_API_KEY']
29
36
  end
37
+ ```
38
+
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.
30
40
 
31
- # Somewhere in the renedring code:
32
- user = ABMeter.user(id: current_user.id, email: current_user.email)
33
- text = ABMeter.param('welcome_text', user)
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 })
50
+ ```
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; use `get_exposure` for the same resolution without submitting. Full guides live at [abmeter.ai](https://abmeter.ai).
53
+
54
+ ## Shutdown
55
+
56
+ ```ruby
57
+ # Graceful — bounded blocking, flushes pending exposures/events.
58
+ # Returns true on clean shutdown, false if the timeout elapsed.
59
+ ABMeter.reset(timeout: 5.0)
34
60
 
35
- # Somewhere in the model code:
36
- current_user.plan = purchased_plan.name
37
- user = ABMeter.user(id: current_user.id, email: current_user.email)
38
- ABMeter.event(`user_purchases_plan`, user, {plan: purchased_plan.name, price: purchased_plan.price})
61
+ # Immediate non-blocking, discards anything still queued.
62
+ # Use only when the process is exiting *right now* and you cannot afford I/O.
63
+ ABMeter.reset!
39
64
  ```
40
65
 
41
66
  ## Development
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Regenerates the cross-language num-utils parity fixture consumed by the
5
+ # Python SDK (sdk-python/tests/test_num_utils_parity.py).
6
+ #
7
+ # Emits 1000 [salt, user_id, expected] triples where `expected` is the integer
8
+ # returned by ABMeter::Core::Utils::NumUtils.to_percentage. The Python
9
+ # implementation must reproduce every row. Run from the abmeter-sdk directory:
10
+ #
11
+ # bundle exec ruby bin/generate_num_utils_parity_fixture.rb
12
+ #
13
+ # Regenerate only when the assignment algorithm changes (it shouldn't).
14
+
15
+ require 'json'
16
+ require_relative '../lib/abmeter/core/utils/num_utils'
17
+
18
+ FIXTURE_PATH = File.expand_path(
19
+ '../../sdk-python/tests/fixtures/num_utils_parity.json', __dir__
20
+ )
21
+ ROW_COUNT = 1000
22
+
23
+ rows = Array.new(ROW_COUNT) do |i|
24
+ # Deterministic, varied inputs — mixed shapes exercise the hashing across
25
+ # salts and user-id formats without depending on RNG.
26
+ salt = "space-salt-#{i}-#{format('%04d', (i * 7) % 9973)}"
27
+ user_id = "user_#{((i * 31) + 5) % 100_003}"
28
+ [salt, user_id, ABMeter::Core::Utils::NumUtils.to_percentage(salt, user_id)]
29
+ end
30
+
31
+ File.write(FIXTURE_PATH, "#{JSON.pretty_generate(rows)}\n")
32
+ puts "Wrote #{rows.size} rows to #{FIXTURE_PATH}"
@@ -1,9 +1,12 @@
1
+ require 'time' # Time#iso8601
2
+
1
3
  module ABMeter
2
4
  class AsyncSubmitter
3
5
  # Private internal constants for async submitter behavior
4
6
  BATCH_SIZE = 100
5
7
  MAX_SUBMIT_ATTEMPTS = 3
6
8
  MAX_RETRY_QUEUE_SIZE = 1000
9
+ DEFAULT_SHUTDOWN_TIMEOUT = 5.0 # seconds — max time a graceful shutdown blocks before killing the worker
7
10
 
8
11
  @queue = Queue.new
9
12
  @retry_queue = []
@@ -12,6 +15,9 @@ module ABMeter
12
15
  @worker_thread = nil
13
16
  @flush_interval = DEFAULT_FLUSH_INTERVAL
14
17
  @logger = nil
18
+ @stopping = false
19
+ @stop_mutex = Mutex.new
20
+ @stop_signal = ConditionVariable.new
15
21
 
16
22
  class << self
17
23
  attr_reader :api_client, :flush_interval, :logger, :retry_queue
@@ -63,20 +69,47 @@ module ABMeter
63
69
  end
64
70
  end
65
71
 
66
- def shutdown
72
+ # Graceful, lossless, bounded blocking. Signals the worker to stop, which
73
+ # triggers a final flush of the main and retry queues, then waits up to
74
+ # `timeout` seconds for that flush to finish and the worker to exit. Kills
75
+ # the worker (dropping anything still unflushed) if the timeout elapses.
76
+ # Returns true on clean shutdown, false on timeout.
77
+ def shutdown(timeout: DEFAULT_SHUTDOWN_TIMEOUT)
78
+ worker = @worker_thread
79
+ return true unless worker
80
+
81
+ request_stop
82
+ joined = worker.join(timeout)
83
+ if joined.nil?
84
+ worker.kill
85
+ worker.join
86
+ log_error("Graceful shutdown timed out after #{timeout}s, killing worker (queued: #{@queue.size}, retry: #{@retry_queue.size})")
87
+ end
88
+ !joined.nil?
89
+ end
90
+
91
+ # Immediate, lossy, non-blocking. Kills the worker and discards queued
92
+ # items without any network I/O.
93
+ def shutdown!
67
94
  @worker_thread&.kill
68
- # Flush all remaining exposures
69
- flush until @queue.empty?
95
+ dropped_queue = @queue.size
96
+ dropped_retry = @retry_queue.size
97
+ if dropped_queue.positive? || dropped_retry.positive?
98
+ log_error("Immediate shutdown, dropping items (dropped_queue: #{dropped_queue}, dropped_retry: #{dropped_retry})")
99
+ end
100
+ true
101
+ end
102
+
103
+ def reset(timeout: DEFAULT_SHUTDOWN_TIMEOUT)
104
+ result = shutdown(timeout: timeout)
105
+ clear_state
106
+ result
70
107
  end
71
108
 
72
109
  def reset!
73
- shutdown
74
- @queue = Queue.new
75
- @retry_queue = []
76
- @api_client = nil
77
- @worker_thread = nil
78
- @flush_interval = DEFAULT_FLUSH_INTERVAL
79
- @logger = nil
110
+ shutdown!
111
+ clear_state
112
+ true
80
113
  end
81
114
 
82
115
  def worker_alive?
@@ -92,17 +125,53 @@ module ABMeter
92
125
  def start_worker
93
126
  return if worker_alive?
94
127
 
128
+ @stopping = false
95
129
  @worker_thread = Thread.new do
96
- loop do
97
- sleep @flush_interval
98
- flush
130
+ until @stopping
131
+ begin
132
+ wait_for_next_flush
133
+ flush unless @stopping
134
+ rescue StandardError => e
135
+ # Log error but keep worker running
136
+ log_error("Worker error: #{e.message}")
137
+ end
138
+ end
139
+ # Final drain on graceful stop — bounded by the caller's join timeout.
140
+ begin
141
+ flush until @queue.empty? && @retry_queue.empty?
99
142
  rescue StandardError => e
100
- # Log error but keep worker running
101
- log_error("Worker error: #{e.message}")
143
+ log_error("Final flush error: #{e.message}")
102
144
  end
103
145
  end
104
146
  end
105
147
 
148
+ # Sleeps for @flush_interval, waking immediately when request_stop
149
+ # signals. Re-checking @stopping under @stop_mutex closes the
150
+ # lost-wakeup window between the worker loop's flag check and the wait
151
+ # (a bare sleep + Thread#wakeup would drop a signal sent in that gap).
152
+ def wait_for_next_flush
153
+ @stop_mutex.synchronize do
154
+ @stop_signal.wait(@stop_mutex, @flush_interval) unless @stopping
155
+ end
156
+ end
157
+
158
+ def request_stop
159
+ @stop_mutex.synchronize do
160
+ @stopping = true
161
+ @stop_signal.broadcast
162
+ end
163
+ end
164
+
165
+ def clear_state
166
+ @queue = Queue.new
167
+ @retry_queue = []
168
+ @api_client = nil
169
+ @worker_thread = nil
170
+ @flush_interval = DEFAULT_FLUSH_INTERVAL
171
+ @logger = nil
172
+ @stopping = false
173
+ end
174
+
106
175
  def submit_exposures(exposures)
107
176
  submit_batch(:exposure, exposures) { |exposures| @api_client.submit_exposures(exposures) }
108
177
  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
 
@@ -43,6 +43,7 @@ module ABMeter
43
43
  {
44
44
  id: id,
45
45
  space_id: space_id,
46
+ salt: salt,
46
47
  range: [range.begin, range.end],
47
48
  audience_variants: audience_variants.map do |audience_variant|
48
49
  {
@@ -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,3 +1,3 @@
1
1
  module ABMeter
2
- VERSION = '0.2.4'.freeze
2
+ VERSION = '0.4.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
 
@@ -64,12 +70,25 @@ module ABMeter
64
70
  end
65
71
  end
66
72
 
67
- def reset!
68
- AsyncSubmitter.shutdown
73
+ # Graceful, lossless, bounded blocking. Flushes pending exposures/events
74
+ # before clearing state. Returns true on clean shutdown, false if the
75
+ # timeout elapsed and the worker had to be killed.
76
+ def reset(timeout: AsyncSubmitter::DEFAULT_SHUTDOWN_TIMEOUT)
77
+ result = AsyncSubmitter.reset(timeout: timeout)
78
+ @config = nil
79
+ @client = nil
80
+ @resolver_provider = nil
81
+ result
82
+ end
69
83
 
84
+ # Immediate, lossy, non-blocking. Kills the worker thread and discards
85
+ # queued exposures/events without any network I/O.
86
+ def reset!
87
+ AsyncSubmitter.reset!
70
88
  @config = nil
71
89
  @client = nil
72
90
  @resolver_provider = nil
91
+ nil
73
92
  end
74
93
 
75
94
  def track_event(event_name, user_id, data)
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.2.4
4
+ version: 0.4.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-06-13 00:00:00.000000000 Z
11
+ date: 2026-07-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -49,6 +49,7 @@ files:
49
49
  - LICENSE.txt
50
50
  - README.md
51
51
  - bin/console
52
+ - bin/generate_num_utils_parity_fixture.rb
52
53
  - bin/rspec
53
54
  - lib/abmeter.rb
54
55
  - lib/abmeter/api_error.rb
@@ -71,11 +72,11 @@ files:
71
72
  - lib/abmeter/error_safety.rb
72
73
  - lib/abmeter/resolver_provider.rb
73
74
  - lib/abmeter/version.rb
74
- homepage: https://github.com/abmeter/abmeter-ruby
75
+ homepage: https://abmeter.ai
75
76
  licenses:
76
77
  - MIT
77
78
  metadata:
78
- homepage_uri: https://github.com/abmeter/abmeter-ruby
79
+ homepage_uri: https://abmeter.ai
79
80
  source_code_uri: https://github.com/abmeter/abmeter-ruby
80
81
  rubygems_mfa_required: 'true'
81
82
  post_install_message: