split 0.4.6 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.travis.yml +11 -3
- data/CHANGELOG.mdown +22 -1
- data/CONTRIBUTING.md +10 -0
- data/LICENSE +1 -1
- data/README.mdown +235 -60
- data/lib/split.rb +8 -9
- data/lib/split/algorithms.rb +3 -0
- data/lib/split/algorithms/weighted_sample.rb +17 -0
- data/lib/split/algorithms/whiplash.rb +35 -0
- data/lib/split/alternative.rb +12 -4
- data/lib/split/configuration.rb +91 -1
- data/lib/split/dashboard/helpers.rb +3 -3
- data/lib/split/dashboard/views/_experiment.erb +1 -1
- data/lib/split/exceptions.rb +4 -0
- data/lib/split/experiment.rb +112 -24
- data/lib/split/extensions.rb +3 -0
- data/lib/split/extensions/array.rb +4 -0
- data/lib/split/extensions/string.rb +15 -0
- data/lib/split/helper.rb +87 -55
- data/lib/split/metric.rb +68 -0
- data/lib/split/persistence.rb +28 -0
- data/lib/split/persistence/cookie_adapter.rb +44 -0
- data/lib/split/persistence/session_adapter.rb +28 -0
- data/lib/split/trial.rb +43 -0
- data/lib/split/version.rb +3 -3
- data/spec/algorithms/weighted_sample_spec.rb +18 -0
- data/spec/algorithms/whiplash_spec.rb +23 -0
- data/spec/alternative_spec.rb +81 -9
- data/spec/configuration_spec.rb +61 -9
- data/spec/dashboard_helpers_spec.rb +2 -5
- data/spec/dashboard_spec.rb +0 -2
- data/spec/experiment_spec.rb +144 -74
- data/spec/helper_spec.rb +234 -29
- data/spec/metric_spec.rb +30 -0
- data/spec/persistence/cookie_adapter_spec.rb +31 -0
- data/spec/persistence/session_adapter_spec.rb +31 -0
- data/spec/persistence_spec.rb +33 -0
- data/spec/spec_helper.rb +12 -0
- data/spec/support/cookies_mock.rb +19 -0
- data/spec/trial_spec.rb +59 -0
- data/split.gemspec +7 -3
- metadata +58 -29
- data/Guardfile +0 -5
@@ -0,0 +1,15 @@
|
|
1
|
+
class String
|
2
|
+
# Constatntize is often provided by ActiveSupport, but ActiveSupport is not a dependency of Split.
|
3
|
+
unless method_defined?(:constantize)
|
4
|
+
def constantize
|
5
|
+
names = self.split('::')
|
6
|
+
names.shift if names.empty? || names.first.empty?
|
7
|
+
|
8
|
+
constant = Object
|
9
|
+
names.each do |name|
|
10
|
+
constant = constant.const_defined?(name) ? constant.const_get(name) : constant.const_missing(name)
|
11
|
+
end
|
12
|
+
constant
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
data/lib/split/helper.rb
CHANGED
@@ -1,16 +1,30 @@
|
|
1
1
|
module Split
|
2
2
|
module Helper
|
3
|
-
|
4
|
-
|
5
|
-
if RUBY_VERSION.match(/1\.8/) && alternatives.length.zero?
|
3
|
+
|
4
|
+
def ab_test(experiment_name, control=nil, *alternatives)
|
5
|
+
if RUBY_VERSION.match(/1\.8/) && alternatives.length.zero? && ! control.nil?
|
6
6
|
puts 'WARNING: You should always pass the control alternative through as the second argument with any other alternatives as the third because the order of the hash is not preserved in ruby 1.8'
|
7
7
|
end
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
8
|
+
|
9
|
+
begin
|
10
|
+
ret = if Split.configuration.enabled
|
11
|
+
load_and_start_trial(experiment_name, control, alternatives)
|
12
|
+
else
|
13
|
+
control_variable(control)
|
14
|
+
end
|
15
|
+
|
16
|
+
rescue => e
|
17
|
+
raise(e) unless Split.configuration.db_failover
|
18
|
+
Split.configuration.db_failover_on_db_error.call(e)
|
19
|
+
|
20
|
+
if Split.configuration.db_failover_allow_parameter_override && override_present?(experiment_name)
|
21
|
+
ret = override_alternative(experiment_name)
|
22
|
+
end
|
23
|
+
ensure
|
24
|
+
if ret.nil?
|
25
|
+
ret = control_variable(control)
|
26
|
+
end
|
27
|
+
end
|
14
28
|
|
15
29
|
if block_given?
|
16
30
|
if defined?(capture) # a block in a rails view
|
@@ -25,41 +39,61 @@ module Split
|
|
25
39
|
end
|
26
40
|
end
|
27
41
|
|
28
|
-
def
|
29
|
-
|
30
|
-
|
31
|
-
return if !options[:reset] && ab_user[experiment.finished_key]
|
32
|
-
|
33
|
-
if alternative_name = ab_user[experiment.key]
|
34
|
-
alternative = Split::Alternative.new(alternative_name, experiment_name)
|
35
|
-
alternative.increment_completion
|
42
|
+
def reset!(experiment)
|
43
|
+
ab_user.delete(experiment.key)
|
44
|
+
end
|
36
45
|
|
37
|
-
|
38
|
-
|
46
|
+
def finish_experiment(experiment, options = {:reset => true})
|
47
|
+
should_reset = experiment.resettable? && options[:reset]
|
48
|
+
if ab_user[experiment.finished_key] && !should_reset
|
49
|
+
return true
|
50
|
+
else
|
51
|
+
alternative_name = ab_user[experiment.key]
|
52
|
+
trial = Trial.new(:experiment => experiment, :alternative_name => alternative_name)
|
53
|
+
trial.complete!
|
54
|
+
if should_reset
|
55
|
+
reset!(experiment)
|
39
56
|
else
|
40
57
|
ab_user[experiment.finished_key] = true
|
41
58
|
end
|
42
59
|
end
|
60
|
+
end
|
61
|
+
|
62
|
+
|
63
|
+
def finished(metric_name, options = {:reset => true})
|
64
|
+
return if exclude_visitor? || Split.configuration.disabled?
|
65
|
+
experiments = Metric.possible_experiments(metric_name)
|
66
|
+
|
67
|
+
if experiments.any?
|
68
|
+
experiments.each do |experiment|
|
69
|
+
finish_experiment(experiment, options)
|
70
|
+
end
|
71
|
+
end
|
43
72
|
rescue => e
|
44
73
|
raise unless Split.configuration.db_failover
|
45
74
|
Split.configuration.db_failover_on_db_error.call(e)
|
46
75
|
end
|
47
76
|
|
48
|
-
def
|
49
|
-
|
77
|
+
def override_present?(experiment_name)
|
78
|
+
defined?(params) && params[experiment_name]
|
79
|
+
end
|
80
|
+
|
81
|
+
def override_alternative(experiment_name)
|
82
|
+
params[experiment_name] if override_present?(experiment_name)
|
50
83
|
end
|
51
84
|
|
52
85
|
def begin_experiment(experiment, alternative_name = nil)
|
53
86
|
alternative_name ||= experiment.control.name
|
54
87
|
ab_user[experiment.key] = alternative_name
|
88
|
+
alternative_name
|
55
89
|
end
|
56
90
|
|
57
91
|
def ab_user
|
58
|
-
|
92
|
+
@ab_user ||= Split::Persistence.adapter.new(self)
|
59
93
|
end
|
60
94
|
|
61
95
|
def exclude_visitor?
|
62
|
-
is_robot?
|
96
|
+
is_robot? || is_ignored_ip_address?
|
63
97
|
end
|
64
98
|
|
65
99
|
def not_allowed_to_test?(experiment_key)
|
@@ -67,7 +101,7 @@ module Split
|
|
67
101
|
end
|
68
102
|
|
69
103
|
def doing_other_tests?(experiment_key)
|
70
|
-
ab_user.keys
|
104
|
+
keys_without_experiment(ab_user.keys, experiment_key).length > 0
|
71
105
|
end
|
72
106
|
|
73
107
|
def clean_old_versions(experiment)
|
@@ -78,7 +112,8 @@ module Split
|
|
78
112
|
|
79
113
|
def old_versions(experiment)
|
80
114
|
if experiment.version > 0
|
81
|
-
ab_user.keys.select { |k| k.match(Regexp.new(experiment.name)) }
|
115
|
+
keys = ab_user.keys.select { |k| k.match(Regexp.new(experiment.name)) }
|
116
|
+
keys_without_experiment(keys, experiment.key)
|
82
117
|
else
|
83
118
|
[]
|
84
119
|
end
|
@@ -96,50 +131,47 @@ module Split
|
|
96
131
|
end
|
97
132
|
end
|
98
133
|
|
99
|
-
|
100
134
|
protected
|
101
135
|
|
102
136
|
def control_variable(control)
|
103
137
|
Hash === control ? control.keys.first : control
|
104
138
|
end
|
105
139
|
|
106
|
-
def
|
107
|
-
|
140
|
+
def load_and_start_trial(experiment_name, control, alternatives)
|
141
|
+
if control.nil? && alternatives.length.zero?
|
142
|
+
experiment = Experiment.find(experiment_name)
|
143
|
+
|
144
|
+
raise ExperimentNotFound.new("#{experiment_name} not found") if experiment.nil?
|
145
|
+
else
|
108
146
|
experiment = Split::Experiment.find_or_create(experiment_name, *([control] + alternatives))
|
109
|
-
|
110
|
-
|
147
|
+
end
|
148
|
+
|
149
|
+
start_trial( Trial.new(:experiment => experiment) )
|
150
|
+
end
|
151
|
+
|
152
|
+
def start_trial(trial)
|
153
|
+
experiment = trial.experiment
|
154
|
+
if override_present?(experiment.name)
|
155
|
+
ret = override_alternative(experiment.name)
|
156
|
+
else
|
157
|
+
clean_old_versions(experiment)
|
158
|
+
if exclude_visitor? || not_allowed_to_test?(experiment.key)
|
159
|
+
ret = experiment.control.name
|
111
160
|
else
|
112
|
-
if
|
113
|
-
ret =
|
161
|
+
if ab_user[experiment.key]
|
162
|
+
ret = ab_user[experiment.key]
|
114
163
|
else
|
115
|
-
|
116
|
-
begin_experiment(experiment
|
117
|
-
|
118
|
-
if ab_user[experiment.key]
|
119
|
-
ret = ab_user[experiment.key]
|
120
|
-
else
|
121
|
-
alternative = experiment.next_alternative
|
122
|
-
alternative.increment_participation
|
123
|
-
begin_experiment(experiment, alternative.name)
|
124
|
-
ret = alternative.name
|
125
|
-
end
|
164
|
+
trial.choose!
|
165
|
+
ret = begin_experiment(experiment, trial.alternative.name)
|
126
166
|
end
|
127
167
|
end
|
128
|
-
rescue => e
|
129
|
-
raise unless Split.configuration.db_failover
|
130
|
-
Split.configuration.db_failover_on_db_error.call(e)
|
131
|
-
if Split.configuration.db_failover_allow_parameter_override
|
132
|
-
all_alternatives = *([control] + alternatives)
|
133
|
-
alternative_names = all_alternatives.map{|a| a.is_a?(Hash) ? a.keys : a}.flatten
|
134
|
-
ret = override(experiment_name, alternative_names)
|
135
|
-
end
|
136
|
-
unless ret
|
137
|
-
ret = control_variable(control)
|
138
|
-
end
|
139
168
|
end
|
169
|
+
|
140
170
|
ret
|
141
171
|
end
|
142
172
|
|
173
|
+
def keys_without_experiment(keys, experiment_key)
|
174
|
+
keys.reject { |k| k.match(Regexp.new("^#{experiment_key}(:finished)?$")) }
|
175
|
+
end
|
143
176
|
end
|
144
|
-
|
145
177
|
end
|
data/lib/split/metric.rb
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
module Split
|
2
|
+
class Metric
|
3
|
+
attr_accessor :name
|
4
|
+
attr_accessor :experiments
|
5
|
+
|
6
|
+
def initialize(attrs = {})
|
7
|
+
attrs.each do |key,value|
|
8
|
+
if self.respond_to?("#{key}=")
|
9
|
+
self.send("#{key}=", value)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.load_from_redis(name)
|
15
|
+
metric = Split.redis.hget(:metrics, name)
|
16
|
+
if metric
|
17
|
+
experiment_names = metric.split(',')
|
18
|
+
|
19
|
+
experiments = experiment_names.collect do |experiment_name|
|
20
|
+
Split::Experiment.find(experiment_name)
|
21
|
+
end
|
22
|
+
|
23
|
+
Split::Metric.new(:name => name, :experiments => experiments)
|
24
|
+
else
|
25
|
+
nil
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.load_from_configuration(name)
|
30
|
+
metrics = Split.configuration.metrics
|
31
|
+
if metrics && metrics[name]
|
32
|
+
Split::Metric.new(:experiments => metrics[name], :name => name)
|
33
|
+
else
|
34
|
+
nil
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.find(name)
|
39
|
+
name = name.intern if name.is_a?(String)
|
40
|
+
metric = load_from_configuration(name)
|
41
|
+
metric = load_from_redis(name) if metric.nil?
|
42
|
+
metric
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.possible_experiments(metric_name)
|
46
|
+
experiments = []
|
47
|
+
metric = Split::Metric.find(metric_name)
|
48
|
+
if metric
|
49
|
+
experiments << metric.experiments
|
50
|
+
end
|
51
|
+
experiment = Split::Experiment.find(metric_name)
|
52
|
+
if experiment
|
53
|
+
experiments << experiment
|
54
|
+
end
|
55
|
+
experiments.flatten
|
56
|
+
end
|
57
|
+
|
58
|
+
def save
|
59
|
+
Split.redis.hset(:metrics, name, experiments.map(&:name).join(','))
|
60
|
+
end
|
61
|
+
|
62
|
+
def complete!
|
63
|
+
experiments.each do |experiment|
|
64
|
+
experiment.complete!
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
%w[session_adapter cookie_adapter].each do |f|
|
2
|
+
require "split/persistence/#{f}"
|
3
|
+
end
|
4
|
+
|
5
|
+
module Split
|
6
|
+
module Persistence
|
7
|
+
ADAPTERS = {
|
8
|
+
:cookie => Split::Persistence::CookieAdapter,
|
9
|
+
:session => Split::Persistence::SessionAdapter
|
10
|
+
}
|
11
|
+
|
12
|
+
def self.adapter
|
13
|
+
if persistence_config.is_a?(Symbol)
|
14
|
+
adapter_class = ADAPTERS[persistence_config]
|
15
|
+
raise Split::InvalidPersistenceAdapterError unless adapter_class
|
16
|
+
else
|
17
|
+
adapter_class = persistence_config
|
18
|
+
end
|
19
|
+
adapter_class
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def self.persistence_config
|
25
|
+
Split.configuration.persistence
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require "json"
|
2
|
+
|
3
|
+
module Split
|
4
|
+
module Persistence
|
5
|
+
class CookieAdapter
|
6
|
+
|
7
|
+
EXPIRES = Time.now + 31536000 # One year from now
|
8
|
+
|
9
|
+
def initialize(context)
|
10
|
+
@cookies = context.send(:cookies)
|
11
|
+
end
|
12
|
+
|
13
|
+
def [](key)
|
14
|
+
hash[key]
|
15
|
+
end
|
16
|
+
|
17
|
+
def []=(key, value)
|
18
|
+
set_cookie(hash.merge(key => value))
|
19
|
+
end
|
20
|
+
|
21
|
+
def delete(key)
|
22
|
+
set_cookie(hash.tap { |h| h.delete(key) })
|
23
|
+
end
|
24
|
+
|
25
|
+
def keys
|
26
|
+
hash.keys
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def set_cookie(value)
|
32
|
+
@cookies[:split] = {
|
33
|
+
:value => JSON.generate(value),
|
34
|
+
:expires => EXPIRES
|
35
|
+
}
|
36
|
+
end
|
37
|
+
|
38
|
+
def hash
|
39
|
+
@cookies[:split] ? JSON.parse(@cookies[:split]) : {}
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Split
|
2
|
+
module Persistence
|
3
|
+
class SessionAdapter
|
4
|
+
|
5
|
+
def initialize(context)
|
6
|
+
@session = context.session
|
7
|
+
@session[:split] ||= {}
|
8
|
+
end
|
9
|
+
|
10
|
+
def [](key)
|
11
|
+
@session[:split][key]
|
12
|
+
end
|
13
|
+
|
14
|
+
def []=(key, value)
|
15
|
+
@session[:split][key] = value
|
16
|
+
end
|
17
|
+
|
18
|
+
def delete(key)
|
19
|
+
@session[:split].delete(key)
|
20
|
+
end
|
21
|
+
|
22
|
+
def keys
|
23
|
+
@session[:split].keys
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/split/trial.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
module Split
|
2
|
+
class Trial
|
3
|
+
attr_accessor :experiment
|
4
|
+
attr_writer :alternative
|
5
|
+
|
6
|
+
def initialize(attrs = {})
|
7
|
+
self.experiment = attrs[:experiment] if !attrs[:experiment].nil?
|
8
|
+
self.alternative = attrs[:alternative] if !attrs[:alternative].nil?
|
9
|
+
self.alternative_name = attrs[:alternative_name] if !attrs[:alternative_name].nil?
|
10
|
+
end
|
11
|
+
|
12
|
+
def alternative
|
13
|
+
@alternative ||= if experiment.winner
|
14
|
+
experiment.winner
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def complete!
|
19
|
+
alternative.increment_completion if alternative
|
20
|
+
end
|
21
|
+
|
22
|
+
def choose!
|
23
|
+
choose
|
24
|
+
record!
|
25
|
+
end
|
26
|
+
|
27
|
+
def record!
|
28
|
+
alternative.increment_participation
|
29
|
+
end
|
30
|
+
|
31
|
+
def choose
|
32
|
+
if experiment.winner
|
33
|
+
self.alternative = experiment.winner
|
34
|
+
else
|
35
|
+
self.alternative = experiment.next_alternative
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def alternative_name=(name)
|
40
|
+
self.alternative= self.experiment.alternatives.find{|a| a.name == name }
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|