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 +4 -4
- data/lib/growthbook/conditions.rb +83 -8
- data/lib/growthbook/context.rb +305 -53
- 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
|
@@ -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
|
-
|
134
|
+
begin
|
135
|
+
compare(attribute_value, condition_value).zero?
|
136
|
+
rescue StandardError
|
137
|
+
false
|
138
|
+
end
|
111
139
|
when '$ne'
|
112
|
-
|
140
|
+
begin
|
141
|
+
compare(attribute_value, condition_value) != 0
|
142
|
+
rescue StandardError
|
143
|
+
false
|
144
|
+
end
|
113
145
|
when '$lt'
|
114
|
-
|
146
|
+
begin
|
147
|
+
compare(attribute_value, condition_value).negative?
|
148
|
+
rescue StandardError
|
149
|
+
false
|
150
|
+
end
|
115
151
|
when '$lte'
|
116
|
-
|
152
|
+
begin
|
153
|
+
compare(attribute_value, condition_value) <= 0
|
154
|
+
rescue StandardError
|
155
|
+
false
|
156
|
+
end
|
117
157
|
when '$gt'
|
118
|
-
|
158
|
+
begin
|
159
|
+
compare(attribute_value, condition_value).positive?
|
160
|
+
rescue StandardError
|
161
|
+
false
|
162
|
+
end
|
119
163
|
when '$gte'
|
120
|
-
|
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.
|
177
|
+
return false unless condition_value.is_a?(Array)
|
178
|
+
|
179
|
+
in?(attribute_value, condition_value)
|
130
180
|
when '$nin'
|
131
|
-
|
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
|
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
|
|
@@ -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'
|
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
|