growthbook 0.3.0 → 1.0.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: 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