growthbook 1.2.2 → 1.3.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: 71807a4bbc1fb91e816a74e32d7d6c9e7d1dbed782b19efd3592fd5f5a944801
4
- data.tar.gz: 03fa6cf71822d7f239afda323e9a28476721d64c6234b199103869207a2cd060
3
+ metadata.gz: d672aca084d165dd7fa2a67772e6db2bb42f80876a55758fe9dc6ace227e2388
4
+ data.tar.gz: 4fd7709789e804bdd1a952ccf580e8fd34f265389725ca5d3f93e5eb4e073b32
5
5
  SHA512:
6
- metadata.gz: af78de253f4b3c2ecfbcdde348237923b3ecdd0dbe547be7d0f177bc7dff2a6299d6f6ac7786c4e4e96c2c031ff46598897ef79214efeaba837cce4db2e3739b
7
- data.tar.gz: fd4b68b94b0448dd1cd88b8aec1b96c1d76755133917c7a915a03b18e6a02cd2b2bc5d9f6735c51742d4af5a8707cec13f61ed571fd013059d45b207688a83f8
6
+ metadata.gz: 6b2a49f5ff71788343f3c8c20d8ea32197b94131e0a93ce5f6d76baa706cc1465b03c3eb1be4208aec5c71672d13442425d9623f56ba1162498613fa91c4f059
7
+ data.tar.gz: e6dab10324ad1e4673db49d5db4b1e5b7b5f97e1153a72d1b19e8607e84c068f1298499d0fd6994a33c132d750a4013d021c197d6e16a8d720c61f9bb657be10
@@ -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).to_s
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
 
@@ -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.2
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-10-30 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