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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ABMeter
|
|
4
|
+
module Core
|
|
5
|
+
module AssignmentConfig
|
|
6
|
+
class FeatureFlag
|
|
7
|
+
include Exposable
|
|
8
|
+
|
|
9
|
+
attr_reader :id, :variant, :audience
|
|
10
|
+
|
|
11
|
+
def initialize(id:, variant:, audience:)
|
|
12
|
+
@id = id
|
|
13
|
+
@variant = variant
|
|
14
|
+
@audience = audience
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.from_json(json)
|
|
18
|
+
json.map do |flag|
|
|
19
|
+
new(
|
|
20
|
+
id: flag[:id],
|
|
21
|
+
audience: Audience.from_json(flag[:audience]),
|
|
22
|
+
variant: Variant.from_json(flag[:variant])
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def serialize(*_)
|
|
28
|
+
{
|
|
29
|
+
id: id,
|
|
30
|
+
audience: audience.serialize,
|
|
31
|
+
variant: variant.serialize
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Expose parameter for feature flags
|
|
36
|
+
def expose_parameter(user, parameter)
|
|
37
|
+
validate_expose_parameter_args!(user.user_id, parameter, audience)
|
|
38
|
+
raise ArgumentError, 'Variant must be provided for feature flags' unless variant
|
|
39
|
+
|
|
40
|
+
make_exposure(user, parameter, 'FeatureFlag', id, audience, variant)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ABMeter
|
|
4
|
+
module Core
|
|
5
|
+
module AssignmentConfig
|
|
6
|
+
class Parameter
|
|
7
|
+
attr_reader :id, :slug, :parameter_type, :default_value, :space_id
|
|
8
|
+
|
|
9
|
+
def initialize(id:, slug:, parameter_type:, default_value:, space_id:)
|
|
10
|
+
@id = id
|
|
11
|
+
@slug = slug
|
|
12
|
+
@parameter_type = parameter_type
|
|
13
|
+
@default_value = default_value
|
|
14
|
+
@space_id = space_id
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.from_json(json)
|
|
18
|
+
json.map do |param|
|
|
19
|
+
new(
|
|
20
|
+
id: param[:id],
|
|
21
|
+
slug: param[:slug],
|
|
22
|
+
default_value: ABMeter::Core::Protocol.cast!(param[:default_value], param[:parameter_type]),
|
|
23
|
+
parameter_type: param[:parameter_type],
|
|
24
|
+
space_id: param[:space_id]
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def serialize(*_)
|
|
30
|
+
{
|
|
31
|
+
id: id,
|
|
32
|
+
slug: slug,
|
|
33
|
+
parameter_type: parameter_type,
|
|
34
|
+
default_value: default_value.to_s,
|
|
35
|
+
space_id: space_id
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ABMeter
|
|
4
|
+
module Core
|
|
5
|
+
module AssignmentConfig
|
|
6
|
+
class Space
|
|
7
|
+
attr_reader :id, :salt
|
|
8
|
+
|
|
9
|
+
def initialize(id:, salt:)
|
|
10
|
+
@id = id
|
|
11
|
+
@salt = salt
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.from_json(json)
|
|
15
|
+
json.map do |space|
|
|
16
|
+
new(
|
|
17
|
+
id: space[:id],
|
|
18
|
+
salt: space[:salt]
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def serialize
|
|
24
|
+
{
|
|
25
|
+
id: id,
|
|
26
|
+
salt: salt
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ABMeter
|
|
4
|
+
module Core
|
|
5
|
+
module AssignmentConfig
|
|
6
|
+
class Variant
|
|
7
|
+
attr_reader :id, :parameter_values
|
|
8
|
+
|
|
9
|
+
def initialize(id:, parameter_values:)
|
|
10
|
+
@id = id
|
|
11
|
+
@parameter_values = parameter_values
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parameter_value(parameter_slug)
|
|
15
|
+
@parameter_values[parameter_slug]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.from_json(variant)
|
|
19
|
+
parameter_values = variant[:parameter_values].map { |pv| [pv[:slug], pv[:value]] }.to_h
|
|
20
|
+
Variant.new(id: variant[:id], parameter_values: parameter_values)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def serialize
|
|
24
|
+
{
|
|
25
|
+
id: id,
|
|
26
|
+
parameter_values: parameter_values.map do |parameter_slug, value|
|
|
27
|
+
{
|
|
28
|
+
slug: parameter_slug,
|
|
29
|
+
value: value
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ABMeter
|
|
6
|
+
module Core
|
|
7
|
+
module AssignmentConfig
|
|
8
|
+
class Config
|
|
9
|
+
attr_reader :feature_flags, :experiments, :parameters, :spaces
|
|
10
|
+
|
|
11
|
+
def initialize(feature_flags:, experiments:, parameters:, spaces:)
|
|
12
|
+
@feature_flags = feature_flags
|
|
13
|
+
@experiments = experiments
|
|
14
|
+
@parameters = parameters
|
|
15
|
+
@spaces = spaces
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_json(*_)
|
|
19
|
+
serialize.to_json
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def serialize
|
|
23
|
+
{
|
|
24
|
+
spaces: spaces.sort_by(&:id).map(&:serialize),
|
|
25
|
+
parameters: parameters.sort_by(&:id).map(&:serialize),
|
|
26
|
+
feature_flags: feature_flags.sort_by(&:id).map(&:serialize),
|
|
27
|
+
experiments: experiments.sort_by(&:id).map(&:serialize)
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.from_json(json)
|
|
33
|
+
parsed_json = JSON.parse(json, symbolize_names: true)
|
|
34
|
+
|
|
35
|
+
spaces = Space.from_json(parsed_json[:spaces])
|
|
36
|
+
space_salts = spaces.to_h { |space| [space.id, space.salt] }
|
|
37
|
+
parameters = Parameter.from_json(parsed_json[:parameters])
|
|
38
|
+
feature_flags = FeatureFlag.from_json(parsed_json[:feature_flags])
|
|
39
|
+
experiments = parsed_json[:experiments] ? Experiment.from_json(parsed_json[:experiments], space_salts) : []
|
|
40
|
+
|
|
41
|
+
Config.new(
|
|
42
|
+
spaces: spaces,
|
|
43
|
+
feature_flags: feature_flags,
|
|
44
|
+
experiments: experiments,
|
|
45
|
+
parameters: parameters
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ABMeter
|
|
4
|
+
module Core
|
|
5
|
+
module Protocol
|
|
6
|
+
# Type name constants
|
|
7
|
+
STRING = 'String'
|
|
8
|
+
INTEGER = 'Integer'
|
|
9
|
+
FLOAT = 'Float'
|
|
10
|
+
BOOLEAN = 'Boolean'
|
|
11
|
+
|
|
12
|
+
class Type
|
|
13
|
+
attr_reader :name
|
|
14
|
+
|
|
15
|
+
def initialize(name)
|
|
16
|
+
@name = name
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def numerical?
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def cast!(value)
|
|
24
|
+
value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
name
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class StringType < Type
|
|
33
|
+
def initialize
|
|
34
|
+
super(STRING)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def cast!(value)
|
|
38
|
+
value.to_s
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class IntegerType < Type
|
|
43
|
+
def initialize
|
|
44
|
+
super(INTEGER)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def numerical?
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def cast!(value)
|
|
52
|
+
Integer(value)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class FloatType < Type
|
|
57
|
+
def initialize
|
|
58
|
+
super(FLOAT)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def numerical?
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def cast!(value)
|
|
66
|
+
Float(value)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class BooleanType < Type
|
|
71
|
+
def initialize
|
|
72
|
+
super(BOOLEAN)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def cast!(value)
|
|
76
|
+
case value
|
|
77
|
+
when true, 'true', 'TRUE', 't', 'T', '1', 1
|
|
78
|
+
true
|
|
79
|
+
when false, 'false', 'FALSE', 'f', 'F', '0', 0, nil, ''
|
|
80
|
+
false
|
|
81
|
+
else
|
|
82
|
+
raise ArgumentError, "Cannot cast #{value.inspect} to Boolean"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class TypeRegistry
|
|
88
|
+
def initialize
|
|
89
|
+
@types = {}
|
|
90
|
+
register_default_types
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def register(type)
|
|
94
|
+
@types[type.name] = type
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def get(type_name)
|
|
98
|
+
@types[type_name] || raise("Unknown type: #{type_name}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def all
|
|
102
|
+
@types.values
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def numerical_types
|
|
106
|
+
@_numerical_types ||= all.select(&:numerical?)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def register_default_types
|
|
112
|
+
register(StringType.new)
|
|
113
|
+
register(IntegerType.new)
|
|
114
|
+
register(FloatType.new)
|
|
115
|
+
register(BooleanType.new)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Protocol class methods
|
|
120
|
+
class << self
|
|
121
|
+
def string
|
|
122
|
+
type(STRING)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def integer
|
|
126
|
+
type(INTEGER)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def float
|
|
130
|
+
type(FLOAT)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def boolean
|
|
134
|
+
type(BOOLEAN)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def type(name)
|
|
138
|
+
registry.get(name)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def all_types
|
|
142
|
+
registry.all.map(&:name)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def numerical?(type_name)
|
|
146
|
+
type(type_name).numerical?
|
|
147
|
+
rescue StandardError
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def cast!(value, type_name)
|
|
152
|
+
type(type_name).cast!(value)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def valid_for_type?(value, type_name)
|
|
156
|
+
cast!(value, type_name)
|
|
157
|
+
true
|
|
158
|
+
rescue ArgumentError
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def registry
|
|
165
|
+
@registry ||= TypeRegistry.new
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ABMeter
|
|
4
|
+
module Core
|
|
5
|
+
class UserParameterResolver
|
|
6
|
+
attr_reader :config
|
|
7
|
+
|
|
8
|
+
def initialize(config:)
|
|
9
|
+
@config = config
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def exposure_for(user:, parameter_slug:)
|
|
13
|
+
validate_user!(user)
|
|
14
|
+
|
|
15
|
+
# Find parameter once, upfront
|
|
16
|
+
parameter = @config.parameters.find { |p| p.slug == parameter_slug }
|
|
17
|
+
raise "Parameter '#{parameter_slug}' not found" unless parameter
|
|
18
|
+
|
|
19
|
+
# First check feature flags that control this parameter
|
|
20
|
+
feature_flag = find_matching_feature_flag(user, parameter_slug)
|
|
21
|
+
return feature_flag.expose_parameter(user, parameter) if feature_flag
|
|
22
|
+
|
|
23
|
+
# Then check experiments that control this parameter
|
|
24
|
+
experiment_result = find_matching_experiment_variant(user, parameter_slug)
|
|
25
|
+
if experiment_result
|
|
26
|
+
variant, experiment, audience = experiment_result
|
|
27
|
+
return experiment.expose_parameter(user, parameter, variant, audience)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
parameter_id: parameter.id,
|
|
32
|
+
space_id: parameter.space_id,
|
|
33
|
+
resolved_value: parameter.default_value,
|
|
34
|
+
user_id: nil,
|
|
35
|
+
exposable_type: nil,
|
|
36
|
+
exposable_id: nil,
|
|
37
|
+
audience_id: nil,
|
|
38
|
+
resolved_at: Time.now
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def validate_user!(user)
|
|
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
|
+
end
|
|
48
|
+
|
|
49
|
+
def find_matching_feature_flag(user, parameter_slug)
|
|
50
|
+
@config.feature_flags.find do |feature_flag|
|
|
51
|
+
# Only match if user is in audience AND the flag's variant controls this parameter
|
|
52
|
+
feature_flag.audience.matches?(user) &&
|
|
53
|
+
feature_flag_controls_parameter?(feature_flag, parameter_slug)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def feature_flag_controls_parameter?(feature_flag, parameter_slug)
|
|
58
|
+
feature_flag.variant&.parameter_values&.key?(parameter_slug)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def find_matching_experiment_variant(user, parameter_slug)
|
|
62
|
+
@config.experiments.each do |experiment|
|
|
63
|
+
# Skip experiments that don't control this parameter
|
|
64
|
+
next unless experiment_controls_parameter?(experiment, parameter_slug)
|
|
65
|
+
|
|
66
|
+
# Check if user is allocated to this experiment using space salt
|
|
67
|
+
experiment_percentage = ABMeter::Core::Utils::NumUtils.to_percentage(experiment.space_salt, user.user_id)
|
|
68
|
+
next unless experiment.range.include?(experiment_percentage)
|
|
69
|
+
|
|
70
|
+
# Then check which audience the user belongs to using experiment salt
|
|
71
|
+
user_percentage = ABMeter::Core::Utils::NumUtils.to_percentage(experiment.salt, user.user_id)
|
|
72
|
+
|
|
73
|
+
assigned_av = experiment.audience_variants.find do |av|
|
|
74
|
+
av.first.range.include?(user_percentage)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return [assigned_av.last, experiment, assigned_av.first] if assigned_av
|
|
78
|
+
end
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def experiment_controls_parameter?(experiment, parameter_slug)
|
|
83
|
+
experiment.audience_variants.any? do |av|
|
|
84
|
+
variant = av.last
|
|
85
|
+
variant&.parameter_values&.key?(parameter_slug)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module ABMeter
|
|
6
|
+
module Core
|
|
7
|
+
module Utils
|
|
8
|
+
# NumUtils provides deterministic percentage assignment for A/B testing
|
|
9
|
+
#
|
|
10
|
+
# This implementation uses SHA256 + multiply-and-shift algorithm:
|
|
11
|
+
# - Zero external dependencies (uses Ruby's built-in Digest)
|
|
12
|
+
# - Fast execution (< 2 microseconds per assignment)
|
|
13
|
+
# - Cryptographically secure randomness
|
|
14
|
+
# - Distribution quality sufficient for A/B testing:
|
|
15
|
+
# - 10K samples: ~3% average deviation (normal for this sample size)
|
|
16
|
+
# - 100K samples: ~0.9% average deviation (good for statistical significance)
|
|
17
|
+
# - 1M samples: ~0.7% average deviation (excellent uniformity)
|
|
18
|
+
#
|
|
19
|
+
# The multiply-and-shift method avoids modulo bias by scaling the hash
|
|
20
|
+
# value proportionally across the entire 64-bit space before mapping
|
|
21
|
+
# to the 1-100 range.
|
|
22
|
+
class NumUtils
|
|
23
|
+
def self.to_percentage(salt, id)
|
|
24
|
+
# Use a hash function to generate a deterministic but random-looking number
|
|
25
|
+
hash = Digest::SHA256.hexdigest("#{salt}:#{id}")
|
|
26
|
+
|
|
27
|
+
# Convert first 16 characters (64 bits) to integer for better distribution
|
|
28
|
+
# This gives us a number between 0 and 2^64-1
|
|
29
|
+
num = hash[0..15].to_i(16)
|
|
30
|
+
|
|
31
|
+
# Industry-standard multiply-and-shift method for uniform distribution
|
|
32
|
+
# This avoids modulo bias by scaling the 64-bit space proportionally
|
|
33
|
+
# Formula: (num * range) >> bits = (num * 100) >> 64
|
|
34
|
+
# This maps [0, 2^64) uniformly to [0, 100)
|
|
35
|
+
percentage = (num * 100) >> 64
|
|
36
|
+
|
|
37
|
+
# Add 1 to get 1-100 range instead of 0-99
|
|
38
|
+
percentage + 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# [10, 20, 30, 40] -> [(1..10) (11..30), (31..60), (61..100)]
|
|
42
|
+
def self.percentages_to_ranges(percentages)
|
|
43
|
+
ranges = []
|
|
44
|
+
percentages.each do |percentage|
|
|
45
|
+
last_end = ranges.last&.end || 0
|
|
46
|
+
start_val = last_end + 1
|
|
47
|
+
end_val = last_end + percentage
|
|
48
|
+
ranges << (start_val..end_val)
|
|
49
|
+
end
|
|
50
|
+
ranges
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/abmeter/core.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ostruct'
|
|
4
|
+
require_relative 'version'
|
|
5
|
+
|
|
6
|
+
# Utils
|
|
7
|
+
require_relative 'core/utils/num_utils'
|
|
8
|
+
|
|
9
|
+
# Protocol types
|
|
10
|
+
require_relative 'core/protocol/type'
|
|
11
|
+
|
|
12
|
+
# Assignment Config - order matters
|
|
13
|
+
require_relative 'core/assignment_config/exposable'
|
|
14
|
+
require_relative 'core/assignment_config/space'
|
|
15
|
+
require_relative 'core/assignment_config/parameter'
|
|
16
|
+
require_relative 'core/assignment_config/variant'
|
|
17
|
+
require_relative 'core/assignment_config/audience'
|
|
18
|
+
require_relative 'core/assignment_config/experiment'
|
|
19
|
+
require_relative 'core/assignment_config/feature_flag'
|
|
20
|
+
require_relative 'core/assignment_config'
|
|
21
|
+
|
|
22
|
+
# Parameter resolution
|
|
23
|
+
require_relative 'core/user_parameter_resolver'
|
|
24
|
+
|
|
25
|
+
# Domain objects
|
|
26
|
+
require_relative 'core/user'
|
|
27
|
+
|
|
28
|
+
module ABMeter
|
|
29
|
+
module Core
|
|
30
|
+
class Error < StandardError; end
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Get type object by name
|
|
34
|
+
def type(type_name)
|
|
35
|
+
Protocol.type(type_name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get all available types
|
|
39
|
+
def all_types
|
|
40
|
+
Protocol.all_types
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if type is numerical
|
|
44
|
+
def numerical?(type_name)
|
|
45
|
+
Protocol.numerical?(type_name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Cast value to given type (strict - raises ArgumentError on invalid)
|
|
49
|
+
def cast!(value, type_name)
|
|
50
|
+
Protocol.cast!(value, type_name)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check if value is valid for type
|
|
54
|
+
def valid_for_type?(value, type_name)
|
|
55
|
+
Protocol.valid_for_type?(value, type_name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Build a resolver from JSON configuration
|
|
59
|
+
def build_resolver_from_json(json)
|
|
60
|
+
config = AssignmentConfig.from_json(json)
|
|
61
|
+
UserParameterResolver.new(config: config)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Provide convenience access to utilities
|
|
65
|
+
def num_utils
|
|
66
|
+
Utils::NumUtils
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Convert percentages to ranges for experiment allocation
|
|
70
|
+
def percentages_to_ranges(percentages)
|
|
71
|
+
Utils::NumUtils.percentages_to_ranges(percentages)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module ABMeter
|
|
2
|
+
module ErrorSafety
|
|
3
|
+
def self.included(base)
|
|
4
|
+
base.extend(ClassMethods)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module ClassMethods
|
|
8
|
+
# DSL method to wrap methods with error handling
|
|
9
|
+
# Usage: error_safe :method_name
|
|
10
|
+
def error_safe(method_name)
|
|
11
|
+
original_method = instance_method(method_name)
|
|
12
|
+
|
|
13
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
14
|
+
original_method.bind(self).call(*args, **kwargs, &block)
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
log_error("Failed to execute #{method_name}", e)
|
|
17
|
+
call_error_callback(e) if @config&.error_callback
|
|
18
|
+
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def log_error(message, error)
|
|
27
|
+
return unless @config&.logger
|
|
28
|
+
|
|
29
|
+
@config.logger.error("#{message}: #{error.class} - #{error.message}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call_error_callback(error)
|
|
33
|
+
@config.error_callback&.call(error)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
# Don't let callback errors escape either
|
|
36
|
+
log_error('Error in error callback', e)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|