growthbook 0.3.0 → 1.0.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: 9f6c9a059b34fad58095b5e0ea6b3710d7b427617a4cd4f9c7ddc8fdebd2a1da
4
- data.tar.gz: c93b9e289a8c950dc3cfa5974062634e7c11f6123e7fd5e94f6ff8c88d262566
3
+ metadata.gz: 95b90dcba429217cc585f4ea2007e3d7aab0f3489dc93ee41d7fa72b847b7f53
4
+ data.tar.gz: 363c48f172798a02d899cfdc6ffb692ab1f85b45cf205e0d4c5dc6339c86c83a
5
5
  SHA512:
6
- metadata.gz: 0f1c46dac7d0e9dccc8641f67d7dbbe8d2af91ec18a4cf0fdcb13701042e0fad55bbcf35b91c330506816fa59cbb0129a8ae5a7f3fb103b308735268f3638937
7
- data.tar.gz: c38fe86529984df54ee57560b01591c99a20b616f02d8296e9e81a7c1fc56d6755096c675e96e94a52c57c5e9e77435b497aed2d4a3ebb4c0cdcbd78f7d8085b
6
+ metadata.gz: 6b8c950602e92cb66a644e6780827410d3c2e5eb71078adb95b469fbbe1c9b7f9925a6d3cf5917c345b9a5d04feabf21198d0349a57d5c597e7dca92f004bf43
7
+ data.tar.gz: ded0ea4cc0913bf8d93f8f61a34b622579d7346dfe02f121d011e82b11920e7703c192e8afb6331c2ac0d5245a435473e54707907e6116e46d4b4b01a5a3c394
@@ -3,6 +3,8 @@
3
3
  require 'json'
4
4
 
5
5
  module Growthbook
6
+ # internal use only
7
+ # Utils for condition evaluation
6
8
  class Conditions
7
9
  # Evaluate a targeting conditions hash against an attributes hash
8
10
  # Both attributes and conditions only have string keys (no symbols)
@@ -25,7 +27,7 @@ module Growthbook
25
27
  when Array
26
28
  return condition.map { |v| parse_condition(v) }
27
29
  when Hash
28
- return condition.map { |k, v| [k.to_s, parse_condition(v)] }.to_h
30
+ return condition.to_h { |k, v| [k.to_s, parse_condition(v)] }
29
31
  end
30
32
 
31
33
  condition
@@ -47,7 +49,7 @@ module Growthbook
47
49
  true
48
50
  end
49
51
 
50
- def self.is_operator_object(obj)
52
+ def self.operator_object?(obj)
51
53
  obj.each do |key, _value|
52
54
  return false if key[0] != '$'
53
55
  end
@@ -58,7 +60,7 @@ module Growthbook
58
60
  return 'string' if attribute_value.is_a? String
59
61
  return 'number' if attribute_value.is_a? Integer
60
62
  return 'number' if attribute_value.is_a? Float
61
- return 'boolean' if attribute_value == true || attribute_value == false
63
+ return 'boolean' if [true, false].include?(attribute_value)
62
64
  return 'array' if attribute_value.is_a? Array
63
65
  return 'null' if attribute_value.nil?
64
66
 
@@ -70,18 +72,16 @@ module Growthbook
70
72
  current = attributes
71
73
 
72
74
  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
75
+ return nil unless current.is_a?(Hash) && current&.key?(value)
76
+
77
+ current = current[value]
78
78
  end
79
79
 
80
80
  current
81
81
  end
82
82
 
83
83
  def self.eval_condition_value(condition_value, attribute_value)
84
- if condition_value.is_a?(Hash) && is_operator_object(condition_value)
84
+ if condition_value.is_a?(Hash) && operator_object?(condition_value)
85
85
  condition_value.each do |key, value|
86
86
  return false unless eval_operator_condition(key, attribute_value, value)
87
87
  end
@@ -94,7 +94,7 @@ module Growthbook
94
94
  return false unless attribute_value.is_a? Array
95
95
 
96
96
  attribute_value.each do |item|
97
- if is_operator_object(condition)
97
+ if operator_object?(condition)
98
98
  return true if eval_condition_value(condition, item)
99
99
  elsif eval_condition(item, condition)
100
100
  return true
@@ -147,10 +147,10 @@ module Growthbook
147
147
  true
148
148
  when '$exists'
149
149
  exists = !attribute_value.nil?
