growthbook 1.0.0 → 1.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: 95b90dcba429217cc585f4ea2007e3d7aab0f3489dc93ee41d7fa72b847b7f53
4
- data.tar.gz: 363c48f172798a02d899cfdc6ffb692ab1f85b45cf205e0d4c5dc6339c86c83a
3
+ metadata.gz: f5919b4bd1e6564233d0fb61f00f2fcc5fee175f3122a35e9815df47ebdaeb6f
4
+ data.tar.gz: 88bf0372e161e261a536fd8953f858e9f4ad60592a96cbfb8e3baa523414cbca
5
5
  SHA512:
6
- metadata.gz: 6b8c950602e92cb66a644e6780827410d3c2e5eb71078adb95b469fbbe1c9b7f9925a6d3cf5917c345b9a5d04feabf21198d0349a57d5c597e7dca92f004bf43
7
- data.tar.gz: ded0ea4cc0913bf8d93f8f61a34b622579d7346dfe02f121d011e82b11920e7703c192e8afb6331c2ac0d5245a435473e54707907e6116e46d4b4b01a5a3c394
6
+ metadata.gz: 771a1f4fd4eeb6acb92606e67677d65717e3b3a98de80528d2adef3cce17f183a6524af1da74cb09ff18d195a062dd1c882c208cb8dfeaf631603c54b7d128e9
7
+ data.tar.gz: 3b2386b97c64ea3609ad907005d90baec0a25e7b51d45bbd973490d66a204255d5a31d1d435da406cfcd8f3443a28bb8163e6f649af114999b847cbe822f2593
@@ -29,7 +29,6 @@ module Growthbook
29
29
  when Hash
30
30
  return condition.to_h { |k, v| [k.to_s, parse_condition(v)] }
31
31
  end
32
-
33
32
  condition
34
33
  end
35
34
 
@@ -68,6 +67,8 @@ module Growthbook
68
67
  end
69
68
 
70
69
  def self.get_path(attributes, path)
70
+ path = path.to_s if path.is_a?(Symbol)
71
+
71
72
  parts = path.split('.')
72
73
  current = attributes
73
74
 
@@ -12,7 +12,7 @@ module Growthbook
12
12
  # @return [true, false, nil] If true, random assignment is disabled and only explicitly forced variations are used.
13
13
  attr_accessor :qa_mode
14
14
 
15
- # @return [Listener] An object that responds to some tracking methods that take experiment and result as arguments.
15
+ # @return [Growthbook::TrackingCallback] An object that responds to `on_experiment_viewed(GrowthBook::InlineExperiment, GrowthBook::InlineExperimentResult)`
16
16
  attr_accessor :listener
17
17
 
18
18
  # @return [Hash] Map of user attributes that are used to assign variations
@@ -24,7 +24,11 @@ module Growthbook
24
24
  # @return [Hash] Force specific experiments to always assign a specific variation (used for QA)
25
25
  attr_reader :forced_variations
26
26
 
27
- attr_reader :impressions, :forced_features
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
28
32
 
29
33
  def initialize(options = {})
30
34
  @features = {}
@@ -34,14 +38,19 @@ module Growthbook
34
38
  @enabled = true
35
39
  @impressions = {}
36
40
 
37
- options.each do |key, value|
38
- case key.to_sym
41
+ options.transform_keys(&:to_sym).each do |key, value|
42
+ case key
39
43
  when :enabled
40
44
  @enabled = value
41
45
  when :attributes
42
46
  self.attributes = value
43
47
  when :url
44
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?
45
54
  when :features
46
55
  self.features = value
47
56
  when :forced_variations, :forcedVariations
@@ -61,6 +70,8 @@ module Growthbook
61
70
  def features=(features)
62
71
  @features = {}
63
72
 
73
+ return if features.nil?
74
+
64
75
  features.each do |k, v|
65
76
  # Convert to a Feature object if it's not already
66
77
  v = Growthbook::Feature.new(v) unless v.is_a? Growthbook::Feature
@@ -83,35 +94,40 @@ module Growthbook
83
94
 
84
95
  def eval_feature(key)
85
96
  # Forced in the context
86
- return get_feature_result(@forced_features[key.to_s], 'override') if @forced_features.key?(key.to_s)
97
+ return get_feature_result(@forced_features[key.to_s], 'override', nil, nil) if @forced_features.key?(key.to_s)
87
98
 
88
99
  # Return if we can't find the feature definition
89
100
  feature = get_feature(key)
