abmeter 0.4.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 +4 -4
- data/README.md +1 -1
- data/bin/generate_num_utils_parity_fixture.rb +12 -8
- data/lib/abmeter/api_error.rb +21 -1
- data/lib/abmeter/async_submitter.rb +54 -18
- data/lib/abmeter/client.rb +2 -0
- data/lib/abmeter/constants.rb +1 -0
- data/lib/abmeter/resolver_provider.rb +22 -2
- data/lib/abmeter/version.rb +1 -1
- data/lib/abmeter.rb +14 -18
- metadata +2 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 127c157ec200964f199fb170d036d9b9898f717a1634002d02dcd211c3296387
|
|
4
|
+
data.tar.gz: 9774017d52cf7b9925eddd837fd4972a693cfd5b76f11c4fe20bd8692d85ddec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6174b2a6d2308658310f5a2f35df3d3a986f0979b2a87cd318b3f051469074fa1cf2ead76a53b4a18569dd50d28181b6bdde256c9086be27612e3e12525efa32
|
|
7
|
+
data.tar.gz: eee251586f7279d59edbb6049e5265156afd1858383752dfb4c280d97dc32dd04543c2e44cf8e8f9348046194ec6bcf5b91c651299a0b53e17716e0fdd069ca6
|
data/README.md
CHANGED
|
@@ -49,7 +49,7 @@ text = ABMeter.resolve_parameter(user: user, parameter_slug: 'welcome_text')
|
|
|
49
49
|
ABMeter.track_event('user_purchases_plan', current_user.id, { plan: purchased_plan.name, price: purchased_plan.price })
|
|
50
50
|
```
|
|
51
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
|
|
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
53
|
|
|
54
54
|
## Shutdown
|
|
55
55
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
# Regenerates the cross-language num-utils parity fixture consumed by
|
|
5
|
-
#
|
|
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).
|
|
6
7
|
#
|
|
7
8
|
# Emits 1000 [salt, user_id, expected] triples where `expected` is the integer
|
|
8
|
-
# returned by ABMeter::Core::Utils::NumUtils.to_percentage.
|
|
9
|
+
# returned by ABMeter::Core::Utils::NumUtils.to_percentage. Each ported
|
|
9
10
|
# implementation must reproduce every row. Run from the abmeter-sdk directory:
|
|
10
11
|
#
|
|
11
12
|
# bundle exec ruby bin/generate_num_utils_parity_fixture.rb
|
|
@@ -15,9 +16,10 @@
|
|
|
15
16
|
require 'json'
|
|
16
17
|
require_relative '../lib/abmeter/core/utils/num_utils'
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
'../../sdk-python/tests/fixtures/num_utils_parity.json',
|
|
20
|
-
|
|
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__) }
|
|
21
23
|
ROW_COUNT = 1000
|
|
22
24
|
|
|
23
25
|
rows = Array.new(ROW_COUNT) do |i|
|
|
@@ -28,5 +30,7 @@ rows = Array.new(ROW_COUNT) do |i|
|
|
|
28
30
|
[salt, user_id, ABMeter::Core::Utils::NumUtils.to_percentage(salt, user_id)]
|
|
29
31
|
end
|
|
30
32
|
|
|
31
|
-
|
|
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
|
data/lib/abmeter/api_error.rb
CHANGED
|
@@ -2,10 +2,16 @@ require 'active_support/core_ext/hash/indifferent_access'
|
|
|
2
2
|
|
|
3
3
|
module ABMeter
|
|
4
4
|
class APIError < StandardError
|
|
5
|
-
|
|
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
|
|
@@ -18,6 +18,7 @@ module ABMeter
|
|
|
18
18
|
@stopping = false
|
|
19
19
|
@stop_mutex = Mutex.new
|
|
20
20
|
@stop_signal = ConditionVariable.new
|
|
21
|
+
@paused_until = nil
|
|
21
22
|
|
|
22
23
|
class << self
|
|
23
24
|
attr_reader :api_client, :flush_interval, :logger, :retry_queue
|
|
@@ -48,7 +49,10 @@ module ABMeter
|
|
|
48
49
|
})
|
|
49
50
|
end
|
|
50
51
|
|
|
52
|
+
# A no-op while a 429 pause lasts: new items wait in the main queue.
|
|
51
53
|
def flush
|
|
54
|
+
return if paused?
|
|
55
|
+
|
|
52
56
|
items = []
|
|
53
57
|
|
|
54
58
|
@mutex.synchronize do
|
|
@@ -56,7 +60,7 @@ module ABMeter
|
|
|
56
60
|
process_retry_queue
|
|
57
61
|
|
|
58
62
|
# Then process new items
|
|
59
|
-
items << @queue.pop while !@queue.empty? && items.size < BATCH_SIZE
|
|
63
|
+
items << @queue.pop while !paused? && !@queue.empty? && items.size < BATCH_SIZE
|
|
60
64
|
end
|
|
61
65
|
|
|
62
66
|
# Group by type and submit
|
|
@@ -120,6 +124,10 @@ module ABMeter
|
|
|
120
124
|
@queue.size
|
|
121
125
|
end
|
|
122
126
|
|
|
127
|
+
def paused?
|
|
128
|
+
!@paused_until.nil? && monotonic_now < @paused_until
|
|
129
|
+
end
|
|
130
|
+
|
|
123
131
|
private
|
|
124
132
|
|
|
125
133
|
def start_worker
|
|
@@ -138,7 +146,10 @@ module ABMeter
|
|
|
138
146
|
end
|
|
139
147
|
# Final drain on graceful stop — bounded by the caller's join timeout.
|
|
140
148
|
begin
|
|
141
|
-
|
|
149
|
+
until @queue.empty? && @retry_queue.empty?
|
|
150
|
+
wait_out_pause
|
|
151
|
+
flush
|
|
152
|
+
end
|
|
142
153
|
rescue StandardError => e
|
|
143
154
|
log_error("Final flush error: #{e.message}")
|
|
144
155
|
end
|
|
@@ -155,6 +166,11 @@ module ABMeter
|
|
|
155
166
|
end
|
|
156
167
|
end
|
|
157
168
|
|
|
169
|
+
def wait_out_pause
|
|
170
|
+
remaining = @paused_until.to_f - monotonic_now
|
|
171
|
+
sleep(remaining) if remaining.positive?
|
|
172
|
+
end
|
|
173
|
+
|
|
158
174
|
def request_stop
|
|
159
175
|
@stop_mutex.synchronize do
|
|
160
176
|
@stopping = true
|
|
@@ -170,6 +186,7 @@ module ABMeter
|
|
|
170
186
|
@flush_interval = DEFAULT_FLUSH_INTERVAL
|
|
171
187
|
@logger = nil
|
|
172
188
|
@stopping = false
|
|
189
|
+
@paused_until = nil
|
|
173
190
|
end
|
|
174
191
|
|
|
175
192
|
def submit_exposures(exposures)
|
|
@@ -188,10 +205,18 @@ module ABMeter
|
|
|
188
205
|
return
|
|
189
206
|
end
|
|
190
207
|
|
|
208
|
+
if paused?
|
|
209
|
+
add_to_retry_queue(type, items)
|
|
210
|
+
return
|
|
211
|
+
end
|
|
212
|
+
|
|
191
213
|
yield items
|
|
192
214
|
# Success - nothing to do
|
|
193
215
|
rescue ABMeter::APIError => e
|
|
194
|
-
if e.
|
|
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?
|
|
195
220
|
log_error("Retryable error submitting #{items.size} #{type}s: #{e.message}")
|
|
196
221
|
add_to_retry_queue(type, items)
|
|
197
222
|
elsif e.partial_failure?
|
|
@@ -209,21 +234,23 @@ module ABMeter
|
|
|
209
234
|
|
|
210
235
|
def add_to_retry_queue(type, items)
|
|
211
236
|
@mutex.synchronize do
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
data: item,
|
|
218
|
-
attempts: 0
|
|
219
|
-
}
|
|
220
|
-
else
|
|
221
|
-
log_error("Retry queue full, dropping #{type}")
|
|
222
|
-
end
|
|
223
|
-
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?
|
|
224
242
|
end
|
|
225
243
|
end
|
|
226
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
|
+
|
|
227
254
|
def process_retry_queue
|
|
228
255
|
return if @retry_queue.empty?
|
|
229
256
|
|
|
@@ -249,6 +276,8 @@ module ABMeter
|
|
|
249
276
|
|
|
250
277
|
def process_retry_batch(type, items)
|
|
251
278
|
return [] if items.empty?
|
|
279
|
+
# Held, not attempted: a pause set earlier in this flush.
|
|
280
|
+
return items if paused?
|
|
252
281
|
|
|
253
282
|
# Increment attempts and filter out max retries
|
|
254
283
|
active_items = items.filter_map do |item|
|
|
@@ -269,7 +298,12 @@ module ABMeter
|
|
|
269
298
|
yield data
|
|
270
299
|
[] # Success - return empty array
|
|
271
300
|
rescue ABMeter::APIError => e
|
|
272
|
-
if e.
|
|
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?
|
|
273
307
|
log_error("Retry failed with retryable error for #{data.size} #{type}s: #{e.message}")
|
|
274
308
|
active_items # Return all items for retry
|
|
275
309
|
elsif e.partial_failure?
|
|
@@ -291,9 +325,11 @@ module ABMeter
|
|
|
291
325
|
end
|
|
292
326
|
|
|
293
327
|
def log_error(message)
|
|
294
|
-
|
|
328
|
+
@logger&.error("AsyncSubmitter: #{message}")
|
|
329
|
+
end
|
|
295
330
|
|
|
296
|
-
|
|
331
|
+
def log_warn(message)
|
|
332
|
+
@logger&.warn("AsyncSubmitter: #{message}")
|
|
297
333
|
end
|
|
298
334
|
end
|
|
299
335
|
|
data/lib/abmeter/client.rb
CHANGED
data/lib/abmeter/constants.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
@
|
|
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
|
data/lib/abmeter/version.rb
CHANGED
data/lib/abmeter.rb
CHANGED
|
@@ -48,7 +48,6 @@ module ABMeter
|
|
|
48
48
|
config.base_url = DEFAULT_BASE_URL
|
|
49
49
|
yield(config)
|
|
50
50
|
config.validate!
|
|
51
|
-
# Set logger level if both logger and log_level are configured
|
|
52
51
|
config.send(:set_logger_level, config.log_level)
|
|
53
52
|
@config = config
|
|
54
53
|
|
|
@@ -126,13 +125,13 @@ module ABMeter
|
|
|
126
125
|
|
|
127
126
|
# Logging and error handling
|
|
128
127
|
attr_accessor :logger # Logger instance (auto-detected by default)
|
|
129
|
-
attr_accessor :log_level #
|
|
128
|
+
attr_accessor :log_level # Level for the SDK's own logger; a logger you pass in keeps its level
|
|
130
129
|
attr_accessor :error_callback # Proc called on errors
|
|
131
130
|
|
|
132
131
|
def initialize
|
|
133
132
|
@flush_interval = DEFAULT_FLUSH_INTERVAL
|
|
134
133
|
@fetch_interval = DEFAULT_FETCH_INTERVAL
|
|
135
|
-
@logger =
|
|
134
|
+
@logger = host_logger || (@own_logger = build_own_logger)
|
|
136
135
|
end
|
|
137
136
|
|
|
138
137
|
def validate!
|
|
@@ -161,13 +160,13 @@ module ABMeter
|
|
|
161
160
|
private
|
|
162
161
|
|
|
163
162
|
def set_logger_level(log_level)
|
|
164
|
-
return unless @logger
|
|
163
|
+
return unless @own_logger && @logger.equal?(@own_logger)
|
|
165
164
|
|
|
166
|
-
@
|
|
165
|
+
@own_logger.level = logger_level_for(@own_logger, log_level || DEFAULT_LOG_LEVEL)
|
|
167
166
|
end
|
|
168
167
|
|
|
169
168
|
def logger_level_for(logger, log_level)
|
|
170
|
-
if defined?(Logger) &&
|
|
169
|
+
if defined?(Logger) && logger.is_a?(Logger)
|
|
171
170
|
return case log_level
|
|
172
171
|
when :debug then Logger::DEBUG
|
|
173
172
|
when :info then Logger::INFO
|
|
@@ -181,18 +180,15 @@ module ABMeter
|
|
|
181
180
|
@log_level
|
|
182
181
|
end
|
|
183
182
|
|
|
184
|
-
def
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
require 'logger'
|
|
194
|
-
Logger.new($stdout, progname: 'ABMeter')
|
|
195
|
-
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')
|
|
196
192
|
end
|
|
197
193
|
end
|
|
198
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.
|
|
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-
|
|
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: []
|