growthbook 1.3.0 → 2.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.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+
3
5
  module Growthbook
4
6
  # Context object passed into the GrowthBook constructor.
5
7
  class Context # rubocop:disable Metrics/ClassLength
@@ -33,6 +35,9 @@ module Growthbook
33
35
  # @return [Hash[String, Any]] Forced feature values
34
36
  attr_reader :forced_features
35
37
 
38
+ # @return [Hash[String, Array]] Saved groups, used by the $inGroup/$notInGroup operators
39
+ attr_reader :saved_groups
40
+
36
41
  # @return [Growthbook::StickyBucketService] Sticky bucket service for sticky bucketing
37
42
  attr_reader :sticky_bucket_service
38
43
 
@@ -57,6 +62,7 @@ module Growthbook
57
62
  @enabled = true
58
63
  @impressions = {}
59
64
  @sticky_bucket_assignment_docs = {}
65
+ @saved_groups = {}
60
66
 
61
67
  features = {}
62
68
  attributes = {}
@@ -90,6 +96,8 @@ module Growthbook
90
96
  @sticky_bucket_service = value
91
97
  when :sticky_bucket_identifier_attributes
92
98
  @sticky_bucket_identifier_attributes = value
99
+ when :saved_groups, :savedGroups
100
+ @saved_groups = value || {}
93
101
  else
94
102
  warn("Unknown context option: #{key}")
95
103
  end
@@ -158,7 +166,7 @@ module Growthbook
158
166
 
159
167
  return 'cyclic' if parent_res.source == 'cyclicPrerequisite'
160
168
 
161
- next if Growthbook::Conditions.eval_condition({ 'value' => parent_res.value }, parent_condition['condition'])
169
+ next if parent_condition_passes?(parent_res.value, parent_condition['condition'])
162
170
  return 'gate' if parent_condition['gate']
163
171
 
164
172
  return 'fail'
@@ -176,6 +184,9 @@ module Growthbook
176
184
 
177
185
  return get_feature_result(key.to_s, nil, 'cyclicPrerequisite', nil, nil) if stack.include?(key.to_s)
178
186
 
187
+ # Work on a copy so sibling prerequisite evaluations don't pollute each
188
+ # other's path (matches the by-value stack semantics of the other SDKs)
189
+ stack = stack.dup
179
190
  stack.add(key.to_s)
180
191
 
181
192
  feature.rules.each do |rule|
@@ -212,7 +223,7 @@ module Growthbook
212
223
  )
213
224
  next unless included_in_rollout
214
225
 
215
- return get_feature_result(key.to_s, rule.force, 'force', nil, nil)
226
+ return get_feature_result(key.to_s, rule.force, 'force', nil, nil, rule.id)
216
227
  end
217
228
  # Experiment rule
218
229
  next unless rule.experiment?
@@ -224,7 +235,7 @@ module Growthbook
224
235
 
225
236
  next unless result.in_experiment && !result.passthrough
226
237
 
227
- return get_feature_result(key.to_s, result.value, 'experiment', exp, result)
238
+ return get_feature_result(key.to_s, result.value, 'experiment', exp, result, rule.id)
228
239
  end
229
240
 
230
241
  # Fallback
@@ -261,8 +272,8 @@ module Growthbook
261
272
  return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) unless exp.active
262
273
 
263
274
  # 6. Get hash_attribute/value and return if empty
264
- hash_attribute, hash_value_raw = get_hash_attribute(exp.hash_attribute, exp.fallback_attribute)
265
- hash_value = hash_value_raw.to_s
275
+ hash_attribute, hash_value_raw = get_hash_attribute(exp.hash_attribute, experiment_fallback_attribute(exp))
276
+ hash_value = hash_string(hash_value_raw)
266
277
  return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if hash_value.empty?
267
278
 
268
279
  assigned = -1
@@ -368,10 +379,20 @@ module Growthbook
368
379
  new_hash
369
380
  end
370
381
 
