abmeter 0.0.2 → 0.2.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: 7c4ff1738a0062b27210d958cbfcddb2b07f635d3c9bdc64a8e764f8a2e0c4c3
4
- data.tar.gz: 899c571cbcf11fef822397640233c6d5a59b0105231d70e676da6b0c29dab1a4
3
+ metadata.gz: d96ae2882d0054f4e6b414ce1d427b7cc2eb2d31dc3b93f7383f98b384922682
4
+ data.tar.gz: aded2a5117637b1c34c92318d5097380ce3f9cd6ad400297e98b5e29de8d2810
5
5
  SHA512:
6
- metadata.gz: 35a34486e360c18fa185c9a919dbf985ada30b197843ba79c90c5de5a5487d0d81a4d1127f56abee834b50990dba12edda9d6edd40ee0965170fa0c488033d8b
7
- data.tar.gz: cce845469fc896337c070d1abff4ff9adb8755e54a2b5e1cc797fbfc868d84c705836edeea8a441b6d82f20583456f49eee48913808d2b3d43cfc83fee9dea4a
6
+ metadata.gz: 1bce7617f6127244f4452beb1a130ce4431d023cc0cf2de35d537003eee09cb4b790717346a427d3e718cf65356c5fe6453dd12c059a91dba608ef3316d4e05a
7
+ data.tar.gz: 37ad3671bf1f486d26d22f10b3485d3b9f71b81f59bacd6b2471f5cfb7b4b17c01600786e30024edf6794b4783f0eb0f818b2d059b8311f5a52c94a3c81e4153
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ABMeter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,5 +1,39 @@
1
- # ABMeter
1
+ # ABMeter Gem
2
2
 
3
- A/B testing and experimentation platform.
3
+ A simple A/B testing client library for Ruby applications.
4
4
 
