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 +4 -4
- data/lib/growthbook/context.rb +304 -52
- data/lib/growthbook/feature_rule.rb +54 -19
- data/lib/growthbook/inline_experiment.rb +30 -0
- data/lib/growthbook/inline_experiment_result.rb +20 -11
- data/lib/growthbook/sticky_bucket_service.rb +45 -0
- data/lib/growthbook.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d672aca084d165dd7fa2a67772e6db2bb42f80876a55758fe9dc6ace227e2388
|
4
|
+
data.tar.gz: 4fd7709789e804bdd1a952ccf580e8fd34f265389725ca5d3f93e5eb4e073b32
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6b2a49f5ff71788343f3c8c20d8ea32197b94131e0a93ce5f6d76baa706cc1465b03c3eb1be4208aec5c71672d13442425d9623f56ba1162498613fa91c4f059
|
7
|
+
data.tar.gz: e6dab10324ad1e4673db49d5db4b1e5b7b5f97e1153a72d1b19e8607e84c068f1298499d0fd6994a33c132d750a4013d021c197d6e16a8d720c61f9bb657be10
|
data/lib/growthbook/context.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
76
|
+
features = decrypted unless decrypted.nil?
|
57
77
|
when :features
|
58
|
-
|
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
|
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
|
197
|
-
hash_value =
|
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
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
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
|
-
#
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
-1,
|
212
|
-
|
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
|
-
|
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
|
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
|
-
|
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'
|
113
|
-
'coverage'
|
114
|
-
'force'
|
115
|
-
'variations'
|
116
|
-
'key'
|
117
|
-
'weights'
|
118
|
-
'namespace'
|
119
|
-
'hashAttribute'
|
120
|
-
'range'
|
121
|
-
'ranges'
|
122
|
-
'meta'
|
123
|
-
'filters'
|
124
|
-
'seed'
|
125
|
-
'name'
|
126
|
-
'phase'
|
127
|
-
'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
|
-
|
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'
|
68
|
-
'variationId'
|
69
|
-
'value'
|
70
|
-
'hashUsed'
|
71
|
-
'hashAttribute'
|
72
|
-
'hashValue'
|
73
|
-
'featureId'
|
74
|
-
'key'
|
75
|
-
'bucket'
|
76
|
-
'name'
|
77
|
-
'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
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.
|
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:
|
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
|