382
+ # A malformed prerequisite condition never matches, rather than raising into the app
383
+ def parent_condition_passes?(parent_value, condition)
384
+ Growthbook::Conditions.eval_condition({ 'value' => parent_value }, condition || {}, @saved_groups)
385
+ rescue StandardError
386
+ false
387
+ end
388
+
389
+ # A condition that raises, such as a malformed one, fails rather than breaking evaluation
371
390
  def condition_passes?(condition)
372
391
  return false if condition.nil?
373
392
 
374
- Growthbook::Conditions.eval_condition(@attributes, condition)
393
+ Growthbook::Conditions.eval_condition(@attributes, condition, @saved_groups)
394
+ rescue StandardError
395
+ false
375
396
  end
376
397
 
377
398
  def get_experiment_result(experiment, variation_index = -1, hash_used: false, feature_id: '', bucket: nil, sticky_bucket_used: false)
@@ -381,7 +402,7 @@ module Growthbook
381
402
  in_experiment = false
382
403
  end
383
404
 
384
- hash_attribute, hash_value = get_hash_attribute(experiment.hash_attribute, experiment.fallback_attribute)
405
+ hash_attribute, hash_value = get_hash_attribute(experiment.hash_attribute, experiment_fallback_attribute(experiment))
385
406
  meta = experiment.meta ? experiment.meta[variation_index] : {}
386
407
 
