split 3.0.0 → 4.0.1
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 +5 -5
- data/.eslintrc +1 -1
- data/.github/FUNDING.yml +1 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
- data/.github/dependabot.yml +7 -0
- data/.github/workflows/ci.yml +71 -0
- data/.rspec +1 -0
- data/.rubocop.yml +71 -1044
- data/.rubocop_todo.yml +226 -0
- data/Appraisals +12 -1
- data/CHANGELOG.md +157 -0
- data/CODE_OF_CONDUCT.md +3 -3
- data/CONTRIBUTING.md +54 -5
- data/Gemfile +2 -0
- data/LICENSE +1 -1
- data/README.md +232 -121
- data/Rakefile +2 -0
- data/gemfiles/5.0.gemfile +1 -2
- data/gemfiles/{4.2.gemfile → 5.1.gemfile} +2 -2
- data/gemfiles/5.2.gemfile +9 -0
- data/gemfiles/6.0.gemfile +9 -0
- data/gemfiles/6.1.gemfile +9 -0
- data/gemfiles/7.0.gemfile +9 -0
- data/lib/split/algorithms/block_randomization.rb +2 -0
- data/lib/split/algorithms/weighted_sample.rb +2 -1
- data/lib/split/algorithms/whiplash.rb +3 -2
- data/lib/split/alternative.rb +7 -3
- data/lib/split/cache.rb +28 -0
- data/lib/split/combined_experiments_helper.rb +38 -0
- data/lib/split/configuration.rb +24 -13
- data/lib/split/dashboard/helpers.rb +3 -2
- data/lib/split/dashboard/pagination_helpers.rb +87 -0
- data/lib/split/dashboard/paginator.rb +17 -0
- data/lib/split/dashboard/public/dashboard.js +10 -0
- data/lib/split/dashboard/public/style.css +14 -0
- data/lib/split/dashboard/views/_controls.erb +13 -0
- data/lib/split/dashboard/views/index.erb +5 -1
- data/lib/split/dashboard/views/layout.erb +1 -1
- data/lib/split/dashboard.rb +21 -1
- data/lib/split/encapsulated_helper.rb +3 -2
- data/lib/split/engine.rb +7 -2
- data/lib/split/exceptions.rb +1 -0
- data/lib/split/experiment.rb +103 -69
- data/lib/split/experiment_catalog.rb +1 -3
- data/lib/split/extensions/string.rb +1 -0
- data/lib/split/goals_collection.rb +2 -0
- data/lib/split/helper.rb +42 -9
- data/lib/split/metric.rb +2 -1
- data/lib/split/persistence/cookie_adapter.rb +58 -15
- data/lib/split/persistence/dual_adapter.rb +54 -12
- data/lib/split/persistence/redis_adapter.rb +5 -0
- data/lib/split/persistence/session_adapter.rb +1 -0
- data/lib/split/persistence.rb +4 -2
- data/lib/split/redis_interface.rb +9 -30
- data/lib/split/trial.rb +25 -17
- data/lib/split/user.rb +20 -4
- data/lib/split/version.rb +2 -4
- data/lib/split/zscore.rb +1 -0
- data/lib/split.rb +17 -3
- data/spec/alternative_spec.rb +13 -1
- data/spec/cache_spec.rb +88 -0
- data/spec/combined_experiments_helper_spec.rb +57 -0
- data/spec/configuration_spec.rb +17 -15
- data/spec/dashboard/pagination_helpers_spec.rb +200 -0
- data/spec/dashboard/paginator_spec.rb +37 -0
- data/spec/dashboard_helpers_spec.rb +2 -2
- data/spec/dashboard_spec.rb +78 -17
- data/spec/encapsulated_helper_spec.rb +2 -2
- data/spec/experiment_spec.rb +117 -13
- data/spec/goals_collection_spec.rb +1 -1
- data/spec/helper_spec.rb +211 -112
- data/spec/persistence/cookie_adapter_spec.rb +90 -23
- data/spec/persistence/dual_adapter_spec.rb +160 -68
- data/spec/persistence/redis_adapter_spec.rb +9 -0
- data/spec/redis_interface_spec.rb +0 -69
- data/spec/spec_helper.rb +5 -6
- data/spec/split_spec.rb +7 -7
- data/spec/trial_spec.rb +65 -19
- data/spec/user_spec.rb +45 -3
- data/split.gemspec +20 -10
- metadata +61 -35
- data/.travis.yml +0 -16
data/lib/split/experiment.rb
CHANGED
|
@@ -1,38 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rubystats'
|
|
4
|
+
|
|
2
5
|
module Split
|
|
3
6
|
class Experiment
|
|
4
7
|
attr_accessor :name
|
|
5
|
-
attr_writer :algorithm
|
|
6
|
-
attr_accessor :resettable
|
|
7
8
|
attr_accessor :goals
|
|
8
|
-
attr_accessor :alternatives
|
|
9
9
|
attr_accessor :alternative_probabilities
|
|
10
10
|
attr_accessor :metadata
|
|
11
11
|
|
|
12
|
+
attr_reader :alternatives
|
|
13
|
+
attr_reader :resettable
|
|
14
|
+
|
|
12
15
|
DEFAULT_OPTIONS = {
|
|
13
16
|
:resettable => true
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
def self.find(name)
|
|
20
|
+
Split.cache(:experiments, name) do
|
|
21
|
+
return unless Split.redis.exists?(name)
|
|
22
|
+
Experiment.new(name).tap { |exp| exp.load_from_redis }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
16
26
|
def initialize(name, options = {})
|
|
17
27
|
options = DEFAULT_OPTIONS.merge(options)
|
|
18
28
|
|
|
19
29
|
@name = name.to_s
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if alternatives.empty? && (exp_config = Split.configuration.experiment_for(name))
|
|
24
|
-
options = {
|
|
25
|
-
alternatives: load_alternatives_from_configuration,
|
|
26
|
-
goals: Split::GoalsCollection.new(@name).load_from_configuration,
|
|
27
|
-
metadata: load_metadata_from_configuration,
|
|
28
|
-
resettable: exp_config[:resettable],
|
|
29
|
-
algorithm: exp_config[:algorithm]
|
|
30
|
-
}
|
|
31
|
-
else
|
|
32
|
-
options[:alternatives] = alternatives
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
set_alternatives_and_options(options)
|
|
31
|
+
extract_alternatives_from_options(options)
|
|
36
32
|
end
|
|
37
33
|
|
|
38
34
|
def self.finished_key(key)
|
|
@@ -40,11 +36,15 @@ module Split
|
|
|
40
36
|
end
|
|
41
37
|
|
|
42
38
|
def set_alternatives_and_options(options)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
self.
|
|
39
|
+
options_with_defaults = DEFAULT_OPTIONS.merge(
|
|
40
|
+
options.reject { |k, v| v.nil? }
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
self.alternatives = options_with_defaults[:alternatives]
|
|
44
|
+
self.goals = options_with_defaults[:goals]
|
|
45
|
+
self.resettable = options_with_defaults[:resettable]
|
|
46
|
+
self.algorithm = options_with_defaults[:algorithm]
|
|
47
|
+
self.metadata = options_with_defaults[:metadata]
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def extract_alternatives_from_options(options)
|
|
@@ -52,7 +52,7 @@ module Split
|
|
|
52
52
|
|
|
53
53
|
if alts.length == 1
|
|
54
54
|
if alts[0].is_a? Hash
|
|
55
|
-
alts = alts[0].map{|k,v| {k => v} }
|
|
55
|
+
alts = alts[0].map{|k, v| {k => v} }
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
@@ -81,14 +81,14 @@ module Split
|
|
|
81
81
|
|
|
82
82
|
if new_record?
|
|
83
83
|
start unless Split.configuration.start_manually
|
|
84
|
+
persist_experiment_configuration
|
|
84
85
|
elsif experiment_configuration_has_changed?
|
|
85
86
|
reset unless Split.configuration.reset_manually
|
|
87
|
+
persist_experiment_configuration
|
|
86
88
|
end
|
|
87
89
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
redis.hset(experiment_config_key, :resettable, resettable)
|
|
91
|
-
redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
|
|
90
|
+
redis.hmset(experiment_config_key, :resettable, resettable,
|
|
91
|
+
:algorithm, algorithm.to_s)
|
|
92
92
|
self
|
|
93
93
|
end
|
|
94
94
|
|
|
@@ -101,7 +101,7 @@ module Split
|
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
def new_record?
|
|
104
|
-
|
|
104
|
+
ExperimentCatalog.find(name).nil?
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
def ==(obj)
|
|
@@ -135,24 +135,29 @@ module Split
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
def winner
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
138
|
+
Split.cache(:experiment_winner, name) do
|
|
139
|
+
experiment_winner = redis.hget(:experiment_winner, name)
|
|
140
|
+
if experiment_winner
|
|
141
|
+
Split::Alternative.new(experiment_winner, name)
|
|
142
|
+
else
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
143
145
|
end
|
|
144
146
|
end
|
|
145
147
|
|
|
146
148
|
def has_winner?
|
|
147
|
-
|
|
149
|
+
return @has_winner if defined? @has_winner
|
|
150
|
+
@has_winner = !winner.nil?
|
|
148
151
|
end
|
|
149
152
|
|
|
150
153
|
def winner=(winner_name)
|
|
151
154
|
redis.hset(:experiment_winner, name, winner_name.to_s)
|
|
155
|
+
@has_winner = true
|
|
156
|
+
Split.configuration.on_experiment_winner_choose.call(self)
|
|
152
157
|
end
|
|
153
158
|
|
|
154
159
|
def participant_count
|
|
155
|
-
alternatives.inject(0){|sum,a| sum + a.participant_count}
|
|
160
|
+
alternatives.inject(0){|sum, a| sum + a.participant_count}
|
|
156
161
|
end
|
|
157
162
|
|
|
158
163
|
def control
|
|
@@ -161,6 +166,8 @@ module Split
|
|
|
161
166
|
|
|
162
167
|
def reset_winner
|
|
163
168
|
redis.hdel(:experiment_winner, name)
|
|
169
|
+
@has_winner = false
|
|
170
|
+
Split::Cache.clear_key(@name)
|
|
164
171
|
end
|
|
165
172
|
|
|
166
173
|
def start
|
|
@@ -168,13 +175,15 @@ module Split
|
|
|
168
175
|
end
|
|
169
176
|
|
|
170
177
|
def start_time
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
+
Split.cache(:experiment_start_times, @name) do
|
|
179
|
+
t = redis.hget(:experiment_start_times, @name)
|
|
180
|
+
if t
|
|
181
|
+
# Check if stored time is an integer
|
|
182
|
+
if t =~ /^[-+]?[0-9]+$/
|
|
183
|
+
Time.at(t.to_i)
|
|
184
|
+
else
|
|
185
|
+
Time.parse(t)
|
|
186
|
+
end
|
|
178
187
|
end
|
|
179
188
|
end
|
|
180
189
|
end
|
|
@@ -225,6 +234,7 @@ module Split
|
|
|
225
234
|
|
|
226
235
|
def reset
|
|
227
236
|
Split.configuration.on_before_experiment_reset.call(self)
|
|
237
|
+
Split::Cache.clear_key(@name)
|
|
228
238
|
alternatives.each(&:reset)
|
|
229
239
|
reset_winner
|
|
230
240
|
Split.configuration.on_experiment_reset.call(self)
|
|
@@ -238,6 +248,7 @@ module Split
|
|
|
238
248
|
end
|
|
239
249
|
reset_winner
|
|
240
250
|
redis.srem(:experiments, name)
|
|
251
|
+
remove_experiment_cohorting
|
|
241
252
|
remove_experiment_configuration
|
|
242
253
|
Split.configuration.on_experiment_delete.call(self)
|
|
243
254
|
increment_version
|
|
@@ -262,10 +273,11 @@ module Split
|
|
|
262
273
|
end
|
|
263
274
|
|
|
264
275
|
def calc_winning_alternatives
|
|
265
|
-
#
|
|
266
|
-
|
|
276
|
+
# Cache the winning alternatives so we recalculate them once per the specified interval.
|
|
277
|
+
intervals_since_epoch =
|
|
278
|
+
Time.now.utc.to_i / Split.configuration.winning_alternative_recalculation_interval
|
|
267
279
|
|
|
268
|
-
if self.calc_time !=
|
|
280
|
+
if self.calc_time != intervals_since_epoch
|
|
269
281
|
if goals.empty?
|
|
270
282
|
self.estimate_winning_alternative
|
|
271
283
|
else
|
|
@@ -274,7 +286,7 @@ module Split
|
|
|
274
286
|
end
|
|
275
287
|
end
|
|
276
288
|
|
|
277
|
-
self.calc_time =
|
|
289
|
+
self.calc_time = intervals_since_epoch
|
|
278
290
|
|
|
279
291
|
self.save
|
|
280
292
|
end
|
|
@@ -334,7 +346,7 @@ module Split
|
|
|
334
346
|
|
|
335
347
|
def find_simulated_winner(simulated_cr_hash)
|
|
336
348
|
# figure out which alternative had the highest simulated conversion rate
|
|
337
|
-
winning_pair = ["",0.0]
|
|
349
|
+
winning_pair = ["", 0.0]
|
|
338
350
|
simulated_cr_hash.each do |alternative, rate|
|
|
339
351
|
if rate > winning_pair[1]
|
|
340
352
|
winning_pair = [alternative, rate]
|
|
@@ -345,17 +357,13 @@ module Split
|
|
|
345
357
|
end
|
|
346
358
|
|
|
347
359
|
def calc_simulated_conversion_rates(beta_params)
|
|
348
|
-
# initialize a random variable (from which to simulate conversion rates ~beta-distributed)
|
|
349
|
-
rand = SimpleRandom.new
|
|
350
|
-
rand.set_seed
|
|
351
|
-
|
|
352
360
|
simulated_cr_hash = {}
|
|
353
361
|
|
|
354
362
|
# create a hash which has the conversion rate pulled from each alternative's beta distribution
|
|
355
363
|
beta_params.each do |alternative, params|
|
|
356
364
|
alpha = params[0]
|
|
357
365
|
beta = params[1]
|
|
358
|
-
simulated_conversion_rate =
|
|
366
|
+
simulated_conversion_rate = Rubystats::BetaDistribution.new(alpha, beta).rng
|
|
359
367
|
simulated_cr_hash[alternative] = simulated_conversion_rate
|
|
360
368
|
end
|
|
361
369
|
|
|
@@ -393,6 +401,23 @@ module Split
|
|
|
393
401
|
js_id.gsub('/', '--')
|
|
394
402
|
end
|
|
395
403
|
|
|
404
|
+
def cohorting_disabled?
|
|
405
|
+
@cohorting_disabled ||= begin
|
|
406
|
+
value = redis.hget(experiment_config_key, :cohorting)
|
|
407
|
+
value.nil? ? false : value.downcase == "true"
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def disable_cohorting
|
|
412
|
+
@cohorting_disabled = true
|
|
413
|
+
redis.hset(experiment_config_key, :cohorting, true)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def enable_cohorting
|
|
417
|
+
@cohorting_disabled = false
|
|
418
|
+
redis.hset(experiment_config_key, :cohorting, false)
|
|
419
|
+
end
|
|
420
|
+
|
|
396
421
|
protected
|
|
397
422
|
|
|
398
423
|
def experiment_config_key
|
|
@@ -419,14 +444,14 @@ module Split
|
|
|
419
444
|
end
|
|
420
445
|
|
|
421
446
|
def load_alternatives_from_redis
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
447
|
+
alternatives = redis.lrange(@name, 0, -1)
|
|
448
|
+
alternatives.map do |alt|
|
|
449
|
+
alt = begin
|
|
450
|
+
JSON.parse(alt)
|
|
451
|
+
rescue
|
|
452
|
+
alt
|
|
453
|
+
end
|
|
454
|
+
Split::Alternative.new(alt, @name)
|
|
430
455
|
end
|
|
431
456
|
end
|
|
432
457
|
|
|
@@ -442,9 +467,14 @@ module Split
|
|
|
442
467
|
|
|
443
468
|
def persist_experiment_configuration
|
|
444
469
|
redis_interface.add_to_set(:experiments, name)
|
|
445
|
-
redis_interface.persist_list(name, @alternatives.map
|
|
470
|
+
redis_interface.persist_list(name, @alternatives.map{|alt| {alt.name => alt.weight}.to_json})
|
|
446
471
|
goals_collection.save
|
|
447
|
-
|
|
472
|
+
|
|
473
|
+
if @metadata
|
|
474
|
+
redis.set(metadata_key, @metadata.to_json)
|
|
475
|
+
else
|
|
476
|
+
delete_metadata
|
|
477
|
+
end
|
|
448
478
|
end
|
|
449
479
|
|
|
450
480
|
def remove_experiment_configuration
|
|
@@ -455,16 +485,20 @@ module Split
|
|
|
455
485
|
end
|
|
456
486
|
|
|
457
487
|
def experiment_configuration_has_changed?
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
existing_metadata != @metadata
|
|
488
|
+
existing_experiment = Experiment.find(@name)
|
|
489
|
+
|
|
490
|
+
existing_experiment.alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
|
|
491
|
+
existing_experiment.goals != @goals ||
|
|
492
|
+
existing_experiment.metadata != @metadata
|
|
464
493
|
end
|
|
465
494
|
|
|
466
495
|
def goals_collection
|
|
467
496
|
Split::GoalsCollection.new(@name, @goals)
|
|
468
497
|
end
|
|
498
|
+
|
|
499
|
+
def remove_experiment_cohorting
|
|
500
|
+
@cohorting_disabled = false
|
|
501
|
+
redis.hdel(experiment_config_key, :cohorting)
|
|
502
|
+
end
|
|
469
503
|
end
|
|
470
504
|
end
|
|
@@ -13,8 +13,7 @@ module Split
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def self.find(name)
|
|
16
|
-
|
|
17
|
-
Experiment.new(name).tap { |exp| exp.load_from_redis }
|
|
16
|
+
Experiment.find(name)
|
|
18
17
|
end
|
|
19
18
|
|
|
20
19
|
def self.find_or_initialize(metric_descriptor, control = nil, *alternatives)
|
|
@@ -46,6 +45,5 @@ module Split
|
|
|
46
45
|
return experiment_name, goals
|
|
47
46
|
end
|
|
48
47
|
private_class_method :normalize_experiment
|
|
49
|
-
|
|
50
48
|
end
|
|
51
49
|
end
|
data/lib/split/helper.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Split
|
|
3
4
|
module Helper
|
|
4
5
|
OVERRIDE_PARAM_NAME = "ab_test"
|
|
@@ -8,8 +9,9 @@ module Split
|
|
|
8
9
|
def ab_test(metric_descriptor, control = nil, *alternatives)
|
|
9
10
|
begin
|
|
10
11
|
experiment = ExperimentCatalog.find_or_initialize(metric_descriptor, control, *alternatives)
|
|
11
|
-
alternative = if Split.configuration.enabled
|
|
12
|
+
alternative = if Split.configuration.enabled && !exclude_visitor?
|
|
12
13
|
experiment.save
|
|
14
|
+
raise(Split::InvalidExperimentsFormatError) unless (Split.configuration.experiments || {}).fetch(experiment.name.to_sym, {})[:combined_experiments].nil?
|
|
13
15
|
trial = Trial.new(:user => ab_user, :experiment => experiment,
|
|
14
16
|
:override => override_alternative(experiment.name), :exclude => exclude_visitor?,
|
|
15
17
|
:disabled => split_generically_disabled?)
|
|
@@ -31,8 +33,8 @@ module Split
|
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
if block_given?
|
|
34
|
-
metadata =
|
|
35
|
-
yield(alternative, metadata)
|
|
36
|
+
metadata = experiment.metadata[alternative] if experiment.metadata
|
|
37
|
+
yield(alternative, metadata || {})
|
|
36
38
|
else
|
|
37
39
|
alternative
|
|
38
40
|
end
|
|
@@ -43,15 +45,21 @@ module Split
|
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
def finish_experiment(experiment, options = {:reset => true})
|
|
48
|
+
return false if active_experiments[experiment.name].nil?
|
|
46
49
|
return true if experiment.has_winner?
|
|
47
50
|
should_reset = experiment.resettable? && options[:reset]
|
|
48
51
|
if ab_user[experiment.finished_key] && !should_reset
|
|
49
52
|
return true
|
|
50
53
|
else
|
|
51
54
|
alternative_name = ab_user[experiment.key]
|
|
52
|
-
trial = Trial.new(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
trial = Trial.new(
|
|
56
|
+
:user => ab_user,
|
|
57
|
+
:experiment => experiment,
|
|
58
|
+
:alternative => alternative_name,
|
|
59
|
+
:goals => options[:goals],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
trial.complete!(self)
|
|
55
63
|
|
|
56
64
|
if should_reset
|
|
57
65
|
reset!(experiment)
|
|
@@ -68,6 +76,7 @@ module Split
|
|
|
68
76
|
|
|
69
77
|
if experiments.any?
|
|
70
78
|
experiments.each do |experiment|
|
|
79
|
+
next if override_present?(experiment.key)
|
|
71
80
|
finish_experiment(experiment, options.merge(:goals => goals))
|
|
72
81
|
end
|
|
73
82
|
end
|
|
@@ -78,7 +87,7 @@ module Split
|
|
|
78
87
|
|
|
79
88
|
def ab_record_extra_info(metric_descriptor, key, value = 1)
|
|
80
89
|
return if exclude_visitor? || Split.configuration.disabled?
|
|
81
|
-
metric_descriptor,
|
|
90
|
+
metric_descriptor, _ = normalize_metric(metric_descriptor)
|
|
82
91
|
experiments = Metric.possible_experiments(metric_descriptor)
|
|
83
92
|
|
|
84
93
|
if experiments.any?
|
|
@@ -96,14 +105,34 @@ module Split
|
|
|
96
105
|
Split.configuration.db_failover_on_db_error.call(e)
|
|
97
106
|
end
|
|
98
107
|
|
|
108
|
+
def ab_active_experiments()
|
|
109
|
+
ab_user.active_experiments
|
|
110
|
+
rescue => e
|
|
111
|
+
raise unless Split.configuration.db_failover
|
|
112
|
+
Split.configuration.db_failover_on_db_error.call(e)
|
|
113
|
+
end
|
|
114
|
+
|
|
99
115
|
def override_present?(experiment_name)
|
|
100
|
-
|
|
116
|
+
override_alternative_by_params(experiment_name) || override_alternative_by_cookies(experiment_name)
|
|
101
117
|
end
|
|
102
118
|
|
|
103
119
|
def override_alternative(experiment_name)
|
|
120
|
+
override_alternative_by_params(experiment_name) || override_alternative_by_cookies(experiment_name)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def override_alternative_by_params(experiment_name)
|
|
104
124
|
defined?(params) && params[OVERRIDE_PARAM_NAME] && params[OVERRIDE_PARAM_NAME][experiment_name]
|
|
105
125
|
end
|
|
106
126
|
|
|
127
|
+
def override_alternative_by_cookies(experiment_name)
|
|
128
|
+
return unless defined?(request)
|
|
129
|
+
|
|
130
|
+
if request.cookies && request.cookies.key?('split_override')
|
|
131
|
+
experiments = JSON.parse(request.cookies['split_override']) rescue {}
|
|
132
|
+
experiments[experiment_name]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
107
136
|
def split_generically_disabled?
|
|
108
137
|
defined?(params) && params['SPLIT_DISABLE']
|
|
109
138
|
end
|
|
@@ -113,13 +142,17 @@ module Split
|
|
|
113
142
|
end
|
|
114
143
|
|
|
115
144
|
def exclude_visitor?
|
|
116
|
-
|
|
145
|
+
defined?(request) && (instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?)
|
|
117
146
|
end
|
|
118
147
|
|
|
119
148
|
def is_robot?
|
|
120
149
|
defined?(request) && request.user_agent =~ Split.configuration.robot_regex
|
|
121
150
|
end
|
|
122
151
|
|
|
152
|
+
def is_preview?
|
|
153
|
+
defined?(request) && defined?(request.headers) && request.headers['x-purpose'] == 'preview'
|
|
154
|
+
end
|
|
155
|
+
|
|
123
156
|
def is_ignored_ip_address?
|
|
124
157
|
return false if Split.configuration.ignore_ip_addresses.empty?
|
|
125
158
|
|
data/lib/split/metric.rb
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Split
|
|
3
4
|
class Metric
|
|
4
5
|
attr_accessor :name
|
|
5
6
|
attr_accessor :experiments
|
|
6
7
|
|
|
7
8
|
def initialize(attrs = {})
|
|
8
|
-
attrs.each do |key,value|
|
|
9
|
+
attrs.each do |key, value|
|
|
9
10
|
if self.respond_to?("#{key}=")
|
|
10
11
|
self.send("#{key}=", value)
|
|
11
12
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require "json"
|
|
3
4
|
|
|
4
5
|
module Split
|
|
@@ -6,20 +7,22 @@ module Split
|
|
|
6
7
|
class CookieAdapter
|
|
7
8
|
|
|
8
9
|
def initialize(context)
|
|
9
|
-
@
|
|
10
|
+
@context = context
|
|
11
|
+
@request, @response = context.request, context.response
|
|
12
|
+
@cookies = @request.cookies
|
|
10
13
|
@expires = Time.now + cookie_length_config
|
|
11
14
|
end
|
|
12
15
|
|
|
13
16
|
def [](key)
|
|
14
|
-
hash[key]
|
|
17
|
+
hash[key.to_s]
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
def []=(key, value)
|
|
18
|
-
set_cookie(hash.merge(key => value))
|
|
21
|
+
set_cookie(hash.merge!(key.to_s => value))
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
def delete(key)
|
|
22
|
-
set_cookie(hash.tap { |h| h.delete(key) })
|
|
25
|
+
set_cookie(hash.tap { |h| h.delete(key.to_s) })
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def keys
|
|
@@ -28,22 +31,55 @@ module Split
|
|
|
28
31
|
|
|
29
32
|
private
|
|
30
33
|
|
|
31
|
-
def set_cookie(value)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
def set_cookie(value = {})
|
|
35
|
+
cookie_key = :split.to_s
|
|
36
|
+
cookie_value = default_options.merge(value: JSON.generate(value))
|
|
37
|
+
if action_dispatch?
|
|
38
|
+
# The "send" is necessary when we call ab_test from the controller
|
|
39
|
+
# and thus @context is a rails controller, because then "cookies" is
|
|
40
|
+
# a private method.
|
|
41
|
+
@context.send(:cookies)[cookie_key] = cookie_value
|
|
42
|
+
else
|
|
43
|
+
set_cookie_via_rack(cookie_key, cookie_value)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def default_options
|
|
48
|
+
{ expires: @expires, path: '/', domain: cookie_domain_config }.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def set_cookie_via_rack(key, value)
|
|
52
|
+
delete_cookie_header!(@response.header, key, value)
|
|
53
|
+
Rack::Utils.set_cookie_header!(@response.header, key, value)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Use Rack::Utils#make_delete_cookie_header after Rack 2.0.0
|
|
57
|
+
def delete_cookie_header!(header, key, value)
|
|
58
|
+
cookie_header = header['Set-Cookie']
|
|
59
|
+
case cookie_header
|
|
60
|
+
when nil, ''
|
|
61
|
+
cookies = []
|
|
62
|
+
when String
|
|
63
|
+
cookies = cookie_header.split("\n")
|
|
64
|
+
when Array
|
|
65
|
+
cookies = cookie_header
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
cookies.reject! { |cookie| cookie =~ /\A#{Rack::Utils.escape(key)}=/ }
|
|
69
|
+
header['Set-Cookie'] = cookies.join("\n")
|
|
36
70
|
end
|
|
37
71
|
|
|
38
72
|
def hash
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
73
|
+
@hash ||= begin
|
|
74
|
+
if cookies = @cookies[:split.to_s]
|
|
75
|
+
begin
|
|
76
|
+
JSON.parse(cookies)
|
|
77
|
+
rescue JSON::ParserError
|
|
78
|
+
{}
|
|
79
|
+
end
|
|
80
|
+
else
|
|
43
81
|
{}
|
|
44
82
|
end
|
|
45
|
-
else
|
|
46
|
-
{}
|
|
47
83
|
end
|
|
48
84
|
end
|
|
49
85
|
|
|
@@ -51,6 +87,13 @@ module Split
|
|
|
51
87
|
Split.configuration.persistence_cookie_length
|
|
52
88
|
end
|
|
53
89
|
|
|
90
|
+
def cookie_domain_config
|
|
91
|
+
Split.configuration.persistence_cookie_domain
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def action_dispatch?
|
|
95
|
+
defined?(Rails) && @response.is_a?(ActionDispatch::Response)
|
|
96
|
+
end
|
|
54
97
|
end
|
|
55
98
|
end
|
|
56
99
|
end
|