split 3.3.0 → 4.0.0.pre
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.eslintrc +1 -1
- data/.github/FUNDING.yml +1 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
- data/.rspec +1 -0
- data/.rubocop.yml +71 -1044
- data/.rubocop_todo.yml +226 -0
- data/.travis.yml +18 -39
- data/Appraisals +4 -0
- data/CHANGELOG.md +110 -0
- data/CODE_OF_CONDUCT.md +3 -3
- data/Gemfile +2 -0
- data/README.md +58 -23
- data/Rakefile +2 -0
- data/gemfiles/{4.2.gemfile → 6.0.gemfile} +1 -1
- data/lib/split.rb +16 -3
- 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 +4 -3
- data/lib/split/cache.rb +28 -0
- data/lib/split/combined_experiments_helper.rb +3 -2
- data/lib/split/configuration.rb +15 -14
- data/lib/split/dashboard.rb +19 -1
- data/lib/split/dashboard/helpers.rb +3 -2
- data/lib/split/dashboard/pagination_helpers.rb +4 -4
- data/lib/split/dashboard/paginator.rb +1 -0
- data/lib/split/dashboard/public/dashboard.js +10 -0
- data/lib/split/dashboard/public/style.css +5 -0
- data/lib/split/dashboard/views/_controls.erb +13 -0
- data/lib/split/dashboard/views/layout.erb +1 -1
- data/lib/split/encapsulated_helper.rb +3 -2
- data/lib/split/engine.rb +7 -4
- data/lib/split/exceptions.rb +1 -0
- data/lib/split/experiment.rb +98 -65
- 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 +30 -10
- data/lib/split/metric.rb +2 -1
- data/lib/split/persistence.rb +4 -2
- data/lib/split/persistence/cookie_adapter.rb +1 -0
- 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/redis_interface.rb +9 -28
- data/lib/split/trial.rb +25 -17
- data/lib/split/user.rb +19 -3
- data/lib/split/version.rb +2 -4
- data/lib/split/zscore.rb +1 -0
- data/spec/alternative_spec.rb +1 -1
- data/spec/cache_spec.rb +88 -0
- data/spec/configuration_spec.rb +1 -14
- data/spec/dashboard/pagination_helpers_spec.rb +3 -1
- 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 +116 -12
- data/spec/goals_collection_spec.rb +1 -1
- data/spec/helper_spec.rb +191 -112
- data/spec/persistence/cookie_adapter_spec.rb +1 -1
- 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/trial_spec.rb +65 -19
- data/spec/user_spec.rb +28 -0
- data/split.gemspec +9 -9
- metadata +34 -28
data/lib/split/engine.rb
CHANGED
@@ -1,12 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
module Split
|
3
4
|
class Engine < ::Rails::Engine
|
4
5
|
initializer "split" do |app|
|
5
6
|
if Split.configuration.include_rails_helper
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
7
|
+
ActiveSupport.on_load(:action_controller) do
|
8
|
+
::ActionController::Base.send :include, Split::Helper
|
9
|
+
::ActionController::Base.helper Split::Helper
|
10
|
+
::ActionController::Base.send :include, Split::CombinedExperimentsHelper
|
11
|
+
::ActionController::Base.helper Split::CombinedExperimentsHelper
|
12
|
+
end
|
10
13
|
end
|
11
14
|
end
|
12
15
|
end
|
data/lib/split/exceptions.rb
CHANGED
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.hset(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
|
@@ -335,7 +346,7 @@ module Split
|
|
335
346
|
|
336
347
|
def find_simulated_winner(simulated_cr_hash)
|
337
348
|
# figure out which alternative had the highest simulated conversion rate
|
338
|
-
winning_pair = ["",0.0]
|
349
|
+
winning_pair = ["", 0.0]
|
339
350
|
simulated_cr_hash.each do |alternative, rate|
|
340
351
|
if rate > winning_pair[1]
|
341
352
|
winning_pair = [alternative, rate]
|
@@ -346,17 +357,13 @@ module Split
|
|
346
357
|
end
|
347
358
|
|
348
359
|
def calc_simulated_conversion_rates(beta_params)
|
349
|
-
# initialize a random variable (from which to simulate conversion rates ~beta-distributed)
|
350
|
-
rand = SimpleRandom.new
|
351
|
-
rand.set_seed
|
352
|
-
|
353
360
|
simulated_cr_hash = {}
|
354
361
|
|
355
362
|
# create a hash which has the conversion rate pulled from each alternative's beta distribution
|
356
363
|
beta_params.each do |alternative, params|
|
357
364
|
alpha = params[0]
|
358
365
|
beta = params[1]
|
359
|
-
simulated_conversion_rate =
|
366
|
+
simulated_conversion_rate = Rubystats::BetaDistribution.new(alpha, beta).rng
|
360
367
|
simulated_cr_hash[alternative] = simulated_conversion_rate
|
361
368
|
end
|
362
369
|
|
@@ -394,6 +401,23 @@ module Split
|
|
394
401
|
js_id.gsub('/', '--')
|
395
402
|
end
|
396
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
|
+
|
397
421
|
protected
|
398
422
|
|
399
423
|
def experiment_config_key
|
@@ -420,14 +444,14 @@ module Split
|
|
420
444
|
end
|
421
445
|
|
422
446
|
def load_alternatives_from_redis
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
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)
|
431
455
|
end
|
432
456
|
end
|
433
457
|
|
@@ -443,9 +467,14 @@ module Split
|
|
443
467
|
|
444
468
|
def persist_experiment_configuration
|
445
469
|
redis_interface.add_to_set(:experiments, name)
|
446
|
-
redis_interface.persist_list(name, @alternatives.map
|
470
|
+
redis_interface.persist_list(name, @alternatives.map{|alt| {alt.name => alt.weight}.to_json})
|
447
471
|
goals_collection.save
|
448
|
-
|
472
|
+
|
473
|
+
if @metadata
|
474
|
+
redis.set(metadata_key, @metadata.to_json)
|
475
|
+
else
|
476
|
+
delete_metadata
|
477
|
+
end
|
449
478
|
end
|
450
479
|
|
451
480
|
def remove_experiment_configuration
|
@@ -456,16 +485,20 @@ module Split
|
|
456
485
|
end
|
457
486
|
|
458
487
|
def experiment_configuration_has_changed?
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
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
|
465
493
|
end
|
466
494
|
|
467
495
|
def goals_collection
|
468
496
|
Split::GoalsCollection.new(@name, @goals)
|
469
497
|
end
|
498
|
+
|
499
|
+
def remove_experiment_cohorting
|
500
|
+
@cohorting_disabled = false
|
501
|
+
redis.hdel(experiment_config_key, :cohorting)
|
502
|
+
end
|
470
503
|
end
|
471
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,7 +9,7 @@ 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
|
13
14
|
raise(Split::InvalidExperimentsFormatError) unless (Split.configuration.experiments || {}).fetch(experiment.name.to_sym, {})[:combined_experiments].nil?
|
14
15
|
trial = Trial.new(:user => ab_user, :experiment => experiment,
|
@@ -32,8 +33,8 @@ module Split
|
|
32
33
|
end
|
33
34
|
|
34
35
|
if block_given?
|
35
|
-
metadata =
|
36
|
-
yield(alternative, metadata)
|
36
|
+
metadata = experiment.metadata[alternative] if experiment.metadata
|
37
|
+
yield(alternative, metadata || {})
|
37
38
|
else
|
38
39
|
alternative
|
39
40
|
end
|
@@ -44,15 +45,21 @@ module Split
|
|
44
45
|
end
|
45
46
|
|
46
47
|
def finish_experiment(experiment, options = {:reset => true})
|
48
|
+
return false if active_experiments[experiment.name].nil?
|
47
49
|
return true if experiment.has_winner?
|
48
50
|
should_reset = experiment.resettable? && options[:reset]
|
49
51
|
if ab_user[experiment.finished_key] && !should_reset
|
50
52
|
return true
|
51
53
|
else
|
52
54
|
alternative_name = ab_user[experiment.key]
|
53
|
-
trial = Trial.new(
|
54
|
-
|
55
|
-
|
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)
|
56
63
|
|
57
64
|
if should_reset
|
58
65
|
reset!(experiment)
|
@@ -69,6 +76,7 @@ module Split
|
|
69
76
|
|
70
77
|
if experiments.any?
|
71
78
|
experiments.each do |experiment|
|
79
|
+
next if override_present?(experiment.key)
|
72
80
|
finish_experiment(experiment, options.merge(:goals => goals))
|
73
81
|
end
|
74
82
|
end
|
@@ -79,7 +87,7 @@ module Split
|
|
79
87
|
|
80
88
|
def ab_record_extra_info(metric_descriptor, key, value = 1)
|
81
89
|
return if exclude_visitor? || Split.configuration.disabled?
|
82
|
-
metric_descriptor,
|
90
|
+
metric_descriptor, _ = normalize_metric(metric_descriptor)
|
83
91
|
experiments = Metric.possible_experiments(metric_descriptor)
|
84
92
|
|
85
93
|
if experiments.any?
|
@@ -104,15 +112,27 @@ module Split
|
|
104
112
|
Split.configuration.db_failover_on_db_error.call(e)
|
105
113
|
end
|
106
114
|
|
107
|
-
|
108
115
|
def override_present?(experiment_name)
|
109
|
-
|
116
|
+
override_alternative_by_params(experiment_name) || override_alternative_by_cookies(experiment_name)
|
110
117
|
end
|
111
118
|
|
112
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)
|
113
124
|
defined?(params) && params[OVERRIDE_PARAM_NAME] && params[OVERRIDE_PARAM_NAME][experiment_name]
|
114
125
|
end
|
115
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
|
+
|
116
136
|
def split_generically_disabled?
|
117
137
|
defined?(params) && params['SPLIT_DISABLE']
|
118
138
|
end
|
@@ -122,7 +142,7 @@ module Split
|
|
122
142
|
end
|
123
143
|
|
124
144
|
def exclude_visitor?
|
125
|
-
instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?
|
145
|
+
defined?(request) && (instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?)
|
126
146
|
end
|
127
147
|
|
128
148
|
def is_robot?
|