387
408
  result = Growthbook::InlineExperimentResult.new(
@@ -405,8 +426,8 @@ module Growthbook
405
426
  result
406
427
  end
407
428
 
408
- def get_feature_result(key, value, source, experiment, experiment_result)
409
- res = Growthbook::FeatureResult.new(value, source, experiment, experiment_result)
429
+ def get_feature_result(key, value, source, experiment, experiment_result, rule_id = '')
430
+ res = Growthbook::FeatureResult.new(value, source, experiment, experiment_result, rule_id)
410
431
 
411
432
  track_feature_usage(key, res)
412
433
 
@@ -427,20 +448,45 @@ module Growthbook
427
448
  nil
428
449
  end
429
450
 
451
+ # Returns the hash attribute and its raw value ('' when missing). Like JS,
452
+ # only a non-empty string or a finite, non-zero number identifies a user;
453
+ # anything else counts as missing, and then the fallback attribute is tried
454
+ # with the same rule.
430
455
  def get_hash_attribute(attr, fallback_attr)
431
- attr ||= 'id'
456
+ attr = 'id' if attr.nil? || attr == ''
432
457
 
433
458
  val = get_attribute(attr)
459
+ val = '' unless hashable?(val)
434
460
 
435
461
  # If no match, try fallback
436
- if (val.nil? || val == '') && fallback_attr && @sticky_bucket_service
462
+ if val == '' && fallback_attr && hashable?(get_attribute(fallback_attr))
437
463
  val = get_attribute(fallback_attr)
438
- attr = fallback_attr unless val.nil? || val == ''
464
+ attr = fallback_attr
439
465
  end
440
466
 
441
467
  [attr, val]
442
468
  end
443
469
 
470
+ def hashable?(value)
471
+ return value != '' if value.is_a?(String)
472
+ return value != 0 && value.to_f.finite? if value.is_a?(Integer) || value.is_a?(Float)
473
+
474
+ false
475
+ end
476
+
477
+ # Formats a hash value like JS String(): 1.0 as "1"
478
+ def hash_string(value)
479
+ return value if value.is_a?(String)
480
+ return Growthbook::Util.js_number_to_s(value) if value.is_a?(Integer) || value.is_a?(Float)
481
+
482
+ ''
483
+ end
484
+
485
+ # Experiments only use their fallback attribute with sticky bucketing, like JS
486
+ def experiment_fallback_attribute(experiment)
487
+ experiment.fallback_attribute if @sticky_bucket_service && !experiment.disable_sticky_bucketing
488
+ end
489
+
444
490
  def get_attribute(key)
445
491
  return '' if key.nil?
446
492
 
@@ -459,10 +505,11 @@ module Growthbook
459
505
 
460
506
  def included_in_rollout?(seed:, hash_attribute:, fallback_attribute:, hash_version:, range:, coverage:)
461
507
  return true if range.nil? && coverage.nil?
508
+ return false if range.nil? && coverage&.zero?
462
509
 
463
510
  _, hash_value_raw = get_hash_attribute(hash_attribute, fallback_attribute)
464
511
 
465
- hash_value = hash_value_raw.to_s
512
+ hash_value = hash_string(hash_value_raw)
466
513
 
467
514
  return false if hash_value.empty?
468
515
 
@@ -470,17 +517,20 @@ module Growthbook
470
517
  return false if n.nil?
471
518
 
472
519
  return Growthbook::Util.in_range?(n, range) if range
473
- return n <= coverage if coverage
520
+ # Half-open, like in_range?, so a coverage of k/10000 includes exactly k hash buckets
521
+ return n < coverage if coverage
474
522
 
475
523
  true
476
524
  end
477
525
 
478
526
  def filtered_out?(filters)
479
527
  filters.any? do |filter|
480
- hash_value = get_attribute(filter['attribute'] || 'id').to_s
528
+ _, hash_value_raw = get_hash_attribute(filter['attribute'], nil)
529
+ hash_value = hash_string(hash_value_raw)
481
530
 
531
+ # Like JS, a user without a hash value is filtered out
482
532
  if hash_value.empty?
483
- false
533
+ true
484
534
  else
485
535
  n = Growthbook::Util.get_hash(seed: filter['seed'] || '', value: hash_value, version: filter['hashVersion'] || 2)
486
536
 
@@ -522,7 +572,7 @@ module Growthbook
522
572
 
523
573
  @sticky_bucket_identifier_attributes.each do |attr|
524
574
  _, hash_value = get_hash_attribute(attr, nil)
525
- attributes[attr] = hash_value if hash_value
575
+ attributes[attr] = hash_string(hash_value)
526
576
  end
527
577
  attributes
528
578
  end
@@ -530,13 +580,13 @@ module Growthbook
530
580
  def _get_sticky_bucket_assignments(attr = nil, fallback = nil)
531
581
  merged = {}
532
582
 
533
- _, hash_value = get_hash_attribute(attr, nil)
534
- key = "#{attr}||#{hash_value}"
583
+ attr, hash_value = get_hash_attribute(attr, nil)
584
+ key = "#{attr}||#{hash_string(hash_value)}"
535
585
  merged = @sticky_bucket_assignment_docs[key]['assignments'] if @sticky_bucket_assignment_docs.key?(key)
536
586
 
537
587
  if fallback
538
588
  _, hash_value = get_hash_attribute(fallback, nil)
539
- key = "#{fallback}||#{hash_value}"
589
+ key = "#{fallback}||#{hash_string(hash_value)}"
540
590
  if @sticky_bucket_assignment_docs.key?(key)
541
591
  @sticky_bucket_assignment_docs[key]['assignments'].each do |k, v|
542
592
  merged[k] = v unless merged.key?(k)
@@ -15,10 +15,14 @@ module Growthbook
15
15
  # Parsed features JSON
16
16
  attr_reader :features_json
17
17
 
18
+ # Parsed saved groups JSON (used by the $inGroup/$notInGroup operators)
19
+ attr_reader :saved_groups_json
20
+
18
21
  def initialize(endpoint:, decryption_key:)
19
22
  @endpoint = endpoint
20
23
  @decryption_key = decryption_key
21
24
  @features_json = {}
25
+ @saved_groups_json = {}
22
26
  end
23
27
 
24
28
  def fetch
@@ -56,7 +60,11 @@ module Growthbook
56
60
  end
57
61
 
58
62
  def parsed_plain_text_response
59
- @features_json = parsed_response['features'] unless parsed_response.nil?
63
+ parsed = parsed_response
64
+ return nil if parsed.nil?
65
+
66
+ @saved_groups_json = parsed['savedGroups'] || {}
67
+ @features_json = parsed['features']
60
68
  rescue StandardError
61
69
  nil
62
70
  end
@@ -65,12 +73,26 @@ module Growthbook
65
73
  k = decryption_key
66
74
  return nil if k.nil?
67
75
 
68
- decrypted_str = Growthbook::DecryptionUtil.decrypt(parsed_response['encryptedFeatures'], key: k)
76
+ parsed = parsed_response
77
+ return nil if parsed.nil?
78
+
79
+ @saved_groups_json = decrypted_saved_groups(parsed, k)
80
+
81
+ decrypted_str = Growthbook::DecryptionUtil.decrypt(parsed['encryptedFeatures'], key: k)
69
82
  @features_json = JSON.parse(decrypted_str) unless decrypted_str.nil?
70
83
  rescue StandardError
71
84
  nil
72
85
  end
73
86
 
87
+ def decrypted_saved_groups(parsed, key)
88
+ if parsed['encryptedSavedGroups']
89
+ decrypted = Growthbook::DecryptionUtil.decrypt(parsed['encryptedSavedGroups'], key: key)
90
+ return JSON.parse(decrypted) unless decrypted.nil?
91
+ end
92
+
93
+ parsed['savedGroups'] || {}
94
+ end
95
+
74
96
  def parsed_response
75
97
  res = response
76
98
  return {} if res.nil?
@@ -27,14 +27,20 @@ module Growthbook
27
27
  # @return [Growthbook::InlineExperimentResult, nil]
28
28
  attr_reader :experiment_result
29
29
 
30
+ # The id of the rule (if any) that was used to assign the value
31
+ # @return [String]
32
+ attr_reader :rule_id
33
+
30
34
  def initialize(
31
35
  value,
32
36
  source,
33
37
  experiment,
34
- experiment_result
38
+ experiment_result,
39
+ rule_id = ''
35
40
  )
36
41
 
37
- on = !value.nil? && value != 0 && value != '' && value != false
42
+ # JS truthiness: [], {} and "off" are on
43
+ on = !value.nil? && value != 0 && value != '' && value != false && !(value.is_a?(Float) && value.nan?)
38
44
 
39
45
  @value = value
40
46
  @on = on
@@ -42,6 +48,7 @@ module Growthbook
42
48
  @source = source
43
49
  @experiment = experiment
44
50
  @experiment_result = experiment_result
51
+ @rule_id = rule_id || ''
45
52
  end
46
53
 
47
54
  def to_json(*_args)
@@ -50,6 +57,7 @@ module Growthbook
50
57
  json['off'] = @off
51
58
  json['value'] = @value
52
59
  json['source'] = @source
60
+ json['ruleId'] = @rule_id
53
61
 
54
62
  if @experiment
55
63
  json['experiment'] = @experiment.to_json
@@ -3,6 +3,9 @@
3
3
  module Growthbook
4
4
  # Internal class that overrides the default value of a Feature based on a set of requirements.
5
5
  class FeatureRule
6
+ # @return [String , nil] Unique identifier for this rule
7
+ attr_reader :id
8
+
6
9
  # @return [Hash , nil] Optional targeting condition
7
10
  attr_reader :condition
8
11
 
@@ -69,7 +72,11 @@ module Growthbook
69
72
  # @return [Array<Hash>] Array of prerequisite flags
70
73
  attr_accessor :parent_conditions
71
74
 
75
+ # @return [Hash, nil] Arbitrary custom fields attached to the rule
76
+ attr_reader :custom_fields
77
+
72
78
  def initialize(rule)
79
+ @id = get_option(rule, :id)
73
80
  @coverage = get_option(rule, :coverage)
74
81
  @force = get_option(rule, :force)
75
82
  @variations = get_option(rule, :variations)
@@ -95,6 +102,7 @@ module Growthbook
95
102
  @bucket_version = get_option(rule, :bucket_version) || get_option(rule, :bucketVersion) || 0
96
103
  @min_bucket_version = get_option(rule, :min_bucket_version) || get_option(rule, :minBucketVersion) || 0
97
104
  @parent_conditions = get_option(rule, :parent_conditions) || get_option(rule, :parentConditions) || []
105
+ @custom_fields = get_option(rule, :custom_fields) || get_option(rule, :customFields)
98
106
 
99
107
  return unless @disable_sticky_bucketing
100
108
 
@@ -123,7 +131,8 @@ module Growthbook
123
131
  fallback_attribute: @fallback_attribute,
124
132
  disable_sticky_bucketing: @disable_sticky_bucketing,
125
133
  bucket_version: @bucket_version,
126
- min_bucket_version: @min_bucket_version
134
+ min_bucket_version: @min_bucket_version,
135
+ custom_fields: @custom_fields
127
136
  )
