growthbook 0.3.0 → 1.1.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: f5919b4bd1e6564233d0fb61f00f2fcc5fee175f3122a35e9815df47ebdaeb6f
4
+ data.tar.gz: 88bf0372e161e261a536fd8953f858e9f4ad60592a96cbfb8e3baa523414cbca
5
5
  SHA512:
6
- metadata.gz: 0f1c46dac7d0e9dccc8641f67d7dbbe8d2af91ec18a4cf0fdcb13701042e0fad55bbcf35b91c330506816fa59cbb0129a8ae5a7f3fb103b308735268f3638937
7
- data.tar.gz: c38fe86529984df54ee57560b01591c99a20b616f02d8296e9e81a7c1fc56d6755096c675e96e94a52c57c5e9e77435b497aed2d4a3ebb4c0cdcbd78f7d8085b
6
+ metadata.gz: 771a1f4fd4eeb6acb92606e67677d65717e3b3a98de80528d2adef3cce17f183a6524af1da74cb09ff18d195a062dd1c882c208cb8dfeaf631603c54b7d128e9
7
+ data.tar.gz: 3b2386b97c64ea3609ad907005d90baec0a25e7b51d45bbd973490d66a204255d5a31d1d435da406cfcd8f3443a28bb8163e6f649af114999b847cbe822f2593
@@ -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,9 +27,8 @@ 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
-
31
32
  condition
32
33
  end
33
34
 
@@ -47,7 +48,7 @@ module Growthbook
47
48
  true
48
49
  end
49
50
 
50
- def self.is_operator_object(obj)
51
+ def self.operator_object?(obj)
51
52
  obj.each do |key, _value|
52
53
  return false if key[0] != '$'
53
54
  end
@@ -58,7 +59,7 @@ module Growthbook
58
59
  return 'string' if attribute_value.is_a? String
59
60
  return 'number' if attribute_value.is_a? Integer
60
61
  return 'number' if attribute_value.is_a? Float
61
- return 'boolean' if attribute_value == true || attribute_value == false
62
+ return 'boolean' if [true, false].include?(attribute_value)
62
63
  return 'array' if attribute_value.is_a? Array
63
64
  return 'null' if attribute_value.nil?
64
65
 
@@ -66,22 +67,22 @@ module Growthbook
66
67
  end
67
68
 
68
69
  def self.get_path(attributes, path)
70
+ path = path.to_s if path.is_a?(Symbol)
71
+
69
72
  parts = path.split('.')
70
73
  current = attributes
71
74
 
72
75
  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
76
+ return nil unless current.is_a?(Hash) && current&.key?(value)
77
+
78
+ current = current[value]
78
79
  end
79
80
 
80
81
  current
81
82
  end
82
83
 
83
84
  def self.eval_condition_value(condition_value, attribute_value)
84
- if condition_value.is_a?(Hash) && is_operator_object(condition_value)
85
+ if condition_value.is_a?(Hash) && operator_object?(condition_value)
85
86
  condition_value.each do |key, value|
86
87
  return false unless eval_operator_condition(key, attribute_value, value)
87
88
  end
@@ -94,7 +95,7 @@ module Growthbook
94
95
  return false unless attribute_value.is_a? Array
95
96
 
96
97
  attribute_value.each do |item|
97
- if is_operator_object(condition)
98
+ if operator_object?(condition)
98
99
  return true if eval_condition_value(condition, item)
99
100
  elsif eval_condition(item, condition)
100
101
  return true
@@ -147,10 +148,10 @@ module Growthbook
147
148
  true
148
149
  when '$exists'
149
150
  exists = !attribute_value.nil?
150
- if !condition_value
151
- !exists
152
- else
151
+ if condition_value
153
152
  exists
153
+ else
154
+ !exists
154
155
  end
155
156
  when '$type'
156
157
  condition_value == get_type(attribute_value)