150
- if !condition_value
151
- !exists
152
- else
150
+ if condition_value
153
151
  exists
152
+ else
153
+ !exists
154
154
  end
155
155
  when '$type'
156
156
  condition_value == get_type(attribute_value)
@@ -1,9 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Growthbook
4
+ # Context object passed into the GrowthBook constructor.
4
5
  class Context
5
- attr_accessor :enabled, :url, :qa_mode, :listener
6
- attr_reader :attributes, :features, :impressions, :forced_variations, :forced_features
6
+ # @return [true, false] Switch to globally disable all experiments. Default true.
7
+ attr_accessor :enabled
8
+
9
+ # @return [String] The URL of the current page
10
+ attr_accessor :url
11
+
12
+ # @return [true, false, nil] If true, random assignment is disabled and only explicitly forced variations are used.
13
+ attr_accessor :qa_mode
14
+
15
+ # @return [Listener] An object that responds to some tracking methods that take experiment and result as arguments.
16
+ attr_accessor :listener
17
+
18
+ # @return [Hash] Map of user attributes that are used to assign variations
19
+ attr_reader :attributes
20
+
21
+ # @return [Hash] Feature definitions (usually pulled from an API or cache)
22
+ attr_reader :features
23
+
24
+ # @return [Hash] Force specific experiments to always assign a specific variation (used for QA)
25
+ attr_reader :forced_variations
26
+
27
+ attr_reader :impressions, :forced_features
7
28
 
8
29
  def initialize(options = {})
9
30
  @features = {}
@@ -62,9 +83,7 @@ module Growthbook
62
83
 
63
84
  def eval_feature(key)
64
85
  # 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
86
+ return get_feature_result(@forced_features[key.to_s], 'override') if @forced_features.key?(key.to_s)
68
87
 
69
88
  # Return if we can't find the feature definition
70
89
  feature = get_feature(key)
@@ -74,24 +93,28 @@ module Growthbook
74
93
  # Targeting condition
75
94
  next if rule.condition && !condition_passes(rule.condition)
76
95
 
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?
96
+ # If there are filters for who is included (e.g. namespaces)
97
+ next if rule.filters && filtered_out?(rule.filters)
98
+
99
+ # If this is a percentage rollout, skip if not included
100
+ if rule.force?
101
+ seed = rule.seed || key
102
+ hash_attribute = rule.hash_attribute || 'id'
103
+ included_in_rollout = included_in_rollout?(
104
+ seed: seed, hash_attribute: hash_attribute, range: rule.range,
105
+ coverage: rule.coverage, hash_version: rule.hash_version
106
+ )
107
+ next unless included_in_rollout
82
108
 
83
- n = Growthbook::Util.hash(hash_value + key)
84
- next if n > rule.coverage
85
- end
86
109
  return get_feature_result(rule.force, 'force')
87
110
  end
88
111
  # Experiment rule
89
- next unless rule.is_experiment?
112
+ next unless rule.experiment?
90
113
 
91
114
  exp = rule.to_experiment(key)
92
115
  result = _run(exp, key)
93
116
 
94
- next unless result.in_experiment
117
+ next unless result.in_experiment && !result.passthrough
95
118
 
96
119
  return get_feature_result(result.value, 'experiment', exp, result)
97
120
  end
@@ -119,58 +142,76 @@ module Growthbook
119
142
 
120
143
  private
121
144
 
122
- def _run(exp, feature_id="")
145
+ def _run(exp, feature_id = '')
123
146
  key = exp.key
124
147
 
125
148
  # 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
149
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if exp.variations.length < 2
127
150
 
128
151
  # 2. If context is disabled, return immediately
129
- return get_experiment_result(exp, -1, false, feature_id) unless @enabled
152
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) unless @enabled
130
153
 
131
154
  # 3. If forced via URL querystring
132
155
  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?
156
+ qs_override = Util.get_query_string_override(key, @url, exp.variations.length)
157
+ return get_experiment_result(exp, qs_override, hash_used: false, feature_id: feature_id) unless qs_override.nil?
135
158
  end
136
159
 
137
160
  # 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)
161
+ if @forced_variations.key?(key.to_s)
162
+ return get_experiment_result(
163
+ exp,
164
+ @forced_variations[key.to_s],
165
+ hash_used: false,
166
+ feature_id: feature_id
167
+ )
168
+ end
139
169
 
140
170
  # 5. Exclude if not active