128
137
  end
129
138
 
@@ -10,11 +10,14 @@ class FNV
10
10
  MOD32 = 4_294_967_296
11
11
  MOD64 = 18_446_744_073_709_551_616
12
12
 
13
+ # Hashes UTF-16 code units, like JavaScript's charCodeAt, so a character
14
+ # above U+FFFF counts as two units
13
15
  def fnv1a_32(data)
14
16
  hash = INIT32
15
17
 
16
- data.bytes.each do |byte|
17
- hash = hash ^ byte
18
+ units = data.ascii_only? ? data.bytes : data.encode('UTF-16LE').unpack('v*').map(&:to_i)
19
+ units.each do |unit|
20
+ hash = hash ^ unit
18
21
  hash = (hash * PRIME32) % MOD32
19
22
  end
20
23
 
@@ -66,12 +66,17 @@ module Growthbook
66
66
  # @return [Array<Hash>] Array of prerequisite flags
67
67
  attr_accessor :parent_conditions
68
68
 
69
+ # @return [Hash, nil] Arbitrary custom fields attached to the experiment
70
+ attr_accessor :custom_fields
71
+
69
72
  def initialize(options = {})
70
73
  @key = get_option(options, :key, '').to_s
71
74
  @variations = get_option(options, :variations, [])
