growthbook 1.2.1 → 1.3.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: cba2938ec675b1626d643fb7493223a54d0faf1ffc37c2bacb5a06637c0a75cd
4
- data.tar.gz: bce59918f346b53182450ec2fdd92776767954712334bc5b7c0bd48d952c05ce
3
+ metadata.gz: d672aca084d165dd7fa2a67772e6db2bb42f80876a55758fe9dc6ace227e2388
4
+ data.tar.gz: 4fd7709789e804bdd1a952ccf580e8fd34f265389725ca5d3f93e5eb4e073b32
5
5
  SHA512:
6
- metadata.gz: 0c4ac9f40b23c8719b9cb585d92f089144df5441c787321fd0083ae72ff954f5f8027615cc3d226dcdb7c2bfdc3dc5911ef1abd472904c026a3e24c1dd81230c
7
- data.tar.gz: 735bfc1c4b82432a4a9b0664282062f43ab644309933afcddb43c9dc53cd32a278d1cf14221ad9b298a0bbdb0e2a12a8e77c4aa814d8ba3719e7840c62a4d09a
6
+ metadata.gz: 6b2a49f5ff71788343f3c8c20d8ea32197b94131e0a93ce5f6d76baa706cc1465b03c3eb1be4208aec5c71672d13442425d9623f56ba1162498613fa91c4f059
7
+ data.tar.gz: e6dab10324ad1e4673db49d5db4b1e5b7b5f97e1153a72d1b19e8607e84c068f1298499d0fd6994a33c132d750a4013d021c197d6e16a8d720c61f9bb657be10
@@ -104,20 +104,68 @@ module Growthbook
104
104
  false
105
105
  end
106
106
 
107
+ def self.compare(val1, val2)
108
+ if val1.is_a?(Numeric) || val2.is_a?(Numeric)
109
+ val1 = val1.is_a?(Numeric) ? val1 : val1.to_f
110
+ val2 = val2.is_a?(Numeric) ? val2 : val2.to_f
111
+ end
112
+
113
+ return 1 if val1 > val2
114
+ return -1 if val1 < val2
115
+
116
+ 0
117
+ end
118
+
107
119
  def self.eval_operator_condition(operator, attribute_value, condition_value)
108
120
  case operator
121
+ when '$veq'
122
+ padded_version_string(attribute_value) == padded_version_string(condition_value)
123
+ when '$vne'
124
+ padded_version_string(attribute_value) != padded_version_string(condition_value)
125
+ when '$vgt'
126
+ padded_version_string(attribute_value) > padded_version_string(condition_value)
127
+ when '$vgte'
128
+ padded_version_string(attribute_value) >= padded_version_string(condition_value)
129
+ when '$vlt'
130
+ padded_version_string(attribute_value) < padded_version_string(condition_value)
131
+ when '$vlte'
132
+ padded_version_string(attribute_value) <= padded_version_string(condition_value)
109
133
  when '$eq'
110
- attribute_value == condition_value
134
+ begin
135
+ compare(attribute_value, condition_value).zero?
136
+ rescue StandardError
137
+ false
138
+ end
111
139
  when '$ne'
112
- attribute_value != condition_value
140
+ begin
141
+ compare(attribute_value, condition_value) != 0
142
+ rescue StandardError
143
+ false
144
+ end
113
145
  when '$lt'
114
- attribute_value < condition_value
146
+ begin
147
+ compare(attribute_value, condition_value).negative?
148
+ rescue StandardError
149
+ false
150
+ end
115
151
  when '$lte'
116
- attribute_value <= condition_value
152
+ begin
153
+ compare(attribute_value, condition_value) <= 0
154
+ rescue StandardError
155
+ false
156
+ end
117
157
  when '$gt'
118
- attribute_value > condition_value
158
+ begin
159
+ compare(attribute_value, condition_value).positive?
160
+ rescue StandardError
161
+ false
162
+ end
119
163
  when '$gte'
120
- attribute_value >= condition_value
164
+ begin
165
+ compare(attribute_value, condition_value) >= 0
166
+ rescue StandardError
167
+ false
168
+ end
121
169
  when '$regex'
122
170
  silence_warnings do
123
171
  re = Regexp.new(condition_value)
@@ -126,9 +174,13 @@ module Growthbook
126
174
  false
127
175
  end
128
176
  when '$in'
129
- condition_value.include? attribute_value
177
+ return false unless condition_value.is_a?(Array)
178
+
179
+ in?(attribute_value, condition_value)
130
180
  when '$nin'
131
- !(condition_value.include? attribute_value)
181
+ return false unless condition_value.is_a?(Array)
182
+
183
+ !in?(attribute_value, condition_value)
132
184
  when '$elemMatch'
133
185
  elem_match(condition_value, attribute_value)
134
186
  when '$size'
@@ -162,6 +214,29 @@ module Growthbook
162
214
  end
163
215
  end
164
216
 