@@ -1,9 +1,34 @@
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 [Growthbook::TrackingCallback] An object that responds to `on_experiment_viewed(GrowthBook::InlineExperiment, GrowthBook::InlineExperimentResult)`
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
+ # @return [Hash[String, Growthbook::InlineExperimentResult]] Tracked impressions
28
+ attr_reader :impressions
29
+
30
+ # @return [Hash[String, Any]] Forced feature values
31
+ attr_reader :forced_features
7
32
 
8
33
  def initialize(options = {})
9
34
  @features = {}
@@ -13,14 +38,19 @@ module Growthbook
13
38
  @enabled = true
14
39
  @impressions = {}
15
40
 
16
- options.each do |key, value|
17
- case key.to_sym
41
+ options.transform_keys(&:to_sym).each do |key, value|
42
+ case key
18
43
  when :enabled
19
44
  @enabled = value
20
45
  when :attributes
21
46
  self.attributes = value
22
47
  when :url
23
48
  @url = value
49
+ when :decryption_key
50
+ nil
51
+ when :encrypted_features
52
+ decrypted = decrypted_features_from_options(options)
53
+ self.features = decrypted unless decrypted.nil?
24
54
  when :features
25
55
  self.features = value
26
56
  when :forced_variations, :forcedVariations
@@ -40,6 +70,8 @@ module Growthbook
40
70
  def features=(features)
41
71
  @features = {}
42
72
 
73
+ return if features.nil?
74
+
43
75
  features.each do |k, v|
44
76
  # Convert to a Feature object if it's not already
45
77
  v = Growthbook::Feature.new(v) unless v.is_a? Growthbook::Feature
@@ -62,42 +94,49 @@ module Growthbook
62
94
 
63
95
  def eval_feature(key)
64
96
  # 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
97
+ return get_feature_result(@forced_features[key.to_s], 'override', nil, nil) if @forced_features.key?(key.to_s)
68
98
 
69
99
  # Return if we can't find the feature definition
70
100
  feature = get_feature(key)
71
- return get_feature_result(nil, 'unknownFeature') unless feature
101
+ return get_feature_result(nil, 'unknownFeature', nil, nil) unless feature
72
102
 
73
103
  feature.rules.each do |rule|
74
104
  # 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')
105
+ next if rule.condition && !condition_passes?(rule.condition)
106
+
107
+ # If there are filters for who is included (e.g. namespaces)
108
+ next if rule.filters && filtered_out?(rule.filters || [])
109
+
110
+ # If this is a percentage rollout, skip if not included
111
+ if rule.force?
112
+ seed = rule.seed || key
113
+ hash_attribute = rule.hash_attribute || 'id'
114
+ included_in_rollout = included_in_rollout?(
115
+ seed: seed.to_s,
116
+ hash_attribute: hash_attribute,
117
+ range: rule.range,
118
+ coverage: rule.coverage,
119
+ hash_version: rule.hash_version
120
+ )
121
+ next unless included_in_rollout
122
+
123
+ return get_feature_result(rule.force, 'force', nil, nil)
87
124
  end
88
125
  # Experiment rule
89
- next unless rule.is_experiment?
126
+ next unless rule.experiment?
90
127
 
91
128
  exp = rule.to_experiment(key)
129
+ next if exp.nil?
130
+
92
131
  result = _run(exp, key)
93
132
 
94
- next unless result.in_experiment
133
+ next unless result.in_experiment && !result.passthrough
95
134
 
96
135
  return get_feature_result(result.value, 'experiment', exp, result)
97
136
  end
98
137
 
99
138
  # Fallback
100
- get_feature_result(feature.default_value || nil, 'defaultValue')
139
+ get_feature_result(feature.default_value.nil? ? nil : feature.default_value, 'defaultValue', nil, nil)
101
140
  end
102
141
 
103
142
  def run(exp)
@@ -119,58 +158,80 @@ module Growthbook
119
158
 
120
159
  private
121
160
 
122
- def _run(exp, feature_id="")
161
+ def _run(exp, feature_id = '')
123
162
  key = exp.key
124
163
 
125
164
  # 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
165
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if exp.variations.length < 2
127
166
 
128
167
  # 2. If context is disabled, return immediately
129
- return get_experiment_result(exp, -1, false, feature_id) unless @enabled
168
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) unless @enabled
130
169
 
131
170
  # 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?
171
+ override_url = @url
172
+ unless override_url.nil?
173
+ qs_override = Util.get_query_string_override(key, override_url, exp.variations.length)
174
+ return get_experiment_result(exp, qs_override, hash_used: false, feature_id: feature_id) unless qs_override.nil?
135
175
  end
136
176
 
137
177
  # 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)
178
+ if @forced_variations.key?(key.to_s)
179
+ return get_experiment_result(
180
+ exp,
181
+ @forced_variations[key.to_s],
182
+ hash_used: false,
183
+ feature_id: feature_id
184
+ )
185
+ end
139
186
 
140
187
  # 5. Exclude if not active
141
- return get_experiment_result(exp, -1, false, feature_id) unless exp.active
188
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) unless exp.active
142
189
 
143
190
  # 6. Get hash_attribute/value and return if empty
144
191
  hash_attribute = exp.hash_attribute || 'id'
145
192
  hash_value = get_attribute(hash_attribute).to_s
146
- return get_experiment_result(exp, -1, false, feature_id) if hash_value.length.zero?
193
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if hash_value.empty?
147
194
 
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)
195
+ # 7. Exclude if user is filtered out (used to be called "namespace")
196
+ if exp.filters
197
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if filtered_out?(exp.filters || [])
198
+ elsif exp.namespace && !Growthbook::Util.in_namespace?(hash_value, exp.namespace)
199
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id)
200
+ end
150
201
 
151
202
  # 8. Exclude if condition is false
152
- return get_experiment_result(exp, -1, false, feature_id) if exp.condition && !condition_passes(exp.condition)
203
+ if exp.condition && !condition_passes?(exp.condition)
204
+ return get_experiment_result(
205
+ exp,
206
+ -1,
207
+ hash_used: false,
208
+ feature_id: feature_id
209
+ )
210
+ end
153
211
 