141
- return get_experiment_result(exp, -1, false, feature_id) unless exp.active
171
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) unless exp.active
142
172
 
143
173
  # 6. Get hash_attribute/value and return if empty
144
174
  hash_attribute = exp.hash_attribute || 'id'
145
175
  hash_value = get_attribute(hash_attribute).to_s
146
- return get_experiment_result(exp, -1, false, feature_id) if hash_value.length.zero?
176
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if hash_value.empty?
147
177
 
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)
178
+ # 7. Exclude if user is filtered out (used to be called "namespace")
179
+ if exp.filters
180
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if filtered_out?(exp.filters)
181
+ elsif exp.namespace && !Growthbook::Util.in_namespace(hash_value, exp.namespace)
182
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id)
183
+ end
150
184
 
151
185
  # 8. Exclude if condition is false
152
- return get_experiment_result(exp, -1, false, feature_id) if exp.condition && !condition_passes(exp.condition)
186
+ if exp.condition && !condition_passes(exp.condition)
187
+ return get_experiment_result(
188
+ exp,
189
+ -1,
190
+ hash_used: false,
191
+ feature_id: feature_id
192
+ )
193
+ end
153
194
 
154
- # 9. Calculate bucket ranges and choose one
155
- ranges = Growthbook::Util.get_bucket_ranges(
195
+ # 9. Get bucket ranges and choose variation
196
+ ranges = exp.ranges || Growthbook::Util.get_bucket_ranges(
156
197
  exp.variations.length,
157
198
  exp.coverage,
158
199
  exp.weights
159
200
  )
160
- n = Growthbook::Util.hash(hash_value + key)
201
+ n = Growthbook::Util.hash(seed: exp.seed || key, value: hash_value, version: exp.hash_version || 1)
161
202
  assigned = Growthbook::Util.choose_variation(n, ranges)
162
203
 
163
204
  # 10. Return if not in experiment
164
- return get_experiment_result(exp, -1, false, feature_id) if assigned.negative?
205
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if assigned.negative?
165
206
 
166
207
  # 11. Experiment has a forced variation
167
- return get_experiment_result(exp, exp.force, false, feature_id) unless exp.force.nil?
208
+ return get_experiment_result(exp, exp.force, hash_used: false, feature_id: feature_id) unless exp.force.nil?
168
209
 
169
210
  # 12. Exclude if in QA mode
170
- return get_experiment_result(exp, -1, false, feature_id) if @qa_mode
211
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if @qa_mode
171
212
 
172
213
  # 13. Build the result object
173
- result = get_experiment_result(exp, assigned, true, feature_id)
214
+ result = get_experiment_result(exp, assigned, hash_used: true, feature_id: feature_id, bucket: n)
174
215
 
175
216
  # 14. Fire tracking callback
176
217
  track_experiment(exp, result)
@@ -191,7 +232,7 @@ module Growthbook
191
232
  Growthbook::Conditions.eval_condition(@attributes, condition)
192
233
  end
193
234
 
194
- def get_experiment_result(experiment, variation_index = -1, hash_used = false, feature_id = "")
235
+ def get_experiment_result(experiment, variation_index = -1, hash_used: false, feature_id: '', bucket: nil)
195
236
  in_experiment = true
196
237
  if variation_index.negative? || variation_index >= experiment.variations.length
197
238
  variation_index = 0
@@ -200,9 +241,17 @@ module Growthbook
200
241
 
201
242
  hash_attribute = experiment.hash_attribute || 'id'
202
243
  hash_value = get_attribute(hash_attribute)
244
+ meta = experiment.meta ? experiment.meta[variation_index] : {}
203
245
 
204
- Growthbook::InlineExperimentResult.new(hash_used, in_experiment, variation_index,
205
- experiment.variations[variation_index], hash_attribute, hash_value, feature_id)
246
+ result = Growthbook::InlineExperimentResult.new(
247
+ { key: meta['key'] || variation_index, in_experiment: in_experiment, variation_id: variation_index,
248
+ value: experiment.variations[variation_index], hash_used: hash_used, hash_attribute: hash_attribute,
249
+ hash_value: hash_value, feature_id: feature_id, bucket: bucket, name: meta['name'] }
250
+ )
251
+
252
+ result.passthrough = true if meta['passthrough']
253
+
254
+ result
206
255
  end
207
256
 
208
257
  def get_feature_result(value, source, experiment = nil, experiment_result = nil)
@@ -224,10 +273,37 @@ module Growthbook
224
273
  end
225
274
 
226
275
  def track_experiment(experiment, result)
227
- if @listener && @listener.respond_to?(:on_experiment_viewed)
228
- @listener.on_experiment_viewed(experiment, result)
229
- end
276
+ @listener.on_experiment_viewed(experiment, result) if @listener.respond_to?(:on_experiment_viewed)
230
277
  @impressions[experiment.key] = result
231
278
  end
279
+
280
+ def included_in_rollout?(seed:, hash_attribute:, hash_version:, range:, coverage:)
281
+ return true if range.nil? && coverage.nil?
282
+
283
+ hash_value = get_attribute(hash_attribute)
284
+
285
+ return false if hash_value.empty?
286
+
287
+ n = Growthbook::Util.hash(seed: seed, value: hash_value, version: hash_version || 1)
288
+
289
+ return Growthbook::Util.in_range?(n, range) if range
290
+ return n <= coverage if coverage
291
+
292
+ true
293
+ end
294
+
295
+ def filtered_out?(filters)
296
+ filters.any? do |filter|
297
+ hash_value = get_attribute(filter['attribute'] || 'id')
298
+
299
+ if hash_value.empty?
300
+ false
301
+ else
302
+ n = Growthbook::Util.hash(seed: filter['seed'], value: hash_value, version: filter['hashVersion'] || 2)
303
+
304
+ filter['ranges'].none? { |range| Growthbook::Util.in_range?(n, range) }
305
+ end
306
+ end
307
+ end
232
308
  end
233
309
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Growthbook
4
+ # The feature with a generic value type.
4
5
  class Feature
5
6
  # @return [Any , nil]
6
7
  attr_reader :default_value
@@ -9,9 +10,9 @@ module Growthbook
9
10
  attr_reader :rules
10
11
 
11
12
  def initialize(feature)
12
- @default_value = getOption(feature, :defaultValue)
13
+ @default_value = get_option(feature, :defaultValue)
13
14
 
14
- rules = getOption(feature, :rules)
15
+ rules = get_option(feature, :rules)
15
16
 
16
17
  @rules = []
17
18
  rules&.each do |rule|
@@ -31,7 +32,7 @@ module Growthbook
31
32
 
32
33
  private
33
34
 
34
- def getOption(hash, key)
35
+ def get_option(hash, key)
35
36
  return hash[key.to_sym] if hash.key?(key.to_sym)
36
37
  return hash[key.to_s] if hash.key?(key.to_s)
37
38
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Growthbook
4
+ # Result of a feature evaluation
4
5
  class FeatureResult
5
6
  # The assigned value of the feature
6
7
  # @return [Any, nil]
@@ -19,11 +20,11 @@ module Growthbook
19
20
  attr_reader :source
20
21
 
21
22
  # The experiment used to decide the feature value
22
- # @return [Growthbook.Experiment, nil]
23
+ # @return [Growthbook::InlineExperiment, nil]
23
24
  attr_reader :experiment
24
25
 
25
26
  # The result of the experiment
26
- # @return [Growthbook.ExperimentResult, nil]
27
+ # @return [Growthbook::InlineExperimentResult, nil]
27
28
  attr_reader :experiment_result
28
29
 
29
30
  def initialize(
@@ -1,37 +1,82 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Growthbook
4
+ # Internal class that overrides the default value of a Feature based on a set of requirements.
4
5
  class FeatureRule
5
- # @return [Hash , nil]
6
+ # @return [Hash , nil] Optional targeting condition
6
7
  attr_reader :condition
7
- # @return [Float , nil]
8
+
9
+ # @return [Float , nil] What percent of users should be included in the experiment (between 0 and 1, inclusive)
8
10
  attr_reader :coverage
9
- # @return [T , nil]
11
+
12
+ # @return [T , nil] Immediately force a specific value (ignore every other option besides condition and coverage)
10
13
  attr_reader :force
11
- # @return [T[] , nil]
14
+
15
+ # @return [T[] , nil] Run an experiment (A/B test) and randomly choose between these variations
12
16
  attr_reader :variations
13
- # @return [String , nil]
17
+
18
+ # @return [String , nil] The globally unique tracking key for the experiment (default to the feature key)
14
19
  attr_reader :key
15
- # @return [Float[] , nil]
20
+
21
+ # @return [Float[] , nil] How to weight traffic between variations. Must add to 1.
16
22
  attr_reader :weights
17
- # @return [Array , nil]
23
+
24
+ # @return [String , nil] Adds the experiment to a namespace
18
25
  attr_reader :namespace
19
- # @return [String , nil]
26
+
27
+ # @return [String , nil] What user attribute should be used to assign variations (defaults to id)
20
28
  attr_reader :hash_attribute
21
29
 
30
+ # @return [Integer , nil] The hash version to use (default to 1)
31
+ attr_reader :hash_version
32
+
33
+ # @return [BucketRange , nil] A more precise version of coverage
34
+ attr_reader :range
35
+
36
+ # @return [BucketRanges[] , nil] Ranges for experiment variations
37
+ attr_reader :ranges
38
+
39
+ # @return [VariationMeta[] , nil] Meta info about the experiment variations
40
+ attr_reader :meta
41
+
42
+ # @return [Filter[] , nil] Array of filters to apply to the rule
43
+ attr_reader :filters
44
+
45
+ # @return [String , nil] Seed to use for hashing
46
+ attr_reader :seed
47
+
48
+ # @return [String , nil] Human-readable name for the experiment
49
+ attr_reader :name
50
+
51
+ # @return [String , nil] The phase id of the experiment
52
+ attr_reader :phase
53
+
54
+ # @return [TrackData[] , nil] Array of tracking calls to fire
55
+ attr_reader :tracks
56
+
22
57
  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)
58
+ @coverage = get_option(rule, :coverage)
59
+ @force = get_option(rule, :force)
60
+ @variations = get_option(rule, :variations)
61
+ @key = get_option(rule, :key)
62
+ @weights = get_option(rule, :weights)
63
+ @namespace = get_option(rule, :namespace)
64
+ @hash_attribute = get_option(rule, :hash_attribute) || get_option(rule, :hashAttribute)
65
+ @hash_version = get_option(rule, :hash_version) || get_option(rule, :hashVersion)
66
+ @range = get_option(rule, :range)
67
+ @ranges = get_option(rule, :ranges)
68
+ @meta = get_option(rule, :meta)
69
+ @filters = get_option(rule, :filters)
70
+ @seed = get_option(rule, :seed)
71
+ @name = get_option(rule, :name)
72
+ @phase = get_option(rule, :phase)
73
+ @tracks = get_option(rule, :tracks)
74
+
75
+ cond = get_option(rule, :condition)
32
76
  @condition = Growthbook::Conditions.parse_condition(cond) unless cond.nil?
33
77
  end
34
78
 
79
+ # @return [Growthbook::InlineExperiment, nil]
35
80
  def to_experiment(feature_key)
36
81
  return nil unless @variations
37
82
 
@@ -41,34 +86,49 @@ module Growthbook
41
86
  coverage: @coverage,
42
87
  weights: @weights,
43
88
  hash_attribute: @hash_attribute,
44
- namespace: @namespace
89
+ hash_version: @hash_version,
90
+ namespace: @namespace,
91
+ meta: @meta,
92
+ ranges: @ranges,
93
+ filters: @filters,
94
+ name: @name,
95
+ phase: @phase,
96
+ seed: @seed
45
97
  )