217
+ def self.padded_version_string(input)
218
+ # Remove build info and leading `v` if any
219
+ # Split version into parts (both core version numbers and pre-release tags)
220
+ # "v1.2.3-rc.1+build123" -> ["1","2","3","rc","1"]
221
+ parts = input.gsub(/(^v|\+.*$)/, '').split(/[-.]/)
222
+
223
+ # If it's SemVer without a pre-release, add `~` to the end
224
+ # ["1","0","0"] -> ["1","0","0","~"]
225
+ # "~" is the largest ASCII character, so this will make "1.0.0" greater than "1.0.0-beta" for example
226
+ parts << '~' if parts.length == 3
227
+
228
+ # Left pad each numeric part with spaces so string comparisons will work ("9">"10", but " 9"<"10")
229
+ parts.map do |part|
230
+ /^[0-9]+$/.match?(part) ? part.rjust(5, ' ') : part
231
+ end.join('-')
232
+ end
233
+
234
+ def self.in?(actual, expected)
235
+ return expected.include?(actual) unless actual.is_a?(Array)
236
+
237
+ (actual & expected).any?
238
+ end
239
+
165
240
  # Sets $VERBOSE for the duration of the block and back to its original
166
241
  # value afterwards. Used for testing invalid regexes.
167
242
  def self.silence_warnings
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Growthbook
4
4
  # Context object passed into the GrowthBook constructor.
5
- class Context
5
+ class Context # rubocop:disable Metrics/ClassLength
6
6
  # @return [true, false] Switch to globally disable all experiments. Default true.
7
7
  attr_accessor :enabled
8
8
 
@@ -33,29 +33,49 @@ module Growthbook
33
33
  # @return [Hash[String, Any]] Forced feature values
34
34
  attr_reader :forced_features
35
35
 
36
+ # @return [Growthbook::StickyBucketService] Sticky bucket service for sticky bucketing
37
+ attr_reader :sticky_bucket_service
38
+
39
+ # @return [String] The attributes that identify users. If omitted, this will be inferred from the feature definitions
40
+ attr_reader :sticky_bucket_identifier_attributes
41
+
42
+ # @return [String] The attributes that are used to assign sticky buckets
43
+ attr_reader :sticky_bucket_assignment_docs
44
+
45
+ # @return [Boolean] If true, the context is using derived sticky bucket attributes
46
+ attr_reader :using_derived_sticky_bucket_attributes
47
+
48
+ # @return [Hash[String, String]] The attributes that are used to assign sticky buckets
49
+ attr_reader :sticky_bucket_attributes
50
+
36
51
  def initialize(options = {})
37
52
  @features = {}
53
+ @attributes = {}
38
54
  @forced_variations = {}
39
55
  @forced_features = {}
40
56
  @attributes = {}
41
57
  @enabled = true
42
58
  @impressions = {}
59
+ @sticky_bucket_assignment_docs = {}
60
+
61
+ features = {}
62
+ attributes = {}
43
63
 
44
64
  options.transform_keys(&:to_sym).each do |key, value|
45
65
  case key
46
66
  when :enabled
47
67
  @enabled = value
48
68
  when :attributes
49
- self.attributes = value
69
+ attributes = value
50
70
  when :url
51
71
  @url = value
52
72
  when :decryption_key
53
73
  nil
54
74
  when :encrypted_features
55
75
  decrypted = decrypted_features_from_options(options)
56
- self.features = decrypted unless decrypted.nil?
76
+ features = decrypted unless decrypted.nil?
57
77
  when :features
58
- self.features = value
78
+ features = value
59
79
  when :forced_variations, :forcedVariations
60
80
  self.forced_variations = value
61
81
  when :forced_features
@@ -66,10 +86,18 @@ module Growthbook
66
86
  @listener = value
67
87
  when :on_feature_usage
68
88
  @on_feature_usage = value
89
+ when :sticky_bucket_service
90
+ @sticky_bucket_service = value
91
+ when :sticky_bucket_identifier_attributes
92
+ @sticky_bucket_identifier_attributes = value
69
93
  else
70
94
  warn("Unknown context option: #{key}")
71
95
  end
72
96
  end
97
+
98
+ @using_derived_sticky_bucket_attributes = !@sticky_bucket_identifier_attributes
99
+ self.attributes = attributes
100
+ self.features = features
73
101
  end
74
102
 
75
103
  def features=(features)
@@ -83,10 +111,14 @@ module Growthbook
83
111
 
84
112
  @features[k.to_s] = v
85
113
  end
114
+
115
+ refresh_sticky_buckets
86
116
  end
87
117
 
88
118
  def attributes=(attrs)
89
119
  @attributes = stringify_keys(attrs || {})
120
+
121
+ refresh_sticky_buckets
90
122
  end
91
123
 
92
124
  def forced_variations=(forced_variations)
@@ -98,6 +130,43 @@ module Growthbook
98
130
  end
99
131
 
100
132
  def eval_feature(key)