90
- return get_feature_result(nil, 'unknownFeature') unless feature
101
+ return get_feature_result(nil, 'unknownFeature', nil, nil) unless feature
91
102
 
92
103
  feature.rules.each do |rule|
93
104
  # Targeting condition
94
- next if rule.condition && !condition_passes(rule.condition)
105
+ next if rule.condition && !condition_passes?(rule.condition)
95
106
 
96
107
  # If there are filters for who is included (e.g. namespaces)
97
- next if rule.filters && filtered_out?(rule.filters)
108
+ next if rule.filters && filtered_out?(rule.filters || [])
98
109
 
99
110
  # If this is a percentage rollout, skip if not included
100
111
  if rule.force?
101
112
  seed = rule.seed || key
102
113
  hash_attribute = rule.hash_attribute || 'id'
103
114
  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
115
+ seed: seed.to_s,
116
+ hash_attribute: hash_attribute,
117
+ range: rule.range,
118
+ coverage: rule.coverage,
119
+ hash_version: rule.hash_version
106
120
  )
107
121
  next unless included_in_rollout
108
122
 
109
- return get_feature_result(rule.force, 'force')
123
+ return get_feature_result(rule.force, 'force', nil, nil)
110
124
  end
111
125
  # Experiment rule
112
126
  next unless rule.experiment?
113
127
 
114
128
  exp = rule.to_experiment(key)
129
+ next if exp.nil?
130
+
115
131
  result = _run(exp, key)
116
132
 
117
133
  next unless result.in_experiment && !result.passthrough
@@ -120,7 +136,7 @@ module Growthbook
120
136
  end
121
137
 
122
138
  # Fallback
123
- get_feature_result(feature.default_value || nil, 'defaultValue')
139
+ get_feature_result(feature.default_value.nil? ? nil : feature.default_value, 'defaultValue', nil, nil)
124
140
  end
125
141
 
126
142
  def run(exp)
@@ -152,8 +168,9 @@ module Growthbook
152
168
  return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) unless @enabled
153
169
 
154
170
  # 3. If forced via URL querystring
155
- if @url
156
- qs_override = Util.get_query_string_override(key, @url, exp.variations.length)
171
+ override_url = @url
172
+ unless override_url.nil?
173
+ qs_override = Util.get_query_string_override(key, override_url, exp.variations.length)
157
174
  return get_experiment_result(exp, qs_override, hash_used: false, feature_id: feature_id) unless qs_override.nil?
158
175
  end
159
176
 
@@ -177,13 +194,13 @@ module Growthbook
177
194
 
178
195
  # 7. Exclude if user is filtered out (used to be called "namespace")
179
196
  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)
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)
182
199
  return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id)
183
200
  end
184
201
 
185
202
  # 8. Exclude if condition is false
186
- if exp.condition && !condition_passes(exp.condition)
203
+ if exp.condition && !condition_passes?(exp.condition)
187
204
  return get_experiment_result(
188
205
  exp,
189
206
  -1,
@@ -198,7 +215,10 @@ module Growthbook
198
215
  exp.coverage,
199
216
  exp.weights
200
217
  )
201
- n = Growthbook::Util.hash(seed: exp.seed || key, value: hash_value, version: exp.hash_version || 1)
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
+
202
222
  assigned = Growthbook::Util.choose_variation(n, ranges)
203
223
 
204
224
  # 10. Return if not in experiment
@@ -228,7 +248,9 @@ module Growthbook
228
248
  new_hash
229
249
  end
230
250
 
231
- def condition_passes(condition)
251
+ def condition_passes?(condition)
252
+ return false if condition.nil?
253
+
232
254
  Growthbook::Conditions.eval_condition(@attributes, condition)
233
255
  end
234
256
 
@@ -244,9 +266,18 @@ module Growthbook
244
266
  meta = experiment.meta ? experiment.meta[variation_index] : {}
245
267
 
246
268
  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'] }
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
+ }
250
281
  )
251
282
 
252
283
  result.passthrough = true if meta['passthrough']
@@ -254,7 +285,7 @@ module Growthbook
254
285
  result
255
286
  end
256
287
 
257
- def get_feature_result(value, source, experiment = nil, experiment_result = nil)
288
+ def get_feature_result(value, source, experiment, experiment_result)
258
289
  Growthbook::FeatureResult.new(value, source, experiment, experiment_result)
259
290
  end
260
291
 
@@ -273,8 +304,10 @@ module Growthbook
273
304
  end
274
305
 
275
306
  def track_experiment(experiment, result)
