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 +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +37 -3
- data/bin/console +15 -0
- data/bin/rspec +27 -0
- data/lib/abmeter/api_error.rb +50 -0
- data/lib/abmeter/async_submitter.rb +234 -0
- data/lib/abmeter/client.rb +66 -0
- data/lib/abmeter/constants.rb +11 -0
- data/lib/abmeter/core/assignment_config/audience.rb +93 -0
- data/lib/abmeter/core/assignment_config/experiment.rb +64 -0
- data/lib/abmeter/core/assignment_config/exposable.rb +36 -0
- data/lib/abmeter/core/assignment_config/feature_flag.rb +45 -0
- data/lib/abmeter/core/assignment_config/parameter.rb +41 -0
- data/lib/abmeter/core/assignment_config/space.rb +32 -0
- data/lib/abmeter/core/assignment_config/variant.rb +37 -0
- data/lib/abmeter/core/assignment_config.rb +50 -0
- data/lib/abmeter/core/protocol/type.rb +170 -0
- data/lib/abmeter/core/user.rb +14 -0
- data/lib/abmeter/core/user_parameter_resolver.rb +90 -0
- data/lib/abmeter/core/utils/num_utils.rb +55 -0
- data/lib/abmeter/core.rb +75 -0
- data/lib/abmeter/error_safety.rb +39 -0
- data/lib/abmeter/resolver_provider.rb +63 -0
- data/lib/abmeter/version.rb +3 -0
- data/lib/abmeter.rb +179 -0
- metadata +64 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d96ae2882d0054f4e6b414ce1d427b7cc2eb2d31dc3b93f7383f98b384922682
|
|
4
|
+
data.tar.gz: aded2a5117637b1c34c92318d5097380ce3f9cd6ad400297e98b5e29de8d2810
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
3
|
+
A simple A/B testing client library for Ruby applications.
|
|
4
4
|
|
|
5
|
-
|
|
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
|