46
98
  end
47
99
 
48
- def is_experiment?
100
+ def experiment?
49
101
  !!@variations
50
102
  end
51
103
 
52
- def is_force?
53
- !is_experiment? && !@force.nil?
104
+ def force?
105
+ !experiment? && !@force.nil?
54
106
  end
55
107
 
56
108
  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
109
+ {
110
+ 'condition' => @condition,
111
+ 'coverage' => @coverage,
112
+ 'force' => @force,
113
+ 'variations' => @variations,
114
+ 'key' => @key,
115
+ 'weights' => @weights,
116
+ 'namespace' => @namespace,
117
+ 'hashAttribute' => @hash_attribute,
118
+ 'range' => @range,
119
+ 'ranges' => @ranges,
120
+ 'meta' => @meta,
121
+ 'filters' => @filters,
122
+ 'seed' => @seed,
123
+ 'name' => @name,
124
+ 'phase' => @phase,
125
+ 'tracks' => @tracks
126
+ }.compact
67
127
  end
68
128
 
69
129
  private
70
130
 
71
- def getOption(hash, key)
131
+ def get_option(hash, key)
72
132
  return hash[key.to_sym] if hash.key?(key.to_sym)
73
133
  return hash[key.to_s] if hash.key?(key.to_s)
74
134