307
+ return if listener.nil?
308
+
276
309
  @listener.on_experiment_viewed(experiment, result) if @listener.respond_to?(:on_experiment_viewed)
277
- @impressions[experiment.key] = result
310
+ @impressions[experiment.key] = result unless experiment.key.nil?
278
311
  end
279
312
 
280
313
  def included_in_rollout?(seed:, hash_attribute:, hash_version:, range:, coverage:)
@@ -284,7 +317,8 @@ module Growthbook
284
317
 
285
318
  return false if hash_value.empty?
286
319
 
287
- n = Growthbook::Util.hash(seed: seed, value: hash_value, version: hash_version || 1)
320
+ n = Growthbook::Util.get_hash(seed: seed, value: hash_value, version: hash_version || 1)
321
+ return false if n.nil?
288
322
 
289
323
  return Growthbook::Util.in_range?(n, range) if range
290
324
  return n <= coverage if coverage
@@ -299,11 +333,23 @@ module Growthbook
299
333
  if hash_value.empty?
300
334
  false
301
335
  else
302
- n = Growthbook::Util.hash(seed: filter['seed'], value: hash_value, version: filter['hashVersion'] || 2)
336
+ n = Growthbook::Util.get_hash(seed: filter['seed'] || '', value: hash_value, version: filter['hashVersion'] || 2)
337
+
338
+ return true if n.nil?
303
339
 
304
340
  filter['ranges'].none? { |range| Growthbook::Util.in_range?(n, range) }
305
341
  end
306
342
  end
307
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
353
+ end
308
354
  end
309
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
@@ -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
@@ -98,7 +98,9 @@ module Growthbook
98
98
  end
99
99
 
100
100
  def experiment?
101
- !!@variations
101
+ return false if @variations.nil?
102
+
103
+ !@variations&.empty?
102
104
  end
103
105
 
104
106
  def force?
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # FNV
4
+ # {https://github.com/jakedouglas/fnv-ruby Source}
5
+ class FNV
6
+ INIT32 = 0x811c9dc5
7
+ INIT64 = 0xcbf29ce484222325
8
+ PRIME32 = 0x01000193
9
+ PRIME64 = 0x100000001b3
10
+ MOD32 = 4_294_967_296
11
+ MOD64 = 18_446_744_073_709_551_616
12
+
13
+ def fnv1a_32(data)
14
+ hash = INIT32
15
+
16
+ data.bytes.each do |byte|
17
+ hash = hash ^ byte
18
+ hash = (hash * PRIME32) % MOD32
19
+ end
20
+
21
+ hash
22
+ end
23
+ end
@@ -56,7 +56,7 @@ module Growthbook
56
56
  @variations = get_option(options, :variations, [])
57
57
  @weights = get_option(options, :weights)
58
58
  @active = get_option(options, :active, true)
59
- @coverage = get_option(options, :coverage, 1)
59
+ @coverage = get_option(options, :coverage, 1.0)
60
60
  @ranges = get_option(options, :ranges)
61
61
  @condition = get_option(options, :condition)
62
62
  @namespace = get_option(options, :namespace)
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Growthbook
4
+ # Extendable class that can be used as the tracking callback
5
+ class TrackingCallback
6
+ def on_experiment_viewed(_experiment, _result); end
7
+ end
8
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'fnv'
3
+ require 'base64'
4
4
  require 'bigdecimal'
5
5
  require 'bigdecimal/util'
6
6
 
@@ -42,15 +42,20 @@ module Growthbook
42
42
  end
43
43
  end
44
44
 
45
- def self.hash(seed:, value:, version:)
45
+ # @return [Float, nil] Hash, or nil if the hash version is invalid
46
+ def self.get_hash(seed:, value:, version:)
46
47
  return (FNV.new.fnv1a_32(value + seed) % 1000) / 1000.0 if version == 1
47
48
  return (FNV.new.fnv1a_32(FNV.new.fnv1a_32(seed + value).to_s) % 10_000) / 10_000.0 if version == 2
48
49
 
49
- -1
50
+ nil
50
51
  end
51
52
 
52
- def self.in_namespace(hash_value, namespace)
53
- n = hash(seed: "__#{namespace[0]}", value: hash_value, version: 1)
53
+ def self.in_namespace?(hash_value, namespace)
54
+ return false if namespace.nil?
55
+
56
+ n = get_hash(seed: "__#{namespace[0]}", value: hash_value, version: 1)
57
+ return false if n.nil?
58
+
54
59
  n >= namespace[1] && n < namespace[2]
55
60
  end
56
61
 
