growthbook 0.0.1 → 0.1.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/lib/growthbook/conditions.rb +174 -0
- data/lib/growthbook/context.rb +225 -0
- data/lib/growthbook/feature.rb +41 -0
- data/lib/growthbook/feature_result.rb +61 -0
- data/lib/growthbook/feature_rule.rb +78 -0
- data/lib/growthbook/inline_experiment.rb +80 -0
- data/lib/growthbook/inline_experiment_result.rb +50 -0
- data/lib/growthbook/util.rb +116 -15
- data/lib/growthbook.rb +11 -2
- data/spec/cases.json +2768 -0
- data/spec/client_spec.rb +57 -0
- data/spec/context_spec.rb +120 -0
- data/spec/json_spec.rb +159 -0
- data/spec/user_spec.rb +213 -0
- data/spec/util_spec.rb +154 -0
- metadata +22 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8a5a979e06c6d8d34b1b8caede098ba6fae05c0e8f6fe82b9b70da2fbd906959
|
4
|
+
data.tar.gz: 55f4d754a3584f5602c5d45f3f3e285cbb18d35a0ed0cdfcaf103b4dd269e81d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 86d3828e8bf6af502fb71d61ad3aa8c092cda8cec3034b72ae6a24baec26f8b377b471ebcd1f3a85a58b4bd5f45dd08f2fc82a285d413414022c785c41190c2c
|
7
|
+
data.tar.gz: d996803716a5c0348278f6e3a067b55bf523fa0abb8c7d09671d61c644e659cdac21104afc0faeb31e7a0054c8b3831966e00462b71ef0f53226a20b291be2d6
|
@@ -0,0 +1,174 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Growthbook
|
6
|
+
class Conditions
|
7
|
+
# Evaluate a targeting conditions hash against an attributes hash
|
8
|
+
# Both attributes and conditions only have string keys (no symbols)
|
9
|
+
def self.eval_condition(attributes, condition)
|
10
|
+
return eval_or(attributes, condition['$or']) if condition.key?('$or')
|
11
|
+
return !eval_or(attributes, condition['$nor']) if condition.key?('$nor')
|
12
|
+
return eval_and(attributes, condition['$and']) if condition.key?('$and')
|
13
|
+
return !eval_condition(attributes, condition['$not']) if condition.key?('$not')
|
14
|
+
|
15
|
+
condition.each do |key, value|
|
16
|
+
return false unless eval_condition_value(value, get_path(attributes, key))
|
17
|
+
end
|
18
|
+
|
19
|
+
true
|
20
|
+
end
|
21
|
+
|
22
|
+
# Helper function to ensure conditions only have string keys (no symbols)
|
23
|
+
def self.parse_condition(condition)
|
24
|
+
case condition
|
25
|
+
when Array
|
26
|
+
return condition.map { |v| parse_condition(v) }
|
27
|
+
when Hash
|
28
|
+
return condition.map { |k, v| [k.to_s, parse_condition(v)] }.to_h
|
29
|
+
end
|
30
|
+
|
31
|
+
condition
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.eval_or(attributes, conditions)
|
35
|
+
return true if conditions.length <= 0
|
36
|
+
|
37
|
+
conditions.each do |condition|
|
38
|
+
return true if eval_condition(attributes, condition)
|
39
|
+
end
|
40
|
+
false
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.eval_and(attributes, conditions)
|
44
|
+
conditions.each do |condition|
|
45
|
+
return false unless eval_condition(attributes, condition)
|
46
|
+
end
|
47
|
+
true
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.is_operator_object(obj)
|
51
|
+
obj.each do |key, _value|
|
52
|
+
return false if key[0] != '$'
|
53
|
+
end
|
54
|
+
true
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.get_type(attribute_value)
|
58
|
+
return 'string' if attribute_value.is_a? String
|
59
|
+
return 'number' if attribute_value.is_a? Integer
|
60
|
+
return 'number' if attribute_value.is_a? Float
|
61
|
+
return 'boolean' if attribute_value == true || attribute_value == false
|
62
|
+
return 'array' if attribute_value.is_a? Array
|
63
|
+
return 'null' if attribute_value.nil?
|
64
|
+
|
65
|
+
'object'
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.get_path(attributes, path)
|
69
|
+
parts = path.split('.')
|
70
|
+
current = attributes
|
71
|
+
|
72
|
+
parts.each do |value|
|
73
|
+
if current && current.is_a?(Hash) && current.key?(value)
|
74
|
+
current = current[value]
|
75
|
+
else
|
76
|
+
return nil
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
current
|
81
|
+
end
|
82
|
+
|
83
|
+
def self.eval_condition_value(condition_value, attribute_value)
|
84
|
+
if condition_value.is_a?(Hash) && is_operator_object(condition_value)
|
85
|
+
condition_value.each do |key, value|
|
86
|
+
return false unless eval_operator_condition(key, attribute_value, value)
|
87
|
+
end
|
88
|
+
return true
|
89
|
+
end
|
90
|
+
condition_value.to_json == attribute_value.to_json
|
91
|
+
end
|
92
|
+
|
93
|
+
def self.elem_match(condition, attribute_value)
|
94
|
+
return false unless attribute_value.is_a? Array
|
95
|
+
|
96
|
+
attribute_value.each do |item|
|
97
|
+
if is_operator_object(condition)
|
98
|
+
return true if eval_condition_value(condition, item)
|
99
|
+
elsif eval_condition(item, condition)
|
100
|
+
return true
|
101
|
+
end
|
102
|
+
end
|
103
|
+
false
|
104
|
+
end
|
105
|
+
|
106
|
+
def self.eval_operator_condition(operator, attribute_value, condition_value)
|
107
|
+
case operator
|
108
|
+
when '$eq'
|
109
|
+
attribute_value == condition_value
|
110
|
+
when '$ne'
|
111
|
+
attribute_value != condition_value
|
112
|
+
when '$lt'
|
113
|
+
attribute_value < condition_value
|
114
|
+
when '$lte'
|
115
|
+
attribute_value <= condition_value
|
116
|
+
when '$gt'
|
117
|
+
attribute_value > condition_value
|
118
|
+
when '$gte'
|
119
|
+
attribute_value >= condition_value
|
120
|
+
when '$regex'
|
121
|
+
silence_warnings do
|
122
|
+
re = Regexp.new(condition_value)
|
123
|
+
!!attribute_value.match(re)
|
124
|
+
rescue StandardError
|
125
|
+
false
|
126
|
+
end
|
127
|
+
when '$in'
|
128
|
+
condition_value.include? attribute_value
|
129
|
+
when '$nin'
|
130
|
+
!(condition_value.include? attribute_value)
|
131
|
+
when '$elemMatch'
|
132
|
+
elem_match(condition_value, attribute_value)
|
133
|
+
when '$size'
|
134
|
+
return false unless attribute_value.is_a? Array
|
135
|
+
|
136
|
+
eval_condition_value(condition_value, attribute_value.length)
|
137
|
+
when '$all'
|
138
|
+
return false unless attribute_value.is_a? Array
|
139
|
+
|
140
|
+
condition_value.each do |condition|
|
141
|
+
passed = false
|
142
|
+
attribute_value.each do |attr|
|
143
|
+
passed = true if eval_condition_value(condition, attr)
|
144
|
+
end
|
145
|
+
return false unless passed
|
146
|
+
end
|
147
|
+
true
|
148
|
+
when '$exists'
|
149
|
+
exists = !attribute_value.nil?
|
150
|
+
if !condition_value
|
151
|
+
!exists
|
152
|
+
else
|
153
|
+
exists
|
154
|
+
end
|
155
|
+
when '$type'
|
156
|
+
condition_value == get_type(attribute_value)
|
157
|
+
when '$not'
|
158
|
+
!eval_condition_value(condition_value, attribute_value)
|
159
|
+
else
|
160
|
+
false
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Sets $VERBOSE for the duration of the block and back to its original
|
165
|
+
# value afterwards. Used for testing invalid regexes.
|
166
|
+
def self.silence_warnings
|
167
|
+
old_verbose = $VERBOSE
|
168
|
+
$VERBOSE = nil
|
169
|
+
yield
|
170
|
+
ensure
|
171
|
+
$VERBOSE = old_verbose
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
@@ -0,0 +1,225 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Growthbook
|
4
|
+
class Context
|
5
|
+
attr_accessor :enabled, :url, :qa_mode, :listener
|
6
|
+
attr_reader :attributes, :features, :impressions, :forced_variations, :forced_features
|
7
|
+
|
8
|
+
def initialize(options = {})
|
9
|
+
@features = {}
|
10
|
+
@forced_variations = {}
|
11
|
+
@forced_features = {}
|
12
|
+
@attributes = {}
|
13
|
+
@enabled = true
|
14
|
+
@impressions = {}
|
15
|
+
|
16
|
+
options.each do |key, value|
|
17
|
+
case key.to_sym
|
18
|
+
when :enabled
|
19
|
+
@enabled = value
|
20
|
+
when :attributes
|
21
|
+
self.attributes = value
|
22
|
+
when :url
|
23
|
+
@url = value
|
24
|
+
when :features
|
25
|
+
self.features = value
|
26
|
+
when :forced_variations, :forcedVariations
|
27
|
+
self.forced_variations = value
|
28
|
+
when :forced_features
|
29
|
+
self.forced_features = value
|
30
|
+
when :qa_mode, :qaMode
|
31
|
+
@qa_mode = value
|
32
|
+
when :listener
|
33
|
+
@listener = value
|
34
|
+
else
|
35
|
+
warn("Unknown context option: #{key}")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def features=(features)
|
41
|
+
@features = {}
|
42
|
+
|
43
|
+
features.each do |k, v|
|
44
|
+
# Convert to a Feature object if it's not already
|
45
|
+
v = Growthbook::Feature.new(v) unless v.is_a? Growthbook::Feature
|
46
|
+
|
47
|
+
@features[k.to_s] = v
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def attributes=(attrs)
|
52
|
+
@attributes = stringify_keys(attrs || {})
|
53
|
+
end
|
54
|
+
|
55
|
+
def forced_variations=(forced_variations)
|
56
|
+
@forced_variations = stringify_keys(forced_variations || {})
|
57
|
+
end
|
58
|
+
|
59
|
+
def forced_features=(forced_features)
|
60
|
+
@forced_features = stringify_keys(forced_features || {})
|
61
|
+
end
|
62
|
+
|
63
|
+
def eval_feature(key)
|
64
|
+
# Forced in the context
|
65
|
+
if @forced_features.key?(key.to_s)
|
66
|
+
return get_feature_result(@forced_features[key.to_s], 'override')
|
67
|
+
end
|
68
|
+
|
69
|
+
# Return if we can't find the feature definition
|
70
|
+
feature = get_feature(key)
|
71
|
+
return get_feature_result(nil, 'unknownFeature') unless feature
|
72
|
+
|
73
|
+
feature.rules.each do |rule|
|
74
|
+
# Targeting condition
|
75
|
+
next if rule.condition && !condition_passes(rule.condition)
|
76
|
+
|
77
|
+
# Rollout or forced value rule
|
78
|
+
if rule.is_force?
|
79
|
+
unless rule.coverage.nil?
|
80
|
+
hash_value = get_attribute(rule.hash_attribute || 'id')
|
81
|
+
next if hash_value.length.zero?
|
82
|
+
|
83
|
+
n = Growthbook::Util.hash(hash_value + key)
|
84
|
+
next if n > rule.coverage
|
85
|
+
end
|
86
|
+
return get_feature_result(rule.force, 'force')
|
87
|
+
end
|
88
|
+
# Experiment rule
|
89
|
+
next unless rule.is_experiment?
|
90
|
+
|
91
|
+
exp = rule.to_experiment(key)
|
92
|
+
result = run(exp)
|
93
|
+
|
94
|
+
next unless result.in_experiment
|
95
|
+
|
96
|
+
return get_feature_result(result.value, 'experiment', exp, result)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Fallback
|
100
|
+
get_feature_result(feature.default_value || nil, 'defaultValue')
|
101
|
+
end
|
102
|
+
|
103
|
+
def run(exp)
|
104
|
+
key = exp.key
|
105
|
+
|
106
|
+
# 1. If experiment doesn't have enough variations, return immediately
|
107
|
+
return get_experiment_result(exp) if exp.variations.length < 2
|
108
|
+
|
109
|
+
# 2. If context is disabled, return immediately
|
110
|
+
return get_experiment_result(exp) unless @enabled
|
111
|
+
|
112
|
+
# 3. If forced via URL querystring
|
113
|
+
if @url
|
114
|
+
qsOverride = Util.get_query_string_override(key, @url, exp.variations.length)
|
115
|
+
return get_experiment_result(exp, qsOverride) unless qsOverride.nil?
|
116
|
+
end
|
117
|
+
|
118
|
+
# 4. If variation is forced in the context, return the forced value
|
119
|
+
return get_experiment_result(exp, @forced_variations[key.to_s]) if @forced_variations.key?(key.to_s)
|
120
|
+
|
121
|
+
# 5. Exclude if not active
|
122
|
+
return get_experiment_result(exp) unless exp.active
|
123
|
+
|
124
|
+
# 6. Get hash_attribute/value and return if empty
|
125
|
+
hash_attribute = exp.hash_attribute || 'id'
|
126
|
+
hash_value = get_attribute(hash_attribute)
|
127
|
+
return get_experiment_result(exp) if hash_value.length.zero?
|
128
|
+
|
129
|
+
# 7. Exclude if user not in namespace
|
130
|
+
return get_experiment_result(exp) if exp.namespace && !Growthbook::Util.in_namespace(hash_value, exp.namespace)
|
131
|
+
|
132
|
+
# 8. Exclude if condition is false
|
133
|
+
return get_experiment_result(exp) if exp.condition && !condition_passes(exp.condition)
|
134
|
+
|
135
|
+
# 9. Calculate bucket ranges and choose one
|
136
|
+
ranges = Growthbook::Util.get_bucket_ranges(
|
137
|
+
exp.variations.length,
|
138
|
+
exp.coverage,
|
139
|
+
exp.weights
|
140
|
+
)
|
141
|
+
n = Growthbook::Util.hash(hash_value + key)
|
142
|
+
assigned = Growthbook::Util.choose_variation(n, ranges)
|
143
|
+
|
144
|
+
# 10. Return if not in experiment
|
145
|
+
return get_experiment_result(exp) if assigned.negative?
|
146
|
+
|
147
|
+
# 11. Experiment has a forced variation
|
148
|
+
return get_experiment_result(exp, exp.force) unless exp.force.nil?
|
149
|
+
|
150
|
+
# 12. Exclude if in QA mode
|
151
|
+
return get_experiment_result(exp) if @qa_mode
|
152
|
+
|
153
|
+
# 13. Build the result object
|
154
|
+
result = get_experiment_result(exp, assigned, true)
|
155
|
+
|
156
|
+
# 14. Fire tracking callback
|
157
|
+
track_experiment(exp, result)
|
158
|
+
|
159
|
+
# 15. Return the result
|
160
|
+
result
|
161
|
+
end
|
162
|
+
|
163
|
+
def on?(key)
|
164
|
+
eval_feature(key).on
|
165
|
+
end
|
166
|
+
|
167
|
+
def off?(key)
|
168
|
+
eval_feature(key).off
|
169
|
+
end
|
170
|
+
|
171
|
+
def feature_value(key, fallback = nil)
|
172
|
+
value = eval_feature(key).value
|
173
|
+
value.nil? ? fallback : value
|
174
|
+
end
|
175
|
+
|
176
|
+
private
|
177
|
+
|
178
|
+
def stringify_keys(hash)
|
179
|
+
new_hash = {}
|
180
|
+
hash.each do |key, value|
|
181
|
+
new_hash[key.to_s] = value
|
182
|
+
end
|
183
|
+
new_hash
|
184
|
+
end
|
185
|
+
|
186
|
+
def condition_passes(condition)
|
187
|
+
Growthbook::Conditions.eval_condition(@attributes, condition)
|
188
|
+
end
|
189
|
+
|
190
|
+
def get_experiment_result(experiment, variation_index = 0, in_experiment = false)
|
191
|
+
variation_index = 0 if variation_index.negative? || variation_index >= experiment.variations.length
|
192
|
+
|
193
|
+
hash_attribute = experiment.hash_attribute || 'id'
|
194
|
+
hash_value = get_attribute(hash_attribute)
|
195
|
+
|
196
|
+
Growthbook::InlineExperimentResult.new(in_experiment, variation_index,
|
197
|
+
experiment.variations[variation_index], hash_attribute, hash_value)
|
198
|
+
end
|
199
|
+
|
200
|
+
def get_feature_result(value, source, experiment = nil, experiment_result = nil)
|
201
|
+
Growthbook::FeatureResult.new(value, source, experiment, experiment_result)
|
202
|
+
end
|
203
|
+
|
204
|
+
def get_feature(key)
|
205
|
+
return @features[key.to_sym] if @features.key?(key.to_sym)
|
206
|
+
return @features[key.to_s] if @features.key?(key.to_s)
|
207
|
+
|
208
|
+
nil
|
209
|
+
end
|
210
|
+
|
211
|
+
def get_attribute(key)
|
212
|
+
return @attributes[key.to_sym] if @attributes.key?(key.to_sym)
|
213
|
+
return @attributes[key.to_s] if @attributes.key?(key.to_s)
|
214
|
+
|
215
|
+
''
|
216
|
+
end
|
217
|
+
|
218
|
+
def track_experiment(experiment, result)
|
219
|
+
if @listener && @listener.respond_to?(:on_experiment_viewed)
|
220
|
+
@listener.on_experiment_viewed(experiment, result)
|
221
|
+
end
|
222
|
+
@impressions[experiment.key] = result
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Growthbook
|
4
|
+
class Feature
|
5
|
+
# @return [Any , nil]
|
6
|
+
attr_reader :default_value
|
7
|
+
|
8
|
+
# @return [Array<Growthbook::FeatureRule>]
|
9
|
+
attr_reader :rules
|
10
|
+
|
11
|
+
def initialize(feature)
|
12
|
+
@default_value = getOption(feature, :defaultValue)
|
13
|
+
|
14
|
+
rules = getOption(feature, :rules)
|
15
|
+
|
16
|
+
@rules = []
|
17
|
+
rules&.each do |rule|
|
18
|
+
@rules << Growthbook::FeatureRule.new(rule)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_json(*_args)
|
23
|
+
res = {}
|
24
|
+
res['defaultValue'] = @default_value unless @default_value.nil?
|
25
|
+
res['rules'] = []
|
26
|
+
@rules.each do |rule|
|
27
|
+
res['rules'] << rule.to_json
|
28
|
+
end
|
29
|
+
res
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def getOption(hash, key)
|
35
|
+
return hash[key.to_sym] if hash.key?(key.to_sym)
|
36
|
+
return hash[key.to_s] if hash.key?(key.to_s)
|
37
|
+
|
38
|
+
nil
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Growthbook
|
4
|
+
class FeatureResult
|
5
|
+
# The assigned value of the feature
|
6
|
+
# @return [Any, nil]
|
7
|
+
attr_reader :value
|
8
|
+
|
9
|
+
# Whether or not the feature is ON
|
10
|
+
# @return [Bool]
|
11
|
+
attr_reader :on
|
12
|
+
|
13
|
+
# Whether or not the feature is OFF
|
14
|
+
# @return [Bool]
|
15
|
+
attr_reader :off
|
16
|
+
|
17
|
+
# The reason the feature was assigned this value
|
18
|
+
# @return [String]
|
19
|
+
attr_reader :source
|
20
|
+
|
21
|
+
# The experiment used to decide the feature value
|
22
|
+
# @return [Growthbook.Experiment, nil]
|
23
|
+
attr_reader :experiment
|
24
|
+
|
25
|
+
# The result of the experiment
|
26
|
+
# @return [Growthbook.ExperimentResult, nil]
|
27
|
+
attr_reader :experiment_result
|
28
|
+
|
29
|
+
def initialize(
|
30
|
+
value,
|
31
|
+
source,
|
32
|
+
experiment,
|
33
|
+
experiment_result
|
34
|
+
)
|
35
|
+
|
36
|
+
on = !value.nil? && value != 0 && value != '' && value != false
|
37
|
+
|
38
|
+
@value = value
|
39
|
+
@on = on
|
40
|
+
@off = !on
|
41
|
+
@source = source
|
42
|
+
@experiment = experiment
|
43
|
+
@experiment_result = experiment_result
|
44
|
+
end
|
45
|
+
|
46
|
+
def to_json(*_args)
|
47
|
+
json = {}
|
48
|
+
json['on'] = @on
|
49
|
+
json['off'] = @off
|
50
|
+
json['value'] = @value
|
51
|
+
json['source'] = @source
|
52
|
+
|
53
|
+
if @experiment
|
54
|
+
json['experiment'] = @experiment.to_json
|
55
|
+
json['experimentResult'] = @experiment_result.to_json
|
56
|
+
end
|
57
|
+
|
58
|
+
json
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Growthbook
|
4
|
+
class FeatureRule
|
5
|
+
# @return [Hash , nil]
|
6
|
+
attr_reader :condition
|
7
|
+
# @return [Float , nil]
|
8
|
+
attr_reader :coverage
|
9
|
+
# @return [T , nil]
|
10
|
+
attr_reader :force
|
11
|
+
# @return [T[] , nil]
|
12
|
+
attr_reader :variations
|
13
|
+
# @return [String , nil]
|
14
|
+
attr_reader :key
|
15
|
+
# @return [Float[] , nil]
|
16
|
+
attr_reader :weights
|
17
|
+
# @return [Array , nil]
|
18
|
+
attr_reader :namespace
|
19
|
+
# @return [String , nil]
|
20
|
+
attr_reader :hash_attribute
|
21
|
+
|
22
|
+
def initialize(rule)
|
23
|
+
@coverage = getOption(rule, :coverage)
|
24
|
+
@force = getOption(rule, :force)
|
25
|
+
@variations = getOption(rule, :variations)
|
26
|
+
@key = getOption(rule, :key)
|
27
|
+
@weights = getOption(rule, :weights)
|
28
|
+
@namespace = getOption(rule, :namespace)
|
29
|
+
@hash_attribute = getOption(rule, :hash_attribute) || getOption(rule, :hashAttribute)
|
30
|
+
|
31
|
+
cond = getOption(rule, :condition)
|
32
|
+
@condition = Growthbook::Conditions.parse_condition(cond) unless cond.nil?
|
33
|
+
end
|
34
|
+
|
35
|
+
def to_experiment(feature_key)
|
36
|
+
return nil unless @variations
|
37
|
+
|
38
|
+
Growthbook::InlineExperiment.new(
|
39
|
+
key: @key || feature_key,
|
40
|
+
variations: @variations,
|
41
|
+
coverage: @coverage,
|
42
|
+
weights: @weights,
|
43
|
+
hash_attribute: @hash_attribute,
|
44
|
+
namespace: @namespace
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
def is_experiment?
|
49
|
+
!!@variations
|
50
|
+
end
|
51
|
+
|
52
|
+
def is_force?
|
53
|
+
!is_experiment? && !@force.nil?
|
54
|
+
end
|
55
|
+
|
56
|
+
def to_json(*_args)
|
57
|
+
res = {}
|
58
|
+
res['condition'] = @condition unless @condition.nil?
|
59
|
+
res['coverage'] = @coverage unless @coverage.nil?
|
60
|
+
res['force'] = @force unless @force.nil?
|
61
|
+
res['variations'] = @variations unless @variations.nil?
|
62
|
+
res['key'] = @key unless @key.nil?
|
63
|
+
res['weights'] = @weights unless @weights.nil?
|
64
|
+
res['namespace'] = @namespace unless @namespace.nil?
|
65
|
+
res['hashAttribute'] = @hash_attribute unless @hash_attribute.nil?
|
66
|
+
res
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def getOption(hash, key)
|
72
|
+
return hash[key.to_sym] if hash.key?(key.to_sym)
|
73
|
+
return hash[key.to_s] if hash.key?(key.to_s)
|
74
|
+
|
75
|
+
nil
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Growthbook
|
4
|
+
class InlineExperiment
|
5
|
+
# @returns [String]
|
6
|
+
attr_accessor :key
|
7
|
+
|
8
|
+
# @returns [Any]
|
9
|
+
attr_accessor :variations
|
10
|
+
|
11
|
+
# @returns [Bool]
|
12
|
+
attr_accessor :active
|
13
|
+
|
14
|
+
# @returns [Integer, nil]
|
15
|
+
attr_accessor :force
|
16
|
+
|
17
|
+
# @returns [Array<Float>, nil]
|
18
|
+
attr_accessor :weights
|
19
|
+
|
20
|
+
# @returns [Float]
|
21
|
+
attr_accessor :coverage
|
22
|
+
|
23
|
+
# @returns [Hash, nil]
|
24
|
+
attr_accessor :condition
|
25
|
+
|
26
|
+
# @returns [Array]
|
27
|
+
attr_accessor :namespace
|
28
|
+
|
29
|
+
# @returns [String]
|
30
|
+
attr_accessor :hash_attribute
|
31
|
+
|
32
|
+
# Constructor for an Experiment
|
33
|
+
#
|
34
|
+
# @param options [Hash]
|
35
|
+
# @option options [Array<Any>] :variations The variations to pick between
|
36
|
+
# @option options [String] :key The unique identifier for this experiment
|
37
|
+
# @option options [Float] :coverage (1.0) The percent of elegible traffic to include in the experiment
|
38
|
+
# @option options [Array<Float>] :weights The relative weights of the variations.
|
39
|
+
# Length must be the same as the number of variations. Total should add to 1.0.
|
40
|
+
# Default is an even split between variations
|
41
|
+
# @option options [Boolean] :anon (false) If false, the experiment uses the logged-in user id for bucketing
|
42
|
+
# If true, the experiment uses the anonymous id for bucketing
|
43
|
+
# @option options [Array<String>] :targeting Array of targeting rules in the format "key op value"
|
44
|
+
# where op is one of: =, !=, <, >, ~, !~
|
45
|
+
# @option options [Integer, nil] :force If an integer, force all users to get this variation
|
46
|
+
# @option options [Hash] :data Data to attach to the variations
|
47
|
+
def initialize(options = {})
|
48
|
+
@key = getOption(options, :key, '').to_s
|
49
|
+
@variations = getOption(options, :variations, [])
|
50
|
+
@active = getOption(options, :active, true)
|
51
|
+
@force = getOption(options, :force)
|
52
|
+
@weights = getOption(options, :weights)
|
53
|
+
@coverage = getOption(options, :coverage, 1)
|
54
|
+
@condition = getOption(options, :condition)
|
55
|
+
@namespace = getOption(options, :namespace)
|
56
|
+
@hash_attribute = getOption(options, :hash_attribute) || getOption(options, :hashAttribute) || 'id'
|
57
|
+
end
|
58
|
+
|
59
|
+
def getOption(hash, key, default = nil)
|
60
|
+
return hash[key.to_sym] if hash.key?(key.to_sym)
|
61
|
+
return hash[key.to_s] if hash.key?(key.to_s)
|
62
|
+
|
63
|
+
default
|
64
|
+
end
|
65
|
+
|
66
|
+
def to_json(*_args)
|
67
|
+
res = {}
|
68
|
+
res['key'] = @key
|
69
|
+
res['variations'] = @variations
|
70
|
+
res['active'] = @active if @active != true && !@active.nil?
|
71
|
+
res['force'] = @force unless @force.nil?
|
72
|
+
res['weights'] = @weights unless @weights.nil?
|
73
|
+
res['coverage'] = @coverage if @coverage != 1 && !@coverage.nil?
|
74
|
+
res['condition'] = @condition unless @condition.nil?
|
75
|
+
res['namespace'] = @namespace unless @namespace.nil?
|
76
|
+
res['hashAttribute'] = @hash_attribute if @hash_attribute != 'id' && !@hash_attribute.nil?
|
77
|
+
res
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|