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 +4 -4
- data/README.md +34 -9
- data/bin/generate_num_utils_parity_fixture.rb +32 -0
- data/lib/abmeter/async_submitter.rb +84 -15
- data/lib/abmeter/core/assignment_config/audience.rb +2 -0
- data/lib/abmeter/core/assignment_config/experiment.rb +1 -0
- data/lib/abmeter/core/assignment_config/exposable.rb +5 -1
- data/lib/abmeter/core/user.rb +1 -1
- data/lib/abmeter/core/user_parameter_resolver.rb +0 -1
- data/lib/abmeter/version.rb +1 -1
- data/lib/abmeter.rb +21 -2
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f33d4944ddd5acac49f58a672004aa3460a7d8435a651ad6172d88960f02ebdb
|
|
4
|
+
data.tar.gz: b800e19818cc9d408380c14ae7e7fc2bad8d421839f970f14e259b327acb314c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 83e08361797d68f6367b1651358557b25635e8a8554a9fb3e7dd79169f08b0c7443a52da385535cda59a25b4276cf9424c696dc4130c2a42f75e114412555f8b
|
|
7
|
+
data.tar.gz: e60a8479335b24cc3f57b24a5af553ad77b5a8451c3b8801650ca13721e3281cda29d9c54717f064393f1b6d816157bc6b84a83290e56bb4abcef4edb8c2bbf0
|
data/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# ABMeter Gem
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
@@ -7,7 +7,11 @@ module ABMeter
|
|
|
7
7
|
protected
|
|
8
8
|
|
|
9
9
|
def resolve_parameter_value(parameter, variant)
|
|
10
|
-
variant
|
|
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)
|
data/lib/abmeter/core/user.rb
CHANGED
|
@@ -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)
|
data/lib/abmeter/version.rb
CHANGED
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
|
-
|
|
68
|
-
|
|
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.
|
|
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-
|
|
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://
|
|
75
|
+
homepage: https://abmeter.ai
|
|
75
76
|
licenses:
|
|
76
77
|
- MIT
|
|
77
78
|
metadata:
|
|
78
|
-
homepage_uri: https://
|
|
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:
|