split 0.4.6 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. data/.travis.yml +11 -3
  2. data/CHANGELOG.mdown +22 -1
  3. data/CONTRIBUTING.md +10 -0
  4. data/LICENSE +1 -1
  5. data/README.mdown +235 -60
  6. data/lib/split.rb +8 -9
  7. data/lib/split/algorithms.rb +3 -0
  8. data/lib/split/algorithms/weighted_sample.rb +17 -0
  9. data/lib/split/algorithms/whiplash.rb +35 -0
  10. data/lib/split/alternative.rb +12 -4
  11. data/lib/split/configuration.rb +91 -1
  12. data/lib/split/dashboard/helpers.rb +3 -3
  13. data/lib/split/dashboard/views/_experiment.erb +1 -1
  14. data/lib/split/exceptions.rb +4 -0
  15. data/lib/split/experiment.rb +112 -24
  16. data/lib/split/extensions.rb +3 -0
  17. data/lib/split/extensions/array.rb +4 -0
  18. data/lib/split/extensions/string.rb +15 -0
  19. data/lib/split/helper.rb +87 -55
  20. data/lib/split/metric.rb +68 -0
  21. data/lib/split/persistence.rb +28 -0
  22. data/lib/split/persistence/cookie_adapter.rb +44 -0
  23. data/lib/split/persistence/session_adapter.rb +28 -0
  24. data/lib/split/trial.rb +43 -0
  25. data/lib/split/version.rb +3 -3
  26. data/spec/algorithms/weighted_sample_spec.rb +18 -0
  27. data/spec/algorithms/whiplash_spec.rb +23 -0
  28. data/spec/alternative_spec.rb +81 -9
  29. data/spec/configuration_spec.rb +61 -9
  30. data/spec/dashboard_helpers_spec.rb +2 -5
  31. data/spec/dashboard_spec.rb +0 -2
  32. data/spec/experiment_spec.rb +144 -74
  33. data/spec/helper_spec.rb +234 -29
  34. data/spec/metric_spec.rb +30 -0
  35. data/spec/persistence/cookie_adapter_spec.rb +31 -0
  36. data/spec/persistence/session_adapter_spec.rb +31 -0
  37. data/spec/persistence_spec.rb +33 -0
  38. data/spec/spec_helper.rb +12 -0
  39. data/spec/support/cookies_mock.rb +19 -0
  40. data/spec/trial_spec.rb +59 -0
  41. data/split.gemspec +7 -3
  42. metadata +58 -29
  43. data/Guardfile +0 -5
@@ -0,0 +1,3 @@
1
+ %w[array string].each do |f|
2
+ require "split/extensions/#{f}"
3
+ end
@@ -0,0 +1,4 @@
1
+ class Array
2
+ # maintain backwards compatibility with 1.8.7
3
+ alias_method :sample, :choice unless method_defined?(:sample)
4
+ end
@@ -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
@@ -1,16 +1,30 @@
1
1
  module Split
2
2
  module Helper
3
- def ab_test(experiment_name, control, *alternatives)
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
- ret = if Split.configuration.enabled
10
- experiment_variable(alternatives, control, experiment_name)
11
- else
12
- control_variable(control)
13
- end
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 finished(experiment_name, options = {:reset => true})
29
- return if exclude_visitor? or !Split.configuration.enabled
30
- return unless (experiment = Split::Experiment.find(experiment_name))
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
- if options[:reset]
38
- ab_user.delete(experiment.key)
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 override(experiment_name, alternatives)
49
- params[experiment_name] if defined?(params) && alternatives.include?(params[experiment_name])
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
- session[:split] ||= {}
92
+ @ab_user ||= Split::Persistence.adapter.new(self)
59
93
  end
60
94
 
61
95
  def exclude_visitor?
62
- is_robot? or is_ignored_ip_address?
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.reject { |k| k == experiment_key }.length > 0
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)) }.reject { |k| k == experiment.key }
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 experiment_variable(alternatives, control, experiment_name)
107
- begin
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
- if experiment.winner
110
- ret = experiment.winner.name
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 forced_alternative = override(experiment.name, experiment.alternative_names)
113
- ret = forced_alternative
161
+ if ab_user[experiment.key]
162
+ ret = ab_user[experiment.key]
114
163
  else
115
- clean_old_versions(experiment)
116
- begin_experiment(experiment) if exclude_visitor? or not_allowed_to_test?(experiment.key)
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
@@ -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
@@ -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