growthbook 0.0.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91514b6437d92dc4d5ff6464f6ae4088dd2f57974fe60ac24d001eeca58ca498
4
- data.tar.gz: 5ecb57a051072fd053838e8dfb0733bf879a44eddb68687583f1c7245496aae6
3
+ metadata.gz: 9f6c9a059b34fad58095b5e0ea6b3710d7b427617a4cd4f9c7ddc8fdebd2a1da
4
+ data.tar.gz: c93b9e289a8c950dc3cfa5974062634e7c11f6123e7fd5e94f6ff8c88d262566
5
5
  SHA512:
6
- metadata.gz: 77a2244bec748a0d55666716b0e532430fc84c3f0d268bf44a54e60fd19ea92329af4cb2cdd19e9af90606845ae8d6a7db6d7ca066d1e1ee2af8a2b126593280
7
- data.tar.gz: af03f9ed8278aaf28705b3eed68e81e9fe0f58e482e476ecfc8bc00c073663db68dcd7f70e2902be56a2debf2eb0e465ac29c6730d2b6377f71c862ee924b8b7
6
+ metadata.gz: 0f1c46dac7d0e9dccc8641f67d7dbbe8d2af91ec18a4cf0fdcb13701042e0fad55bbcf35b91c330506816fa59cbb0129a8ae5a7f3fb103b308735268f3638937
7
+ data.tar.gz: c38fe86529984df54ee57560b01591c99a20b616f02d8296e9e81a7c1fc56d6755096c675e96e94a52c57c5e9e77435b497aed2d4a3ebb4c0cdcbd78f7d8085b
@@ -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,233 @@
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').to_s
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, key)
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
+ _run(exp)
105
+ end
106
+
107
+ def on?(key)
108
+ eval_feature(key).on
109
+ end
110
+
111
+ def off?(key)
112
+ eval_feature(key).off
113
+ end
114
+
115
+ def feature_value(key, fallback = nil)
116
+ value = eval_feature(key).value
117
+ value.nil? ? fallback : value
118
+ end
119
+
120
+ private
121
+
122
+ def _run(exp, feature_id="")
123
+ key = exp.key
124
+
125
+ # 1. If experiment doesn't have enough variations, return immediately
126
+ return get_experiment_result(exp, -1, false, feature_id) if exp.variations.length < 2
127
+
128
+ # 2. If context is disabled, return immediately
129
+ return get_experiment_result(exp, -1, false, feature_id) unless @enabled
130
+
131
+ # 3. If forced via URL querystring
132
+ if @url
133
+ qsOverride = Util.get_query_string_override(key, @url, exp.variations.length)
134
+ return get_experiment_result(exp, qsOverride, false, feature_id) unless qsOverride.nil?
135
+ end
136
+
137
+ # 4. If variation is forced in the context, return the forced value
138
+ return get_experiment_result(exp, @forced_variations[key.to_s], false, feature_id) if @forced_variations.key?(key.to_s)
139
+
140
+ # 5. Exclude if not active
141
+ return get_experiment_result(exp, -1, false, feature_id) unless exp.active
142
+
143
+ # 6. Get hash_attribute/value and return if empty
144
+ hash_attribute = exp.hash_attribute || 'id'
145
+ hash_value = get_attribute(hash_attribute).to_s
146
+ return get_experiment_result(exp, -1, false, feature_id) if hash_value.length.zero?
147
+
148
+ # 7. Exclude if user not in namespace
149
+ return get_experiment_result(exp, -1, false, feature_id) if exp.namespace && !Growthbook::Util.in_namespace(hash_value, exp.namespace)
150
+
151
+ # 8. Exclude if condition is false
152
+ return get_experiment_result(exp, -1, false, feature_id) if exp.condition && !condition_passes(exp.condition)
153
+
154
+ # 9. Calculate bucket ranges and choose one
155
+ ranges = Growthbook::Util.get_bucket_ranges(
156
+ exp.variations.length,
157
+ exp.coverage,
158
+ exp.weights
159
+ )
160
+ n = Growthbook::Util.hash(hash_value + key)
161
+ assigned = Growthbook::Util.choose_variation(n, ranges)
162
+
163
+ # 10. Return if not in experiment
164
+ return get_experiment_result(exp, -1, false, feature_id) if assigned.negative?
165
+
166
+ # 11. Experiment has a forced variation
167
+ return get_experiment_result(exp, exp.force, false, feature_id) unless exp.force.nil?
168
+
169
+ # 12. Exclude if in QA mode
170
+ return get_experiment_result(exp, -1, false, feature_id) if @qa_mode
171
+
172
+ # 13. Build the result object
173
+ result = get_experiment_result(exp, assigned, true, feature_id)
174
+
175
+ # 14. Fire tracking callback
176
+ track_experiment(exp, result)
177
+
178
+ # 15. Return the result
179
+ result
180
+ end
181
+
182
+ def stringify_keys(hash)
183
+ new_hash = {}
184
+ hash.each do |key, value|
185
+ new_hash[key.to_s] = value
186
+ end
187
+ new_hash
188
+ end
189
+
190
+ def condition_passes(condition)
191
+ Growthbook::Conditions.eval_condition(@attributes, condition)
192
+ end
193
+
194
+ def get_experiment_result(experiment, variation_index = -1, hash_used = false, feature_id = "")
195
+ in_experiment = true
196
+ if variation_index.negative? || variation_index >= experiment.variations.length
197
+ variation_index = 0
198
+ in_experiment = false
199
+ end
200
+
201
+ hash_attribute = experiment.hash_attribute || 'id'
202
+ hash_value = get_attribute(hash_attribute)
203
+
204
+ Growthbook::InlineExperimentResult.new(hash_used, in_experiment, variation_index,
205
+ experiment.variations[variation_index], hash_attribute, hash_value, feature_id)
206
+ end
207
+
208
+ def get_feature_result(value, source, experiment = nil, experiment_result = nil)
209
+ Growthbook::FeatureResult.new(value, source, experiment, experiment_result)
210
+ end
211
+
212
+ def get_feature(key)
213
+ return @features[key.to_sym] if @features.key?(key.to_sym)
214
+ return @features[key.to_s] if @features.key?(key.to_s)
215
+
216
+ nil
217
+ end
218
+
219
+ def get_attribute(key)
220
+ return @attributes[key.to_sym] if @attributes.key?(key.to_sym)
221
+ return @attributes[key.to_s] if @attributes.key?(key.to_s)
222
+
223
+ ''
224
+ end
225
+
226
+ def track_experiment(experiment, result)
227
+ if @listener && @listener.respond_to?(:on_experiment_viewed)
228
+ @listener.on_experiment_viewed(experiment, result)
229
+ end
230
+ @impressions[experiment.key] = result
231
+ end
232
+ end
233
+ 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