133
+ _eval_feature(key, Set.new)
134
+ end
135
+
136
+ def run(exp)
137
+ _run(exp)
138
+ end
139
+
140
+ def on?(key)
141
+ eval_feature(key).on
142
+ end
143
+
144
+ def off?(key)
145
+ eval_feature(key).off
146
+ end
147
+
148
+ def feature_value(key, fallback = nil)
149
+ value = eval_feature(key).value
150
+ value.nil? ? fallback : value
151
+ end
152
+
153
+ private
154
+
155
+ def _eval_prereqs(parent_conditions, stack)
156
+ parent_conditions.each do |parent_condition|
157
+ parent_res = _eval_feature(parent_condition['id'], stack)
158
+
159
+ return 'cyclic' if parent_res.source == 'cyclicPrerequisite'
160
+
161
+ next if Growthbook::Conditions.eval_condition({ 'value' => parent_res.value }, parent_condition['condition'])
162
+ return 'gate' if parent_condition['gate']
163
+
164
+ return 'fail'
165
+ end
166
+ 'pass'
167
+ end
168
+
169
+ def _eval_feature(key, stack)
101
170
  # Forced in the context
102
171
  return get_feature_result(key.to_s, @forced_features[key.to_s], 'override', nil, nil) if @forced_features.key?(key.to_s)
103
172
 
@@ -105,7 +174,24 @@ module Growthbook
105
174
  feature = get_feature(key)
106
175
  return get_feature_result(key.to_s, nil, 'unknownFeature', nil, nil) unless feature
107
176
 
177
+ return get_feature_result(key.to_s, nil, 'cyclicPrerequisite', nil, nil) if stack.include?(key.to_s)
178
+
179
+ stack.add(key.to_s)
180
+
108
181
  feature.rules.each do |rule|
182
+ if rule.parent_conditions&.length&.positive?
183
+ prereq_res = _eval_prereqs(rule.parent_conditions, stack)
184
+ case prereq_res
185
+ when 'gate'
186
+ return get_feature_result(key.to_s, nil, 'prerequisite', nil, nil)
187
+ when 'cyclic'
188
+ # Warning already logged in this case
189
+ return get_feature_result(key.to_s, nil, 'cyclicPrerequisite', nil, nil)
190
+ when 'fail'
191
+ next
192
+ end
193
+ end
194
+
109
195
  # Targeting condition
110
196
  next if rule.condition && !condition_passes?(rule.condition)
111
197
 
@@ -119,6 +205,7 @@ module Growthbook
119
205
  included_in_rollout = included_in_rollout?(
120
206
  seed: seed.to_s,
121
207
  hash_attribute: hash_attribute,
208
+ fallback_attribute: rule.fallback_attribute,
122
209
  range: rule.range,
123
210
  coverage: rule.coverage,
124
211
  hash_version: rule.hash_version
@@ -144,26 +231,7 @@ module Growthbook
144
231
  get_feature_result(key.to_s, feature.default_value.nil? ? nil : feature.default_value, 'defaultValue', nil, nil)
145
232
  end
146
233
 
147
- def run(exp)
148
- _run(exp)
149
- end
150
-
151
- def on?(key)
152
- eval_feature(key).on
153
- end
154
-
155
- def off?(key)
156
- eval_feature(key).off
157
- end
158
-
159
- def feature_value(key, fallback = nil)
160
- value = eval_feature(key).value
161
- value.nil? ? fallback : value
162
- end
163
-
164
- private
165
-
166
- def _run(exp, feature_id = '')
234
+ def _run(exp, feature_id = '') # rubocop:disable Metrics/AbcSize
167
235
  key = exp.key
168
236
 
169
237
  # 1. If experiment doesn't have enough variations, return immediately
@@ -193,38 +261,70 @@ module Growthbook
193
261
  return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) unless exp.active
194
262
 
195
263
  # 6. Get hash_attribute/value and return if empty
196
- hash_attribute = exp.hash_attribute || 'id'
197
- hash_value = get_attribute(hash_attribute).to_s
264
+ hash_attribute, hash_value_raw = get_hash_attribute(exp.hash_attribute, exp.fallback_attribute)
265
+ hash_value = hash_value_raw.to_s
198
266
  return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if hash_value.empty?
199
267
 
200
- # 7. Exclude if user is filtered out (used to be called "namespace")
201
- if exp.filters
202
- return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if filtered_out?(exp.filters || [])
203
- elsif exp.namespace && !Growthbook::Util.in_namespace?(hash_value, exp.namespace)
204
- return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id)
268
+ assigned = -1
269
+
270
+ found_sticky_bucket = false
271
+ sticky_bucket_version_is_blocked = false
272
+ if sticky_bucket_service && !exp.disable_sticky_bucketing
273
+ sticky_bucket = _get_sticky_bucket_variation(
274
+ exp.key,
275
+ exp.bucket_version,
276
+ exp.min_bucket_version,
277
+ exp.meta,
278
+ exp.hash_attribute,
279
+ exp.fallback_attribute
280
+ )
281
+ found_sticky_bucket = sticky_bucket['variation'].to_i >= 0
282
+ assigned = sticky_bucket['variation'].to_i
283
+ sticky_bucket_version_is_blocked = sticky_bucket['versionIsBlocked']
205
284
  end