@@ -65,11 +70,11 @@ module Growthbook
65
70
  end
66
71
 
67
72
  # Determine bucket ranges for experiment variations
68
- def self.get_bucket_ranges(num_variations, coverage = 1, weights = [])
73
+ def self.get_bucket_ranges(num_variations, coverage, weights)
69
74
  # Make sure coverage is within bounds
70
- coverage = 1 if coverage.nil?
71
- coverage = 0 if coverage.negative?
72
- coverage = 1 if coverage > 1
75
+ coverage = 1.0 if coverage.nil?
76
+ coverage = 0.0 if coverage.negative?
77
+ coverage = 1.0 if coverage > 1
73
78
 
74
79
  # Default to equal weights
75
80
  weights = get_equal_weights(num_variations) if !weights || weights.length != num_variations
@@ -79,7 +84,7 @@ module Growthbook
79
84
  weights = get_equal_weights(num_variations) if total < 0.99 || total > 1.01
80
85
 
81
86
  # Convert weights to ranges
82
- cumulative = 0
87
+ cumulative = 0.0
83
88
  ranges = []
84
89
  weights.each do |w|
85
90
  start = cumulative
@@ -102,13 +107,14 @@ module Growthbook
102
107
  # e.g. http://localhost?my-test=1 will return `1` for id `my-test`
103
108
  def self.get_query_string_override(id, url, num_variations)
104
109
  # Skip if url is empty
105
- return nil if url == ''
110
+ return nil if url == '' || id.nil?
106
111
 
107
112
  # Parse out the query string
108
113
  parsed = URI(url)
109
- return nil unless parsed.query
114
+ parsed_query = parsed.query
115
+ return nil if parsed_query.nil?
110
116
 
111
- qs = URI.decode_www_form(parsed.query)
117
+ qs = URI.decode_www_form(parsed_query)
112
118
 
113
119
  # Look for `id` in the querystring and get the value
114
120
  vals = qs.assoc(id)
data/lib/growthbook.rb CHANGED
@@ -6,9 +6,13 @@ end
6
6
 
7
7
  require 'growthbook/conditions'
8
8
  require 'growthbook/context'
9
+ require 'growthbook/decryption_util'
9
10
  require 'growthbook/feature'
11
+ require 'growthbook/feature_repository'
10
12
  require 'growthbook/feature_result'
11
13
  require 'growthbook/feature_rule'
14
+ require 'growthbook/fnv'
12
15
  require 'growthbook/inline_experiment'
13
16
  require 'growthbook/inline_experiment_result'
17
+ require 'growthbook/tracking_callback'
14
18
  require 'growthbook/util'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: growthbook
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GrowthBook
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-04-20 00:00:00.000000000 Z
11
+ date: 2023-06-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '3.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec-its
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.3'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.3'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: simplecov
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -53,19 +67,19 @@ dependencies:
53
67
  - !ruby/object:Gem::Version
54
68
  version: 0.1.0
55
69
  - !ruby/object:Gem::Dependency
56
- name: fnv
70
+ name: webmock
57
71
  requirement: !ruby/object:Gem::Requirement
58
72
  requirements:
59
73
  - - "~>"
60
74
  - !ruby/object:Gem::Version
61
- version: 0.2.0
62
- type: :runtime
75
+ version: '3.18'
76
+ type: :development
63
77
  prerelease: false
64
78
  version_requirements: !ruby/object:Gem::Requirement
65
79
  requirements:
66
80
  - - "~>"
67
81
  - !ruby/object:Gem::Version
68
- version: 0.2.0
82
+ version: '3.18'
69
83
  description: Official GrowthBook SDK for Ruby
70
84
  email: jeremy@growthbook.io
71
85
  executables: []
@@ -75,11 +89,15 @@ files:
75
89
  - lib/growthbook.rb
76
90
  - lib/growthbook/conditions.rb
77
91
  - lib/growthbook/context.rb
92
+ - lib/growthbook/decryption_util.rb
78
93
  - lib/growthbook/feature.rb
94
+ - lib/growthbook/feature_repository.rb
79
95
  - lib/growthbook/feature_result.rb
80
96
  - lib/growthbook/feature_rule.rb
97
+ - lib/growthbook/fnv.rb
81
98
  - lib/growthbook/inline_experiment.rb
82
99
  - lib/growthbook/inline_experiment_result.rb
100
+ - lib/growthbook/tracking_callback.rb
83
101
  - lib/growthbook/util.rb
84
102
  homepage: https://github.com/growthbook/growthbook-ruby
85
103
  licenses: