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