206
285
 
207
- # 8. Exclude if condition is false
208
- if exp.condition && !condition_passes?(exp.condition)
209
- return get_experiment_result(
210
- exp,
211
- -1,
212
- hash_used: false,
213
- feature_id: feature_id
214
- )
286
+ # Some checks are not needed if we already have a sticky bucket
287
+ unless found_sticky_bucket
288
+ # 7. Exclude if user is filtered out (used to be called "namespace")
289
+ if exp.filters
290
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if filtered_out?(exp.filters || [])
291
+ elsif exp.namespace && !Growthbook::Util.in_namespace?(hash_value, exp.namespace)
292
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id)
293
+ end
294
+
295
+ # 8. Exclude if condition is false
296
+ if exp.condition && !condition_passes?(exp.condition)
297
+ return get_experiment_result(
298
+ exp,
299
+ -1,
300
+ hash_used: false,
301
+ feature_id: feature_id
302
+ )
303
+ end
304
+
305
+ # 8.01 Exclude if parent conditions are not met
306
+ if exp.parent_conditions
307
+ prereq_res = _eval_prereqs(exp.parent_conditions, Set.new)
308
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if %w[gate fail cyclic].include?(prereq_res)
309
+ end
215
310
  end
216
311
 
217
312
  # 9. Get bucket ranges and choose variation
218
- ranges = exp.ranges || Growthbook::Util.get_bucket_ranges(
219
- exp.variations.length,
220
- exp.coverage,
221
- exp.weights
222
- )
223
313
  seed = exp.seed || key || ''
224
314
  n = Growthbook::Util.get_hash(seed: seed, value: hash_value, version: exp.hash_version || 1)
225
315
  return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if n.nil?
226
316
 
227
- assigned = Growthbook::Util.choose_variation(n, ranges)
317
+ unless found_sticky_bucket
318
+ ranges = exp.ranges || Growthbook::Util.get_bucket_ranges(
319
+ exp.variations.length,
320
+ exp.coverage,
321
+ exp.weights
322
+ )
323
+ assigned = Growthbook::Util.choose_variation(n, ranges)
324
+ end
325
+
326
+ # Unenroll if any prior sticky buckets are blocked by version
327
+ return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id, sticky_bucket_used: true) if sticky_bucket_version_is_blocked
228
328
 
229
329
  # 10. Return if not in experiment
230
330
  return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if assigned.negative?
@@ -236,7 +336,22 @@ module Growthbook
236
336
  return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if @qa_mode
237
337
 
238
338
  # 13. Build the result object
239
- result = get_experiment_result(exp, assigned, hash_used: true, feature_id: feature_id, bucket: n)
339
+ result = get_experiment_result(exp, assigned, hash_used: true, feature_id: feature_id, bucket: n, sticky_bucket_used: found_sticky_bucket)
340
+
341
+ # 13.5 Persist sticky bucket
342
+ if sticky_bucket_service && !exp.disable_sticky_bucketing
343
+ assignment = {
344
+ _get_sticky_bucket_experiment_key(exp.key, exp.bucket_version) => result.key.to_s
345
+ }
346
+
347
+ data = _generate_sticky_bucket_assignment_doc(hash_attribute, hash_value, assignment)
348
+ doc = data['doc']
349
+ if doc && data['changed']
350
+ @sticky_bucket_assignment_docs ||= {}
351
+ @sticky_bucket_assignment_docs[data['key']] = doc
352
+ sticky_bucket_service.save_assignments(doc)
353
+ end
354
+ end
240
355
 
241
356
  # 14. Fire tracking callback
242
357
  track_experiment(exp, result)
@@ -259,15 +374,14 @@ module Growthbook
259
374
  Growthbook::Conditions.eval_condition(@attributes, condition)
260
375
  end
261
376
 
262
- def get_experiment_result(experiment, variation_index = -1, hash_used: false, feature_id: '', bucket: nil)
377
+ def get_experiment_result(experiment, variation_index = -1, hash_used: false, feature_id: '', bucket: nil, sticky_bucket_used: false)
263
378
  in_experiment = true
264
379
  if variation_index.negative? || variation_index >= experiment.variations.length
265
380
  variation_index = 0
266
381
  in_experiment = false
267
382
  end
268
383
 
269
- hash_attribute = experiment.hash_attribute || 'id'
270
- hash_value = get_attribute(hash_attribute)
384
+ hash_attribute, hash_value = get_hash_attribute(experiment.hash_attribute, experiment.fallback_attribute)
271
385
  meta = experiment.meta ? experiment.meta[variation_index] : {}
272
386
 