5
- **Status**: Coming soon.
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'abmeter'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ $ bundle install
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```ruby
22
+ # configure the client
23
+ ABMeter.configure do |config|
24
+ config.api_key = ENV['ABMETER_API_KEY']
25
+ end
26
+
27
+ # Somewhere in the renedring code:
28
+ user = ABMeter.user(id: current_user.id, email: current_user.email)
29
+ text = ABMeter.param('welcome_text', user)
30
+
31
+ # Somewhere in the model code:
32
+ current_user.plan = purchased_plan.name
33
+ user = ABMeter.user(id: current_user.id, email: current_user.email)
34
+ ABMeter.event(`user_purchases_plan`, user, {plan: purchased_plan.name, price: purchased_plan.price})
35
+ ```
36
+
37
+ ## License
38
+
39
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'abmeter/core'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
data/bin/rspec ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rspec' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
+
13
+ bundle_binstub = File.expand_path("bundle", __dir__)
14
+
15
+ if File.file?(bundle_binstub)
16
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
+ load(bundle_binstub)
18
+ else
19
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
+ end
22
+ end
23
+
24
+ require "rubygems"
25
+ require "bundler/setup"
26
+
27
+ load Gem.bin_path("rspec-core", "rspec")
@@ -0,0 +1,50 @@
1
+ require 'active_support/core_ext/hash/indifferent_access'
2
+
3
+ module ABMeter
4
+ class APIError < StandardError
5
+ attr_reader :code, :details, :status
6
+
7
+ def initialize(response)
8
+ @status = response.status
9
+ error_body = response.body
10
+
11
+ if error_body.is_a?(Hash)
12
+ body = error_body.with_indifferent_access
13
+ @error_message = body[:error] || 'Unknown error'
14
+ @code = body[:code]
15
+ @details = body[:details] || {}
16
+ else
17
+ @error_message = error_body.to_s
18
+ @code = nil
19
+ @details = {}
20
+ end
21
+
22
+ super(@error_message)
23
+ end
24
+
25
+ def message
26
+ @error_message
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ error: @error_message,
32
+ code: @code,
33
+ details: @details,
34
+ status: @status
35
+ }.compact
36
+ end
37
+
38
+ def retryable?
39
+ @status >= 500 || @status == 408 || @status == 429
40
+ end
41
+
42
+ def partial_failure?
43
+ @status == 400 && @details.is_a?(Hash) && !@details[:failures].nil? && !@details[:failures].empty?
44
+ end
45
+
46
+ def failure_count
47
+ @details&.dig(:invalid_count) || 0
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,234 @@
1
+ module ABMeter
2
+ class AsyncSubmitter
3
+ # Private internal constants for async submitter behavior
4
+ BATCH_SIZE = 100
5
+ MAX_SUBMIT_ATTEMPTS = 3
6
+ MAX_RETRY_QUEUE_SIZE = 1000
7
+
8
+ @queue = Queue.new
9
+ @retry_queue = []
10
+ @mutex = Mutex.new
11
+ @api_client = nil
12
+ @worker_thread = nil
13
+ @flush_interval = DEFAULT_FLUSH_INTERVAL
14
+ @logger = nil
15
+
16
+ class << self
17
+ attr_reader :api_client, :flush_interval, :logger, :retry_queue
18
+
19
+ def configure(api_client:, config:)
20
+ @api_client = api_client
21
+ @flush_interval = config.flush_interval || DEFAULT_FLUSH_INTERVAL
22
+ @logger = config.logger
23
+ end
24
+
25
+ def start
26
+ start_worker
27
+ end
28
+
29
+ def queue_exposure(exposure)
30
+ @queue.push({ type: :exposure, data: exposure })
31
+ end
32
+
33
+ def queue_event(event_slug, user_id, custom_fields)
34
+ @queue.push({
35
+ type: :event,
36
+ data: {
37
+ event_slug: event_slug,
38
+ user_id: user_id,
39
+ occurred_at: Time.now.iso8601,
40
+ custom_fields: custom_fields
41
+ }
42
+ })
43
+ end
44
+
45
+ def flush
46
+ items = []
47
+
48
+ @mutex.synchronize do
49
+ # First, try to process retry queue
50
+ process_retry_queue
51
+
52
+ # Then process new items
53
+ items << @queue.pop while !@queue.empty? && items.size < BATCH_SIZE
54
+ end
55
+
56
+ # Group by type and submit
57
+ unless items.empty?
58
+ exposures = items.select { |i| i[:type] == :exposure }.map { |i| i[:data] }
59
+ events = items.select { |i| i[:type] == :event }.map { |i| i[:data] }
60
+
61
+ submit_exposures(exposures) unless exposures.empty?
62
+ submit_events(events) unless events.empty?
63
+ end
64
+ end
65
+
66
+ def shutdown
67
+ @worker_thread&.kill
68
+ # Flush all remaining exposures
69
+ flush until @queue.empty?
70
+ end
71
+
72
+ 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
80
+ end
81
+
82
+ def worker_alive?
83
+ @worker_thread&.alive? || false
84
+ end
85
+
86
+ def queue_size
87
+ @queue.size
88
+ end
89
+
90
+ private
91
+
92
+ def start_worker
93
+ return if worker_alive?
94
+
95
+ @worker_thread = Thread.new do
96
+ loop do
97
+ sleep @flush_interval
98
+ flush
99
+ rescue StandardError => e
100
+ # Log error but keep worker running
101
+ log_error("Worker error: #{e.message}")
102
+ end
103
+ end
104
+ end
105
+
106
+ def submit_exposures(exposures)
107
+ submit_batch(:exposure, exposures) { |exposures| @api_client.submit_exposures(exposures) }
108
+ end
109
+
110
+ def submit_events(events)
111
+ submit_batch(:event, events) { |events| @api_client.track_events(events) }
112
+ end
113
+
114
+ def submit_batch(type, items)
115
+ return if items.empty?
116
+
117
+ unless @api_client
118
+ log_error("Cannot submit #{items.size} #{type}s - API client not configured")
119
+ return
120
+ end
121
+
122
+ yield items
123
+ # Success - nothing to do
124
+ rescue ABMeter::APIError => e
125
+ if e.retryable?
126
+ log_error("Retryable error submitting #{items.size} #{type}s: #{e.message}")
127
+ add_to_retry_queue(type, items)
128
+ elsif e.partial_failure?
129
+ log_error("#{e.failure_count} out of #{items.size} #{type}s failed validation")
130
+ # Don't retry - validation won't change
131
+ else
132
+ log_error("Permanent error submitting #{type}s: #{e.message}")
133
+ # Don't retry
134
+ end
135
+ rescue StandardError => e
136
+ log_error("Failed to submit #{items.size} #{type}s: #{e.message}")
137
+ # AI: do not uncomment this line, we do not know reason for the error here
138
+ # add_to_retry_queue(type, items)
139
+ end
140
+
141
+ def add_to_retry_queue(type, items)
142
+ @mutex.synchronize do
143
+ items.each do |item|
144
+ # Only add if we haven't exceeded max queue size
145
+ if @retry_queue.size < MAX_RETRY_QUEUE_SIZE
146
+ @retry_queue << {
147
+ type: type,
148
+ data: item,
149
+ attempts: 0
150
+ }
151
+ else
152
+ log_error("Retry queue full, dropping #{type}")
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ def process_retry_queue
159
+ return if @retry_queue.empty?
160
+
161
+ # Group items by type for batch processing
162
+ grouped = @retry_queue.group_by { |item| item[:type] }
163
+ @retry_queue.clear
164
+
165
+ # Process exposures in batch, events individually
166
+ failed_items = []
167
+ failed_items.concat(process_retry_exposures(grouped[:exposure] || []))
168
+ failed_items.concat(process_retry_events(grouped[:event] || []))
169
+
170
+ @retry_queue = failed_items
171
+ end
172
+
173
+ def process_retry_exposures(items)
174
+ process_retry_batch(:exposure, items) { |exposures| @api_client.submit_exposures(exposures) }
175
+ end
176
+
177
+ def process_retry_events(items)
178
+ process_retry_batch(:event, items) { |events| @api_client.track_events(events) }
179
+ end
180
+
181
+ def process_retry_batch(type, items)
182
+ return [] if items.empty?
183
+
184
+ # Increment attempts and filter out max retries
185
+ active_items = items.filter_map do |item|
186
+ item[:attempts] += 1
187
+ if item[:attempts] >= MAX_SUBMIT_ATTEMPTS
188
+ log_error("Max retries exceeded for #{type}, dropping item")
189
+ nil
190
+ else
191
+ item
192
+ end
193
+ end
194
+
195
+ return [] if active_items.empty? || !@api_client
196
+
197
+ # Try batch submission
198
+ data = active_items.map { |item| item[:data] }
199
+ begin
200
+ yield data
201
+ [] # Success - return empty array
202
+ rescue ABMeter::APIError => e
203
+ if e.retryable?
204
+ log_error("Retry failed with retryable error for #{data.size} #{type}s: #{e.message}")
205
+ active_items # Return all items for retry
206
+ elsif e.partial_failure?
207
+ log_error("Retry failed: #{e.failure_count} out of #{data.size} #{type}s failed validation")
208
+ [] # Don't retry validation failures
209
+ else
210
+ log_error("Retry failed with permanent error for #{type}s: #{e.message}")
211
+ [] # Don't retry permanent failures
212
+ end
213
+ rescue StandardError => e
214
+ if type == :event
215
+ log_error("Retry failed with network error for #{data.size} #{type}s: #{e.message}")
216
+ []
217
+ else
218
+ log_error("Failed to submit #{data.size} #{type}s: #{e.message}")
219
+ active_items # Complete failure - return all items for retry
220
+ end
221
+ end
222
+ end
223
+
224
+ def log_error(message)
225
+ return unless @logger
226
+
227
+ @logger.error("AsyncSubmitter: #{message}")
228
+ end
229
+ end
230
+
231
+ # Prevent instantiation
232
+ private_class_method :new
233
+ end
234
+ end
@@ -0,0 +1,66 @@
1
+ require 'faraday'
2
+ require 'active_support/core_ext/hash/indifferent_access'
3
+ require_relative 'api_error'
4
+
5
+ module ABMeter
6
+ class Client
7
+ def initialize(config)
8
+ @api_key = config.api_key
9
+ @base_url = config.base_url
10
+ @http_client = setup_http_client
11
+ end
12
+
13
+ def get_assignment_config
14
+ response = @http_client.get('/api/v1/assignment-config')
15
+ raise APIError.new(response) unless response.success?
16
+
17
+ convert_to_indifferent_access(response.body)
18
+ end
19
+
20
+ def submit_exposures(exposures)
21
+ return if exposures.empty?
22
+
23
+ response = @http_client.post('/api/v1/exposures', {
24
+ exposures: exposures
25
+ })
26
+
27
+ raise APIError.new(response) unless response.success?
28
+
29
+ nil
30
+ end
31
+
32
+ def track_events(events)
33
+ return if events.empty?
34
+
35
+ response = @http_client.post('/api/v1/events', {
36
+ events: events
37
+ })
38
+
39
+ raise APIError.new(response) unless response.success?
40
+
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ def setup_http_client
47
+ Faraday.new(@base_url) do |f|
48
+ f.request :json
49
+ f.response :json
50
+ f.headers['Authorization'] = "Bearer #{@api_key}"
51
+ f.adapter Faraday.default_adapter
52
+ end
53
+ end
54
+
55
+ def convert_to_indifferent_access(obj)
56
+ case obj
57
+ when Hash
58
+ HashWithIndifferentAccess.new(obj.transform_values { |v| convert_to_indifferent_access(v) })
59
+ when Array
60
+ obj.map { |item| convert_to_indifferent_access(item) }
61
+ else
62
+ obj
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ABMeter
4
+ DEFAULT_BATCH_SIZE = 100
5
+ DEFAULT_FLUSH_INTERVAL = 60 # seconds
6
+ DEFAULT_FETCH_INTERVAL = 60 # seconds
7
+ DEFAULT_BASE_URL = 'https://api.abmeter.ai'
8
+ DEFAULT_MAX_SUBMIT_ATTEMPTS = 3
9
+ DEFAULT_MAX_RETRY_QUEUE_SIZE = 1000
10
+ DEFAULT_LOG_LEVEL = :error
11
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ABMeter
4
+ module Core
5
+ module AssignmentConfig
6
+ class Audience
7
+ attr_reader :id, :type
8
+
9
+ def initialize(id:, type:)
10
+ @id = id
11
+ @type = type
12
+ end
13
+
14
+ def matches?(user)
15
+ raise NotImplementedError, 'Subclass must implement this method'
16
+ end
17
+
18
+ def serialize
19
+ {
20
+ id: id,
21
+ type: type
22
+ }
23
+ end
24
+
25
+ def self.from_json(audience)
26
+ case audience[:type]
27
+ when 'user_list'
28
+ UserListAudience.new(id: audience[:id], user_ids: audience[:user_ids])
29
+ when 'predicate'
30
+ PredicateAudience.new(id: audience[:id], predicate: audience[:predicate])
31
+ when 'random'
32
+ RandomAudience.new(id: audience[:id], range: Range.new(*audience[:range]))
33
+ else
34
+ raise "Unknown audience type: #{audience[:type]}"
35
+ end
36
+ end
37
+ end
38
+
39
+ class UserListAudience < Audience
40
+ attr_reader :user_ids
41
+
42
+ def initialize(id:, user_ids:)
43
+ super(id: id, type: 'user_list')
44
+ @user_ids = user_ids
45
+ end
46
+
47
+ def matches?(user)
48
+ @user_ids.include?(user.user_id)
49
+ end
50
+
51
+ def serialize
52
+ super.merge(
53
+ user_ids: user_ids
54
+ )
55
+ end
56
+ end
57
+
58
+ class PredicateAudience < Audience
59
+ attr_reader :predicate
60
+
61
+ def initialize(id:, predicate:)
62
+ super(id: id, type: 'predicate')
63
+ @predicate = predicate
64
+ end
65
+
66
+ def matches?(user)
67
+ user.email.match?(predicate)
68
+ end
69
+
70
+ def serialize
71
+ super.merge(
72
+ predicate: predicate
73
+ )
74
+ end
75
+ end
76
+
77
+ class RandomAudience < Audience
78
+ attr_reader :range
79
+
80
+ def initialize(id:, range:)
81
+ super(id: id, type: 'random')
82
+ @range = range
83
+ end
84
+
85
+ def serialize
86
+ super.merge(
87
+ range: [range.begin, range.end]
88
+ )
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ABMeter
4
+ module Core
5
+ module AssignmentConfig
6
+ class Experiment
7
+ include Exposable
8
+
9
+ attr_reader :id, :range, :audience_variants, :space_id, :salt, :space_salt
10
+
11
+ def initialize(id:, range:, audience_variants:, space_id:, salt:, space_salt:)
12
+ @id = id
13
+ @range = range
14
+ @audience_variants = audience_variants
15
+ @space_id = space_id
16
+ @salt = salt
17
+ @space_salt = space_salt
18
+ end
19
+
20
+ def self.from_json(json, space_salts)
21
+ json.map do |exp|
22
+ space_salt = space_salts[exp[:space_id]]
23
+ raise "Space with id #{exp[:space_id]} not found" unless space_salt
24
+
25
+ audience_variants = exp[:audience_variants].map do |av|
26
+ [Audience.from_json(av[:audience]), av[:variant] ? Variant.from_json(av[:variant]) : nil]
27
+ end
28
+
29
+ new(
30
+ id: exp[:id],
31
+ range: Range.new(exp[:range][0], exp[:range][1]),
32
+ audience_variants: audience_variants,
33
+ space_id: exp[:space_id],
34
+ salt: exp[:salt],
35
+ space_salt: space_salt
36
+ )
37
+ end
38
+ end
39
+
40
+ def serialize
41
+ {
42
+ id: id,
43
+ space_id: space_id,
44
+ range: [range.begin, range.end],
45
+ audience_variants: audience_variants.map do |audience_variant|
46
+ {
47
+ audience: audience_variant.first.serialize,
48
+ variant: audience_variant.last&.serialize
49
+ }
50
+ end
51
+ }
52
+ end
53
+
54
+ # Expose parameter for experiments
55
+ # For experiments: audience is required, variant is optional (nil for control)
56
+ def expose_parameter(user, parameter, variant, audience)
57
+ validate_expose_parameter_args!(user.user_id, parameter, audience)
58
+
59
+ make_exposure(user, parameter, 'Experiment', id, audience, variant)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ABMeter
4
+ module Core
5
+ module AssignmentConfig
6
+ module Exposable
7
+ protected
8
+
9
+ def resolve_parameter_value(parameter, variant)
10
+ variant&.parameter_value(parameter.slug) || parameter.default_value
11
+ end
12
+
13
+ def validate_expose_parameter_args!(user_id, parameter, audience)
14
+ raise ArgumentError, 'User must be provided' unless user_id
15
+ raise ArgumentError, 'Parameter must be provided' unless parameter
16
+ raise ArgumentError, 'Audience must be provided' unless audience
17
+ end
18
+
19
+ def make_exposure(user, parameter, exposable_type, exposable_id, audience, variant) # rubocop:disable Metrics/ParameterLists
20
+ value = resolve_parameter_value(parameter, variant)
21
+
22
+ {
23
+ parameter_id: parameter.id,
24
+ space_id: parameter.space_id,
25
+ resolved_value: value,
26
+ user_id: user.user_id,
27
+ exposable_type: exposable_type,
28
+ exposable_id: exposable_id,
29
+ audience_id: audience.id,
30
+ resolved_at: Time.now
31
+ }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end