154
- # 9. Calculate bucket ranges and choose one
155
- ranges = Growthbook::Util.get_bucket_ranges(
212
+ # 9. Get bucket ranges and choose variation
213
+ ranges = exp.ranges || Growthbook::Util.get_bucket_ranges(
156
214
  exp.variations.length,
157
215
  exp.coverage,
158
216
  exp.weights
159
217
  )
160
- n = Growthbook::Util.hash(hash_value + key)
218
+ seed = exp.seed || key || ''
219
+ n = Growthbook::Util.get_hash(seed: seed, value: hash_value, version: exp.hash_version || 1)
220
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if n.nil?
221
+
161
222
  assigned = Growthbook::Util.choose_variation(n, ranges)
162
223
 
163
224
  # 10. Return if not in experiment
164
- return get_experiment_result(exp, -1, false, feature_id) if assigned.negative?
225
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if assigned.negative?
165
226
 
166
227
  # 11. Experiment has a forced variation
167
- return get_experiment_result(exp, exp.force, false, feature_id) unless exp.force.nil?
228
+ return get_experiment_result(exp, exp.force, hash_used: false, feature_id: feature_id) unless exp.force.nil?
168
229
 
169
230
  # 12. Exclude if in QA mode
170
- return get_experiment_result(exp, -1, false, feature_id) if @qa_mode
231
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if @qa_mode
171
232
 
172
233
  # 13. Build the result object
173
- result = get_experiment_result(exp, assigned, true, feature_id)
234
+ result = get_experiment_result(exp, assigned, hash_used: true, feature_id: feature_id, bucket: n)
174
235
 
175
236
  # 14. Fire tracking callback
176
237
  track_experiment(exp, result)
@@ -187,11 +248,13 @@ module Growthbook
187
248
  new_hash
188
249
  end
189
250
 
190
- def condition_passes(condition)
251
+ def condition_passes?(condition)
252
+ return false if condition.nil?
253
+
191
254
  Growthbook::Conditions.eval_condition(@attributes, condition)
192
255
  end
193
256
 
194
- def get_experiment_result(experiment, variation_index = -1, hash_used = false, feature_id = "")
257
+ def get_experiment_result(experiment, variation_index = -1, hash_used: false, feature_id: '', bucket: nil)
195
258
  in_experiment = true
196
259
  if variation_index.negative? || variation_index >= experiment.variations.length
197
260
  variation_index = 0
@@ -200,12 +263,29 @@ module Growthbook
200
263
 
201
264
  hash_attribute = experiment.hash_attribute || 'id'
202
265
  hash_value = get_attribute(hash_attribute)
266
+ meta = experiment.meta ? experiment.meta[variation_index] : {}
267
+
268
+ result = Growthbook::InlineExperimentResult.new(
269
+ {
270
+ key: meta['key'] || variation_index,
271
+ in_experiment: in_experiment,
272
+ variation_id: variation_index,
273
+ value: experiment.variations[variation_index],
274
+ hash_used: hash_used,
275
+ hash_attribute: hash_attribute,
276
+ hash_value: hash_value,
277
+ feature_id: feature_id,
278
+ bucket: bucket,
279
+ name: meta['name']
280
+ }
281
+ )
282
+
283
+ result.passthrough = true if meta['passthrough']
203
284
 
204
- Growthbook::InlineExperimentResult.new(hash_used, in_experiment, variation_index,
205
- experiment.variations[variation_index], hash_attribute, hash_value, feature_id)
285
+ result
206
286
  end
207
287
 
208
- def get_feature_result(value, source, experiment = nil, experiment_result = nil)
288
+ def get_feature_result(value, source, experiment, experiment_result)
209
289
  Growthbook::FeatureResult.new(value, source, experiment, experiment_result)
210
290
  end
211
291
 
@@ -224,10 +304,52 @@ module Growthbook
224
304
  end
225
305
 
226
306
  def track_experiment(experiment, result)
227
- if @listener && @listener.respond_to?(:on_experiment_viewed)
228
- @listener.on_experiment_viewed(experiment, result)
307
+ return if listener.nil?
308
+
309
+ @listener.on_experiment_viewed(experiment, result) if @listener.respond_to?(:on_experiment_viewed)
310
+ @impressions[experiment.key] = result unless experiment.key.nil?
311
+ end
312
+
313
+ def included_in_rollout?(seed:, hash_attribute:, hash_version:, range:, coverage:)
314
+ return true if range.nil? && coverage.nil?
315
+
316
+ hash_value = get_attribute(hash_attribute)
317
+
318
+ return false if hash_value.empty?
319
+
320
+ n = Growthbook::Util.get_hash(seed: seed, value: hash_value, version: hash_version || 1)
321
+ return false if n.nil?
322
+
323
+ return Growthbook::Util.in_range?(n, range) if range
324
+ return n <= coverage if coverage
325
+
326
+ true
327
+ end
328
+
329
+ def filtered_out?(filters)
330
+ filters.any? do |filter|
331
+ hash_value = get_attribute(filter['attribute'] || 'id')
332
+
333
+ if hash_value.empty?
334
+ false
335
+ else
336
+ n = Growthbook::Util.get_hash(seed: filter['seed'] || '', value: hash_value, version: filter['hashVersion'] || 2)
337
+
338
+ return true if n.nil?
339
+
340
+ filter['ranges'].none? { |range| Growthbook::Util.in_range?(n, range) }
341
+ end
229
342
  end
230
- @impressions[experiment.key] = result
343
+ end
344
+
345
+ def decrypted_features_from_options(options)
346
+ decrypted_features = DecryptionUtil.decrypt(options[:encrypted_features], key: options[:decryption_key])
347
+
348
+ return nil if decrypted_features.nil?
349
+
350
+ JSON.parse(decrypted_features)
351
+ rescue StandardError
352
+ nil
231
353
  end
232
354
  end
233
355
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'openssl'
5
+
6
+ module Growthbook
7
+ # Utils for working with encrypted feature payloads.
8
+ class DecryptionUtil
9
+ # @return [String, nil] The decrypted payload, or nil if it fails to decrypt
10
+ def self.decrypt(payload, key:)
11
+ return nil if payload.nil?
12
+ return nil unless payload.include?('.')
13
+
14
+ parts = payload.split('.')
15
+ return nil if parts.length != 2
16
+
17
+ iv = parts[0]
18
+ decoded_iv = Base64.strict_decode64(iv)
19
+ decoded_key = Base64.strict_decode64(key)
20
+
21
+ cipher_text = parts[1]
22
+ decoded_cipher_text = Base64.strict_decode64(cipher_text)
23
+
24
+ cipher = OpenSSL::Cipher.new('aes-128-cbc')
25
+
26
+ cipher.decrypt
27
+ cipher.key = decoded_key
28
+ cipher.iv = decoded_iv
29
+
30
+ cipher.update(decoded_cipher_text) + cipher.final
31
+ rescue StandardError
32
+ nil
33
+ end
34
+ end
35
+ 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
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ module Growthbook
7
+ # Optional class for fetching features from the GrowthBook API
8
+ class FeatureRepository
9
+ # [String] The SDK endpoint
10
+ attr_reader :endpoint
11
+
12
+ # [String, nil] Optional key for decrypting an encrypted payload
13
+ attr_reader :decryption_key
14
+
15
+ # Parsed features JSON
16
+ attr_reader :features_json
17
+
18
+ def initialize(endpoint:, decryption_key:)
19
+ @endpoint = endpoint
20
+ @decryption_key = decryption_key
21
+ @features_json = {}
22
+ end
23
+
24
+ def fetch
25
+ uri = URI(endpoint)
26
+ res = Net::HTTP.get_response(uri)
27
+
28
+ @response = res.is_a?(Net::HTTPSuccess) ? res.body : nil
29
+
30
+ return nil if response.nil?
31
+
32
+ if use_decryption?
33
+ parsed_decrypted_response
34
+ else
35
+ parsed_plain_text_response
36
+ end
37
+ rescue StandardError
38
+ nil
39
+ end
40
+
41
+ def fetch!
42
+ fetch
43
+
44
+ raise FeatureFetchError if response.nil?
45
+ raise FeatureParseError if features_json.nil? || features_json.empty?
46
+
47
+ features_json
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :response
53
+
54
+ def use_decryption?
55
+ !decryption_key.nil?
56
+ end
57
+
58
+ def parsed_plain_text_response
59
+ @features_json = parsed_response['features'] unless parsed_response.nil?
60
+ rescue StandardError
61
+ nil
62
+ end
63
+
64
+ def parsed_decrypted_response
65
+ k = decryption_key
66
+ return nil if k.nil?
67
+
68
+ decrypted_str = Growthbook::DecryptionUtil.decrypt(parsed_response['encryptedFeatures'], key: k)
69
+ @features_json = JSON.parse(decrypted_str) unless decrypted_str.nil?
70
+ rescue StandardError
71
+ nil
72
+ end
73
+
74
+ def parsed_response
75
+ res = response
76
+ return {} if res.nil?
77
+
78
+ JSON.parse(res)
79
+ rescue StandardError
80
+ {}
81
+ end
82
+
83
+ class FeatureFetchError < StandardError; end
84
+
85
+ class FeatureParseError < StandardError; end
86
+ end
87
+ end
@@ -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(