273
387
  result = Growthbook::InlineExperimentResult.new(
@@ -281,7 +395,8 @@ module Growthbook
281
395
  hash_value: hash_value,
282
396
  feature_id: feature_id,
283
397
  bucket: bucket,
284
- name: meta['name']
398
+ name: meta['name'],
399
+ sticky_bucket_used: sticky_bucket_used
285
400
  }
286
401
  )
287
402
 
@@ -312,7 +427,23 @@ module Growthbook
312
427
  nil
313
428
  end
314
429
 
430
+ def get_hash_attribute(attr, fallback_attr)
431
+ attr ||= 'id'
432
+
433
+ val = get_attribute(attr)
434
+
435
+ # If no match, try fallback
436
+ if (val.nil? || val == '') && fallback_attr && @sticky_bucket_service
437
+ val = get_attribute(fallback_attr)
438
+ attr = fallback_attr unless val.nil? || val == ''
439
+ end
440
+
441
+ [attr, val]
442
+ end
443
+
315
444
  def get_attribute(key)
445
+ return '' if key.nil?
446
+
316
447
  return @attributes[key.to_sym] if @attributes.key?(key.to_sym)
317
448
  return @attributes[key.to_s] if @attributes.key?(key.to_s)
318
449
 
@@ -326,10 +457,12 @@ module Growthbook
326
457
  @impressions[experiment.key] = result unless experiment.key.nil?
327
458
  end
328
459
 
329
- def included_in_rollout?(seed:, hash_attribute:, hash_version:, range:, coverage:)
460
+ def included_in_rollout?(seed:, hash_attribute:, fallback_attribute:, hash_version:, range:, coverage:)
330
461
  return true if range.nil? && coverage.nil?
331
462
 
332
- hash_value = get_attribute(hash_attribute)
463
+ _, hash_value_raw = get_hash_attribute(hash_attribute, fallback_attribute)
464
+
465
+ hash_value = hash_value_raw.to_s
333
466
 
334
467
  return false if hash_value.empty?
335
468
 
@@ -344,7 +477,7 @@ module Growthbook
344
477
 
345
478
  def filtered_out?(filters)
346
479
  filters.any? do |filter|
347
- hash_value = get_attribute(filter['attribute'] || 'id')
480
+ hash_value = get_attribute(filter['attribute'] || 'id').to_s
348
481
 
349
482
  if hash_value.empty?
350
483
  false
@@ -367,5 +500,124 @@ module Growthbook
367
500
  rescue StandardError
368
501
  nil
369
502
  end
