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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91514b6437d92dc4d5ff6464f6ae4088dd2f57974fe60ac24d001eeca58ca498
4
- data.tar.gz: 5ecb57a051072fd053838e8dfb0733bf879a44eddb68687583f1c7245496aae6
3
+ metadata.gz: 8a5a979e06c6d8d34b1b8caede098ba6fae05c0e8f6fe82b9b70da2fbd906959
4
+ data.tar.gz: 55f4d754a3584f5602c5d45f3f3e285cbb18d35a0ed0cdfcaf103b4dd269e81d
5
5
  SHA512:
6
- metadata.gz: 77a2244bec748a0d55666716b0e532430fc84c3f0d268bf44a54e60fd19ea92329af4cb2cdd19e9af90606845ae8d6a7db6d7ca066d1e1ee2af8a2b126593280
7
- data.tar.gz: af03f9ed8278aaf28705b3eed68e81e9fe0f58e482e476ecfc8bc00c073663db68dcd7f70e2902be56a2debf2eb0e465ac29c6730d2b6377f71c862ee924b8b7
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