72
75
  @weights = get_option(options, :weights)
73
76
  @active = get_option(options, :active, true)
74
77
  @coverage = get_option(options, :coverage, 1.0)
78
+ # Serialize coverage whenever it was given, even when it's 1 (like the JS SDK)
79
+ @coverage_given = !get_option(options, :coverage).nil?
75
80
  @ranges = get_option(options, :ranges)
76
81
  @condition = get_option(options, :condition)
77
82
  @namespace = get_option(options, :namespace)
@@ -88,6 +93,7 @@ module Growthbook
88
93
  @bucket_version = get_option(options, :bucket_version) || get_option(options, :bucketVersion) || 0
89
94
  @min_bucket_version = get_option(options, :min_bucket_version) || get_option(options, :minBucketVersion) || 0
90
95
  @parent_conditions = get_option(options, :parent_conditions) || get_option(options, :parentConditions) || []
96
+ @custom_fields = get_option(options, :custom_fields) || get_option(options, :customFields)
91
97
 
92
98
  return unless @disable_sticky_bucketing
93
99
 
@@ -100,7 +106,7 @@ module Growthbook
100
106
  res['variations'] = @variations
101
107
  res['weights'] = @weights unless @weights.nil?
102
108
  res['active'] = @active if @active != true && !@active.nil?
103
- res['coverage'] = @coverage if @coverage != 1 && !@coverage.nil?
109
+ res['coverage'] = @coverage if @coverage_given || (@coverage != 1 && !@coverage.nil?)
104
110
  res['ranges'] = @ranges
105
111
  res['condition'] = @condition
106
112
  res['namespace'] = @namespace