503
+
504
+ def _derive_sticky_bucket_identifier_attributes
505
+ attributes = Set.new
506
+ @features.each do |_key, feature|
507
+ feature.rules.each do |rule|
508
+ if rule.variations
509
+ attributes.add(rule.hash_attribute || 'id')
510
+ attributes.add(rule.fallback_attribute) if rule.fallback_attribute
511
+ end
512
+ end
513
+ end
514
+ attributes.to_a
515
+ end
516
+
517
+ def _get_sticky_bucket_attributes
518
+ attributes = {}
519
+ @sticky_bucket_identifier_attributes = _derive_sticky_bucket_identifier_attributes if @using_derived_sticky_bucket_attributes
520
+
521
+ return attributes unless @sticky_bucket_identifier_attributes
522
+
523
+ @sticky_bucket_identifier_attributes.each do |attr|
524
+ _, hash_value = get_hash_attribute(attr, nil)
525
+ attributes[attr] = hash_value if hash_value
526
+ end
527
+ attributes
528
+ end
529
+
530
+ def _get_sticky_bucket_assignments(attr = nil, fallback = nil)
531
+ merged = {}
532
+
533
+ _, hash_value = get_hash_attribute(attr, nil)
534
+ key = "#{attr}||#{hash_value}"
535
+ merged = @sticky_bucket_assignment_docs[key]['assignments'] if @sticky_bucket_assignment_docs.key?(key)
536
+
537
+ if fallback
538
+ _, hash_value = get_hash_attribute(fallback, nil)
539
+ key = "#{fallback}||#{hash_value}"
540
+ if @sticky_bucket_assignment_docs.key?(key)
541
+ @sticky_bucket_assignment_docs[key]['assignments'].each do |k, v|
542
+ merged[k] = v unless merged.key?(k)
543
+ end
544
+ end
545
+ end
546
+
547
+ merged
548
+ end
549
+
550
+ def _is_blocked(assignments, experiment_key, min_bucket_version)
551
+ return false if min_bucket_version.zero?
552
+
553
+ (0...min_bucket_version).each do |i|
554
+ blocked_key = _get_sticky_bucket_experiment_key(experiment_key, i)
555
+ return true if assignments.key?(blocked_key)
556
+ end
557
+ false
558
+ end
559
+
560
+ def _get_sticky_bucket_variation(experiment_key, bucket_version = nil, min_bucket_version = nil, meta = nil, hash_attribute = nil, fallback_attribute = nil)
561
+ bucket_version ||= 0
562
+ min_bucket_version ||= 0
563
+ meta ||= []
564
+
565
+ id = _get_sticky_bucket_experiment_key(experiment_key, bucket_version)
566
+
567
+ assignments = _get_sticky_bucket_assignments(hash_attribute, fallback_attribute)
568
+ if _is_blocked(assignments, experiment_key, min_bucket_version)
569
+ return {
570
+ 'variation' => -1,
571
+ 'versionIsBlocked' => true
572
+ }
573
+ end
574
+
575
+ variation_key = assignments[id]
576
+ return { 'variation' => -1 } unless variation_key
577
+
578
+ variation = meta.find_index { |v| v['key'] == variation_key } || -1
579
+ return { 'variation' => -1 } if variation.negative?
580
+
581
+ { 'variation' => variation }
582
+ end
583
+
584
+ def _get_sticky_bucket_experiment_key(experiment_key, bucket_version = 0)
585
+ "#{experiment_key}__#{bucket_version}"
586
+ end
587
+
588
+ def refresh_sticky_buckets(force: false)
589
+ return unless @sticky_bucket_service
590
+
591
+ attributes = _get_sticky_bucket_attributes
592
+ return if !force && attributes == @sticky_bucket_attributes
593
+
594
+ @sticky_bucket_attributes = attributes
595
+ @sticky_bucket_assignment_docs = @sticky_bucket_service.get_all_assignments(attributes)
596
+ end
597
+
598
+ def _generate_sticky_bucket_assignment_doc(attribute_name, attribute_value, assignments)
599
+ key = "#{attribute_name}||#{attribute_value}"
600
+ existing_assignments = @sticky_bucket_assignment_docs[key]&.fetch('assignments', {})
601
+
602
+ if existing_assignments
603
+ new_assignments = existing_assignments.merge(assignments)
604
+ existing_json = existing_assignments.to_json
605
+ new_json = new_assignments.to_json
606
+ changed = existing_json != new_json
607
+ else
608
+ changed = true
609
+ new_assignments = assignments
610
+ end
611
+
612
+ {
613
+ 'key' => key,
614
+ 'doc' => {
615
+ 'attributeName' => attribute_name,
616
+ 'attributeValue' => attribute_value,
617
+ 'assignments' => new_assignments
618
+ },
619
+ 'changed' => changed
620
+ }
621
+ end
370
622
  end
371
623
  end
@@ -54,6 +54,21 @@ module Growthbook
54
54
  # @return [TrackData[] , nil] Array of tracking calls to fire
55
55
  attr_reader :tracks
56
56
 
57
+ # @return [String, nil] The attribute to use when hash_attribute is missing (requires Sticky Bucketing)
58
+ attr_accessor :fallback_attribute
59
+
60
+ # @return [String, nil] When true, disables sticky bucketing
61
+ attr_accessor :disable_sticky_bucketing
62
+
63
+ # @return [integer] Appended to the experiment key for sticky bucketing
64
+ attr_accessor :bucket_version
65
+
66
+ # @return [integer] Minimum bucket version required for sticky bucketing
67
+ attr_accessor :min_bucket_version
68
+
69
+ # @return [Array<Hash>] Array of prerequisite flags
70
+ attr_accessor :parent_conditions
71
+
57
72
  def initialize(rule)
58
73
  @coverage = get_option(rule, :coverage)
59
74
  @force = get_option(rule, :force)
@@ -74,6 +89,16 @@ module Growthbook
74
89
 
75
90
  cond = get_option(rule, :condition)
76
91
  @condition = Growthbook::Conditions.parse_condition(cond) unless cond.nil?
92
+
93
+ @fallback_attribute = get_option(rule, :fallback_attribute) || get_option(rule, :fallbackAttribute)
94
+ @disable_sticky_bucketing = get_option(rule, :disable_sticky_bucketing, false) || get_option(rule, :disableStickyBucketing, false)
95
+ @bucket_version = get_option(rule, :bucket_version) || get_option(rule, :bucketVersion) || 0
96
+ @min_bucket_version = get_option(rule, :min_bucket_version) || get_option(rule, :minBucketVersion) || 0
97
+ @parent_conditions = get_option(rule, :parent_conditions) || get_option(rule, :parentConditions) || []
98
+
99
+ return unless @disable_sticky_bucketing
100
+
101
+ @fallback_attribute = nil
77
102
  end
78
103
 
79
104
  # @return [Growthbook::InlineExperiment, nil]
@@ -83,6 +108,7 @@ module Growthbook
83
108
  Growthbook::InlineExperiment.new(
84
109
  key: @key || feature_key,
85
110
  variations: @variations,
111
+ condition: @condition,
86
112
  coverage: @coverage,
87
113
  weights: @weights,
88
114
  hash_attribute: @hash_attribute,
@@ -93,7 +119,11 @@ module Growthbook
93
119
  filters: @filters,
94
120
  name: @name,
95
121
  phase: @phase,
96
- seed: @seed
122
+ seed: @seed,
123
+ fallback_attribute: @fallback_attribute,
124
+ disable_sticky_bucketing: @disable_sticky_bucketing,
125
+ bucket_version: @bucket_version,
126
+ min_bucket_version: @min_bucket_version
97
127
  )
98
128
  end
99
129
 
@@ -109,32 +139,37 @@ module Growthbook
109
139
 
110
140
  def to_json(*_args)
111
141
  {
112
- 'condition' => @condition,
113
- 'coverage' => @coverage,
114
- 'force' => @force,
115
- 'variations' => @variations,
116
- 'key' => @key,
117
- 'weights' => @weights,
118
- 'namespace' => @namespace,
119
- 'hashAttribute' => @hash_attribute,
120
- 'range' => @range,
121
- 'ranges' => @ranges,
122
- 'meta' => @meta,
123
- 'filters' => @filters,
124
- 'seed' => @seed,
125
- 'name' => @name,
126
- 'phase' => @phase,
127
- 'tracks' => @tracks
142
+ 'condition' => @condition,
143
+ 'coverage' => @coverage,
144
+ 'force' => @force,
145
+ 'variations' => @variations,
146
+ 'key' => @key,
147
+ 'weights' => @weights,
148
+ 'namespace' => @namespace,
149
+ 'hashAttribute' => @hash_attribute,
150
+ 'range' => @range,
151
+ 'ranges' => @ranges,
152
+ 'meta' => @meta,
153
+ 'filters' => @filters,
154
+ 'seed' => @seed,
155
+ 'name' => @name,
156
+ 'phase' => @phase,
157
+ 'tracks' => @tracks,
158
+ 'fallbackAttribute' => @fallback_attribute,
159
+ 'disableStickyBucketing' => @disable_sticky_bucketing,
160
+ 'bucketVersion' => @bucket_version,
161
+ 'minBucketVersion' => @min_bucket_version,
162
+ 'parentConditions' => @parent_conditions
128
163
  }.compact
129
164
  end
130
165
 
131
166
  private
132
167
 
133
- def get_option(hash, key)
168
+ def get_option(hash, key, default = nil)
134
169
  return hash[key.to_sym] if hash.key?(key.to_sym)
135
170
  return hash[key.to_s] if hash.key?(key.to_s)
136
171
 
137
- nil
172
+ default
138
173
  end
139
174
  end
140
175
  end
@@ -51,6 +51,21 @@ module Growthbook
51
51
  # @return [String, nil] Id of the current experiment phase
52
52
  attr_accessor :phase
53
53
 
54
+ # @return [String, nil] The attribute to use when hash_attribute is missing (requires Sticky Bucketing)
55
+ attr_accessor :fallback_attribute
56
+
57
+ # @return [bool, nil] When true, disables sticky bucketing
58
+ attr_accessor :disable_sticky_bucketing
59
+
60
+ # @return [integer] Appended to the experiment key for sticky bucketing
61
+ attr_accessor :bucket_version
62
+
63
+ # @return [integer] Minimum bucket version required for sticky bucketing
64
+ attr_accessor :min_bucket_version
65
+
66
+ # @return [Array<Hash>] Array of prerequisite flags
67
+ attr_accessor :parent_conditions
68
+
54
69
  def initialize(options = {})
55
70
  @key = get_option(options, :key, '').to_s
56
71
  @variations = get_option(options, :variations, [])
@@ -68,6 +83,15 @@ module Growthbook
68
83
  @seed = get_option(options, :seed)
69
84
  @name = get_option(options, :name)
70
85
  @phase = get_option(options, :phase)
86
+ @fallback_attribute = get_option(options, :fallback_attribute) || get_option(options, :fallbackAttribute)
87
+ @disable_sticky_bucketing = get_option(options, :disable_sticky_bucketing, false) || get_option(options, :disableStickyBucketing, false)
88
+ @bucket_version = get_option(options, :bucket_version) || get_option(options, :bucketVersion) || 0
89
+ @min_bucket_version = get_option(options, :min_bucket_version) || get_option(options, :minBucketVersion) || 0
90
+ @parent_conditions = get_option(options, :parent_conditions) || get_option(options, :parentConditions) || []
91
+
92
+ return unless @disable_sticky_bucketing
93
+
94
+ @fallback_attribute = nil
71
95
  end
72
96
 
73
97
  def to_json(*_args)
@@ -88,6 +112,12 @@ module Growthbook
88
112
  res['seed'] = @seed
89
113
  res['name'] = @name
90
114
  res['phase'] = @phase
115
+ res['fallbackAttribute'] = @fallback_attribute unless @fallback_attribute.nil?
116
+ res['disableStickyBucketing'] = @disable_sticky_bucketing if @disable_sticky_bucketing
117
+ res['bucketVersion'] = @bucket_version if @bucket_version != 0
118
+ res['minBucketVersion'] = @min_bucket_version if @min_bucket_version != 0
119
+ res['parentConditions'] = @parent_conditions unless @parent_conditions.empty?
120
+
91
121
  res.compact
92
122
  end
93
123
 
@@ -36,6 +36,9 @@ module Growthbook
36
36
  # @return [Boolean] Used for holdout groups
37
37
  attr_accessor :passthrough
38
38
 
39
+ # @return [Boolean] When true, sticky bucketing was used to assign a variation
40
+ attr_accessor :sticky_bucket_used
41
+
39
42
  def initialize(options = {})
40
43
  @key = options[:key]
41
44
  @in_experiment = options[:in_experiment]
@@ -48,6 +51,7 @@ module Growthbook
48
51
  @bucket = options[:bucket]
49
52
  @name = options[:name]
50
53
  @passthrough = options[:passthrough]
54
+ @sticky_bucket_used = options[:sticky_bucket_used]
51
55
  end
52
56
 
53
57
  # If the variation was randomly assigned based on user attribute hashes
@@ -56,6 +60,10 @@ module Growthbook
56
60
  @hash_used
57
61
  end
58
62
 
63
+ def sticky_bucket_used?
64
+ @sticky_bucket_used || false
65
+ end
66
+
59
67
  # Whether or not the user is in the experiment
60
68
  # @return [Bool]
61
69
  def in_experiment?
@@ -64,17 +72,18 @@ module Growthbook
64
72
 
65
73
  def to_json(*_args)
66
74
  {
67
- 'inExperiment' => @in_experiment,
68
- 'variationId' => @variation_id,
69
- 'value' => @value,
70
- 'hashUsed' => @hash_used,
71
- 'hashAttribute' => @hash_attribute,
72
- 'hashValue' => @hash_value,
73
- 'featureId' => @feature_id.to_s,
74
- 'key' => @key.to_s,
75
- 'bucket' => @bucket,
76
- 'name' => @name,
77
- 'passthrough' => @passthrough
75
+ 'inExperiment' => @in_experiment,
76
+ 'variationId' => @variation_id,
77
+ 'value' => @value,
78
+ 'hashUsed' => @hash_used,
79
+ 'hashAttribute' => @hash_attribute,
80
+ 'hashValue' => @hash_value,
81
+ 'featureId' => @feature_id.to_s,
82
+ 'key' => @key.to_s,
83
+ 'bucket' => @bucket,
84
+ 'name' => @name,
85
+ 'passthrough' => @passthrough,
86
+ 'stickyBucketUsed' => @sticky_bucket_used
78
87
  }.compact
79
88
  end
80
89
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Growthbook
4
+ # Extendable class that can be used as the tracking callback
5
+ class StickyBucketService
6
+ def get_assignments(_attribute_name, _attribute_value)
7
+ nil
8
+ end
9
+
10
+ def save_assignments(_doc)
11
+ nil
12
+ end
13
+
14
+ def get_key(attribute_name, attribute_value)
15
+ "#{attribute_name}||#{attribute_value}"
16
+ end
17
+
18
+ def get_all_assignments(attributes)
19
+ docs = {}
20
+ attributes.each do |attribute_name, attribute_value|
21
+ doc = get_assignments(attribute_name, attribute_value)
22
+ docs[get_key(attribute_name, attribute_value)] = doc if doc
23
+ end
24
+ docs
25
+ end
26
+ end
27
+
28
+ # Sample implementation (not meant for production use)
29
+ class InMemoryStickyBucketService < StickyBucketService
30
+ attr_accessor :assignments
31
+
32
+ def initialize
33
+ super
34
+ @assignments = {}
35
+ end
36
+
37
+ def get_assignments(attribute_name, attribute_value)
38
+ @assignments[get_key(attribute_name, attribute_value)]
39
+ end
40
+
41
+ def save_assignments(doc)
42
+ @assignments[get_key(doc['attributeName'], doc['attributeValue'])] = doc
43
+ end
44
+ end
45
+ end
data/lib/growthbook.rb CHANGED
@@ -17,3 +17,4 @@ require 'growthbook/inline_experiment_result'
17
17
  require 'growthbook/tracking_callback'
18
18
  require 'growthbook/util'
19
19
  require 'growthbook/feature_usage_callback'
20
+ require 'growthbook/sticky_bucket_service'
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.2.1
4
+ version: 1.3.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-07-13 00:00:00.000000000 Z
11
+ date: 2024-04-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -98,6 +98,7 @@ files:
98
98
  - lib/growthbook/fnv.rb
99
99
  - lib/growthbook/inline_experiment.rb
100
100
  - lib/growthbook/inline_experiment_result.rb
101
+ - lib/growthbook/sticky_bucket_service.rb
101
102
  - lib/growthbook/tracking_callback.rb
102
103
  - lib/growthbook/util.rb
103
104
  homepage: https://github.com/growthbook/growthbook-ruby