split 2.2.0 → 3.3.0
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/.rubocop.yml +2 -2
- data/.travis.yml +39 -3
- data/Appraisals +8 -5
- data/CHANGELOG.md +59 -0
- data/CONTRIBUTING.md +54 -5
- data/LICENSE +1 -1
- data/README.md +193 -113
- data/gemfiles/4.2.gemfile +1 -1
- data/gemfiles/5.0.gemfile +1 -2
- data/gemfiles/{4.1.gemfile → 5.1.gemfile} +2 -2
- data/gemfiles/5.2.gemfile +9 -0
- data/lib/split/algorithms/block_randomization.rb +22 -0
- data/lib/split/alternative.rb +32 -7
- data/lib/split/combined_experiments_helper.rb +37 -0
- data/lib/split/configuration.rb +8 -0
- data/lib/split/dashboard/helpers.rb +5 -1
- data/lib/split/dashboard/pagination_helpers.rb +87 -0
- data/lib/split/dashboard/paginator.rb +16 -0
- data/lib/split/dashboard/public/style.css +9 -0
- data/lib/split/dashboard/views/_experiment.erb +31 -1
- data/lib/split/dashboard/views/index.erb +5 -1
- data/lib/split/dashboard.rb +2 -0
- data/lib/split/encapsulated_helper.rb +2 -0
- data/lib/split/engine.rb +2 -0
- data/lib/split/experiment.rb +5 -4
- data/lib/split/helper.rb +34 -1
- data/lib/split/persistence/cookie_adapter.rb +53 -15
- data/lib/split/persistence/dual_adapter.rb +3 -0
- data/lib/split/persistence.rb +5 -3
- data/lib/split/redis_interface.rb +1 -3
- data/lib/split/user.rb +2 -0
- data/lib/split/version.rb +2 -2
- data/lib/split/zscore.rb +1 -1
- data/lib/split.rb +21 -19
- data/spec/algorithms/block_randomization_spec.rb +32 -0
- data/spec/alternative_spec.rb +43 -0
- data/spec/combined_experiments_helper_spec.rb +57 -0
- data/spec/dashboard/pagination_helpers_spec.rb +198 -0
- data/spec/dashboard/paginator_spec.rb +37 -0
- data/spec/dashboard_helpers_spec.rb +14 -0
- data/spec/experiment_spec.rb +1 -3
- data/spec/helper_spec.rb +20 -0
- data/spec/persistence/cookie_adapter_spec.rb +90 -23
- data/spec/persistence/dual_adapter_spec.rb +2 -2
- data/spec/split_spec.rb +7 -7
- data/split.gemspec +16 -6
- metadata +36 -19
- data/lib/split/algorithms.rb +0 -4
- data/lib/split/extensions.rb +0 -4
data/lib/split/configuration.rb
CHANGED
|
@@ -25,6 +25,7 @@ module Split
|
|
|
25
25
|
attr_accessor :on_before_experiment_delete
|
|
26
26
|
attr_accessor :include_rails_helper
|
|
27
27
|
attr_accessor :beta_probability_simulations
|
|
28
|
+
attr_accessor :winning_alternative_recalculation_interval
|
|
28
29
|
attr_accessor :redis
|
|
29
30
|
|
|
30
31
|
attr_reader :experiments
|
|
@@ -48,7 +49,9 @@ module Split
|
|
|
48
49
|
'spider' => 'generic web spider',
|
|
49
50
|
'UnwindFetchor' => 'Gnip crawler',
|
|
50
51
|
'WordPress' => 'WordPress spider',
|
|
52
|
+
'YandexAccessibilityBot' => 'Yandex accessibility spider',
|
|
51
53
|
'YandexBot' => 'Yandex spider',
|
|
54
|
+
'YandexMobileBot' => 'Yandex mobile spider',
|
|
52
55
|
'ZIBB' => 'ZIBB spider',
|
|
53
56
|
|
|
54
57
|
# HTTP libraries
|
|
@@ -71,12 +74,16 @@ module Split
|
|
|
71
74
|
'bitlybot' => 'bit.ly bot',
|
|
72
75
|
'bot@linkfluence.net' => 'Linkfluence bot',
|
|
73
76
|
'facebookexternalhit' => 'facebook bot',
|
|
77
|
+
'Facebot' => 'Facebook crawler',
|
|
74
78
|
'Feedfetcher-Google' => 'Google Feedfetcher',
|
|
75
79
|
'https://developers.google.com/+/web/snippet' => 'Google+ Snippet Fetcher',
|
|
80
|
+
'LinkedInBot' => 'LinkedIn bot',
|
|
76
81
|
'LongURL' => 'URL expander service',
|
|
77
82
|
'NING' => 'NING - Yet Another Twitter Swarmer',
|
|
83
|
+
'Pinterest' => 'Pinterest Bot',
|
|
78
84
|
'redditbot' => 'Reddit Bot',
|
|
79
85
|
'ShortLinkTranslate' => 'Link shortener',
|
|
86
|
+
'Slackbot' => 'Slackbot link expander',
|
|
80
87
|
'TweetmemeBot' => 'TweetMeMe Crawler',
|
|
81
88
|
'Twitterbot' => 'Twitter URL expander',
|
|
82
89
|
'UnwindFetch' => 'Gnip URL expander',
|
|
@@ -212,6 +219,7 @@ module Split
|
|
|
212
219
|
@algorithm = Split::Algorithms::WeightedSample
|
|
213
220
|
@include_rails_helper = true
|
|
214
221
|
@beta_probability_simulations = 10000
|
|
222
|
+
@winning_alternative_recalculation_interval = 60 * 60 * 24 # 1 day
|
|
215
223
|
@redis = ENV.fetch(ENV.fetch('REDIS_PROVIDER', 'REDIS_URL'), 'redis://localhost:6379')
|
|
216
224
|
end
|
|
217
225
|
|
|
@@ -18,7 +18,11 @@ module Split
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def round(number, precision = 2)
|
|
21
|
-
|
|
21
|
+
begin
|
|
22
|
+
BigDecimal.new(number.to_s)
|
|
23
|
+
rescue ArgumentError
|
|
24
|
+
BigDecimal.new(0)
|
|
25
|
+
end.round(precision).to_f
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
def confidence_level(z_score)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'split/dashboard/paginator'
|
|
3
|
+
|
|
4
|
+
module Split
|
|
5
|
+
module DashboardPaginationHelpers
|
|
6
|
+
DEFAULT_PER = 10
|
|
7
|
+
|
|
8
|
+
def pagination_per
|
|
9
|
+
@pagination_per ||= (params[:per] || DEFAULT_PER).to_i
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def page_number
|
|
13
|
+
@page_number ||= (params[:page] || 1).to_i
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def paginated(collection)
|
|
17
|
+
Split::DashboardPaginator.new(collection, page_number, pagination_per).paginate
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pagination(collection)
|
|
21
|
+
html = []
|
|
22
|
+
html << first_page_tag if show_first_page_tag?
|
|
23
|
+
html << ellipsis_tag if show_first_ellipsis_tag?
|
|
24
|
+
html << prev_page_tag if show_prev_page_tag?
|
|
25
|
+
html << current_page_tag
|
|
26
|
+
html << next_page_tag if show_next_page_tag?(collection)
|
|
27
|
+
html << ellipsis_tag if show_last_ellipsis_tag?(collection)
|
|
28
|
+
html << last_page_tagcollection if show_last_page_tag?(collection)
|
|
29
|
+
html.join
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def show_first_page_tag?
|
|
35
|
+
page_number > 2
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def first_page_tag
|
|
39
|
+
%Q(<a href="#{url.chop}?page=1&per=#{pagination_per}">1</a>)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def show_first_ellipsis_tag?
|
|
43
|
+
page_number >= 4
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ellipsis_tag
|
|
47
|
+
'<span>...</span>'
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def show_prev_page_tag?
|
|
51
|
+
page_number > 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def prev_page_tag
|
|
55
|
+
%Q(<a href="#{url.chop}?page=#{page_number - 1}&per=#{pagination_per}">#{page_number - 1}</a>)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def current_page_tag
|
|
59
|
+
"<span><b>#{page_number}</b></span>"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def show_next_page_tag?(collection)
|
|
63
|
+
(page_number * pagination_per) < collection.count
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def next_page_tag
|
|
67
|
+
%Q(<a href="#{url.chop}?page=#{page_number + 1}&per=#{pagination_per}">#{page_number + 1}</a>)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def show_last_ellipsis_tag?(collection)
|
|
71
|
+
(total_pages(collection) - page_number) >= 3
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def total_pages(collection)
|
|
75
|
+
collection.count / pagination_per + ((collection.count % pagination_per).zero? ? 0 : 1)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def show_last_page_tag?(collection)
|
|
79
|
+
page_number < (total_pages(collection) - 1)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def last_page_tag(collection)
|
|
83
|
+
total = total_pages(collection)
|
|
84
|
+
%Q(<a href="#{url.chop}?page=#{total}&per=#{pagination_per}">#{total}</a>)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Split
|
|
3
|
+
class DashboardPaginator
|
|
4
|
+
def initialize(collection, page_number, per)
|
|
5
|
+
@collection = collection
|
|
6
|
+
@page_number = page_number
|
|
7
|
+
@per = per
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def paginate
|
|
11
|
+
to = @page_number * @per
|
|
12
|
+
from = to - @per
|
|
13
|
+
@collection[from...to]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -317,3 +317,12 @@ a.button.green:focus, button.green:focus, input[type="submit"].green:focus {
|
|
|
317
317
|
}
|
|
318
318
|
|
|
319
319
|
|
|
320
|
+
.pagination {
|
|
321
|
+
text-align: center;
|
|
322
|
+
font-size: 15px;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.pagination a, .paginaton span {
|
|
326
|
+
display: inline-block;
|
|
327
|
+
padding: 5px;
|
|
328
|
+
}
|
|
@@ -5,6 +5,25 @@
|
|
|
5
5
|
<% end %>
|
|
6
6
|
|
|
7
7
|
<% experiment.calc_winning_alternatives %>
|
|
8
|
+
<%
|
|
9
|
+
extra_columns = []
|
|
10
|
+
experiment.alternatives.each do |alternative|
|
|
11
|
+
extra_info = alternative.extra_info || {}
|
|
12
|
+
extra_columns += extra_info.keys
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
extra_columns.uniq!
|
|
16
|
+
summary_texts = {}
|
|
17
|
+
extra_columns.each do |column|
|
|
18
|
+
extra_infos = experiment.alternatives.map(&:extra_info).select{|extra_info| extra_info && extra_info[column] }
|
|
19
|
+
if extra_infos[0][column].kind_of?(Numeric)
|
|
20
|
+
summary_texts[column] = extra_infos.inject(0){|sum, extra_info| sum += extra_info[column]}
|
|
21
|
+
else
|
|
22
|
+
summary_texts[column] = "N/A"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
%>
|
|
26
|
+
|
|
8
27
|
|
|
9
28
|
<div class="<%= experiment_class %>" data-name="<%= experiment.name %>" data-complete="<%= experiment.has_winner? %>">
|
|
10
29
|
<div class="experiment-header">
|
|
@@ -32,6 +51,9 @@
|
|
|
32
51
|
<th>Non-finished</th>
|
|
33
52
|
<th>Completed</th>
|
|
34
53
|
<th>Conversion Rate</th>
|
|
54
|
+
<% extra_columns.each do |column| %>
|
|
55
|
+
<th><%= column %></th>
|
|
56
|
+
<% end %>
|
|
35
57
|
<th>
|
|
36
58
|
<form>
|
|
37
59
|
<select id="dropdown-<%=experiment.jstring(goal)%>" name="dropdown-<%=experiment.jstring(goal)%>">
|
|
@@ -82,6 +104,9 @@
|
|
|
82
104
|
});
|
|
83
105
|
});
|
|
84
106
|
</script>
|
|
107
|
+
<% extra_columns.each do |column| %>
|
|
108
|
+
<td><%= alternative.extra_info && alternative.extra_info[column] %></td>
|
|
109
|
+
<% end %>
|
|
85
110
|
<td>
|
|
86
111
|
<div class="box-<%=experiment.jstring(goal)%> confidence-<%=experiment.jstring(goal)%>">
|
|
87
112
|
<span title='z-score: <%= round(alternative.z_score(goal), 3) %>'><%= confidence_level(alternative.z_score(goal)) %></span>
|
|
@@ -90,7 +115,7 @@
|
|
|
90
115
|
<div class="box-<%=experiment.jstring(goal)%> probability-<%=experiment.jstring(goal)%>">
|
|
91
116
|
<span title="p_winner: <%= round(alternative.p_winner(goal), 3) %>"><%= number_to_percentage(round(alternative.p_winner(goal), 3)) %>%</span>
|
|
92
117
|
</div>
|
|
93
|
-
</td>
|
|
118
|
+
</td>
|
|
94
119
|
<td>
|
|
95
120
|
<% if experiment.has_winner? %>
|
|
96
121
|
<% if experiment.winner.name == alternative.name %>
|
|
@@ -118,6 +143,11 @@
|
|
|
118
143
|
<td><%= total_unfinished %></td>
|
|
119
144
|
<td><%= total_completed %></td>
|
|
120
145
|
<td>N/A</td>
|
|
146
|
+
<% extra_columns.each do |column| %>
|
|
147
|
+
<td>
|
|
148
|
+
<%= summary_texts[column] %>
|
|
149
|
+
</td>
|
|
150
|
+
<% end %>
|
|
121
151
|
<td>N/A</td>
|
|
122
152
|
<td>N/A</td>
|
|
123
153
|
</tr>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<input type="button" id="toggle-active" value="Hide active" />
|
|
7
7
|
<input type="button" id="clear-filter" value="Clear filters" />
|
|
8
8
|
|
|
9
|
-
<% @experiments.each do |experiment| %>
|
|
9
|
+
<% paginated(@experiments).each do |experiment| %>
|
|
10
10
|
<% if experiment.goals.empty? %>
|
|
11
11
|
<%= erb :_experiment, :locals => {:goal => nil, :experiment => experiment} %>
|
|
12
12
|
<% else %>
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
<% end %>
|
|
17
17
|
<% end %>
|
|
18
18
|
<% end %>
|
|
19
|
+
|
|
20
|
+
<div class="pagination">
|
|
21
|
+
<%= pagination(@experiments) %>
|
|
22
|
+
</div>
|
|
19
23
|
<% else %>
|
|
20
24
|
<p class="intro">No experiments have started yet, you need to define them in your code and introduce them to your users.</p>
|
|
21
25
|
<p class="intro">Check out the <a href='https://github.com/splitrb/split#readme'>Readme</a> for more help getting started.</p>
|
data/lib/split/dashboard.rb
CHANGED
|
@@ -3,6 +3,7 @@ require 'sinatra/base'
|
|
|
3
3
|
require 'split'
|
|
4
4
|
require 'bigdecimal'
|
|
5
5
|
require 'split/dashboard/helpers'
|
|
6
|
+
require 'split/dashboard/pagination_helpers'
|
|
6
7
|
|
|
7
8
|
module Split
|
|
8
9
|
class Dashboard < Sinatra::Base
|
|
@@ -14,6 +15,7 @@ module Split
|
|
|
14
15
|
set :method_override, true
|
|
15
16
|
|
|
16
17
|
helpers Split::DashboardHelpers
|
|
18
|
+
helpers Split::DashboardPaginationHelpers
|
|
17
19
|
|
|
18
20
|
get '/' do
|
|
19
21
|
# Display experiments without a winner at the top of the dashboard
|
data/lib/split/engine.rb
CHANGED
|
@@ -5,6 +5,8 @@ module Split
|
|
|
5
5
|
if Split.configuration.include_rails_helper
|
|
6
6
|
ActionController::Base.send :include, Split::Helper
|
|
7
7
|
ActionController::Base.helper Split::Helper
|
|
8
|
+
ActionController::Base.send :include, Split::CombinedExperimentsHelper
|
|
9
|
+
ActionController::Base.helper Split::CombinedExperimentsHelper
|
|
8
10
|
end
|
|
9
11
|
end
|
|
10
12
|
end
|
data/lib/split/experiment.rb
CHANGED
|
@@ -262,10 +262,11 @@ module Split
|
|
|
262
262
|
end
|
|
263
263
|
|
|
264
264
|
def calc_winning_alternatives
|
|
265
|
-
#
|
|
266
|
-
|
|
265
|
+
# Cache the winning alternatives so we recalculate them once per the specified interval.
|
|
266
|
+
intervals_since_epoch =
|
|
267
|
+
Time.now.utc.to_i / Split.configuration.winning_alternative_recalculation_interval
|
|
267
268
|
|
|
268
|
-
if self.calc_time !=
|
|
269
|
+
if self.calc_time != intervals_since_epoch
|
|
269
270
|
if goals.empty?
|
|
270
271
|
self.estimate_winning_alternative
|
|
271
272
|
else
|
|
@@ -274,7 +275,7 @@ module Split
|
|
|
274
275
|
end
|
|
275
276
|
end
|
|
276
277
|
|
|
277
|
-
self.calc_time =
|
|
278
|
+
self.calc_time = intervals_since_epoch
|
|
278
279
|
|
|
279
280
|
self.save
|
|
280
281
|
end
|
data/lib/split/helper.rb
CHANGED
|
@@ -10,6 +10,7 @@ module Split
|
|
|
10
10
|
experiment = ExperimentCatalog.find_or_initialize(metric_descriptor, control, *alternatives)
|
|
11
11
|
alternative = if Split.configuration.enabled
|
|
12
12
|
experiment.save
|
|
13
|
+
raise(Split::InvalidExperimentsFormatError) unless (Split.configuration.experiments || {}).fetch(experiment.name.to_sym, {})[:combined_experiments].nil?
|
|
13
14
|
trial = Trial.new(:user => ab_user, :experiment => experiment,
|
|
14
15
|
:override => override_alternative(experiment.name), :exclude => exclude_visitor?,
|
|
15
16
|
:disabled => split_generically_disabled?)
|
|
@@ -76,6 +77,34 @@ module Split
|
|
|
76
77
|
Split.configuration.db_failover_on_db_error.call(e)
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
def ab_record_extra_info(metric_descriptor, key, value = 1)
|
|
81
|
+
return if exclude_visitor? || Split.configuration.disabled?
|
|
82
|
+
metric_descriptor, goals = normalize_metric(metric_descriptor)
|
|
83
|
+
experiments = Metric.possible_experiments(metric_descriptor)
|
|
84
|
+
|
|
85
|
+
if experiments.any?
|
|
86
|
+
experiments.each do |experiment|
|
|
87
|
+
alternative_name = ab_user[experiment.key]
|
|
88
|
+
|
|
89
|
+
if alternative_name
|
|
90
|
+
alternative = experiment.alternatives.find{|alt| alt.name == alternative_name}
|
|
91
|
+
alternative.record_extra_info(key, value) if alternative
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
rescue => e
|
|
96
|
+
raise unless Split.configuration.db_failover
|
|
97
|
+
Split.configuration.db_failover_on_db_error.call(e)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def ab_active_experiments()
|
|
101
|
+
ab_user.active_experiments
|
|
102
|
+
rescue => e
|
|
103
|
+
raise unless Split.configuration.db_failover
|
|
104
|
+
Split.configuration.db_failover_on_db_error.call(e)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
|
|
79
108
|
def override_present?(experiment_name)
|
|
80
109
|
override_alternative(experiment_name)
|
|
81
110
|
end
|
|
@@ -93,13 +122,17 @@ module Split
|
|
|
93
122
|
end
|
|
94
123
|
|
|
95
124
|
def exclude_visitor?
|
|
96
|
-
|
|
125
|
+
instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?
|
|
97
126
|
end
|
|
98
127
|
|
|
99
128
|
def is_robot?
|
|
100
129
|
defined?(request) && request.user_agent =~ Split.configuration.robot_regex
|
|
101
130
|
end
|
|
102
131
|
|
|
132
|
+
def is_preview?
|
|
133
|
+
defined?(request) && defined?(request.headers) && request.headers['x-purpose'] == 'preview'
|
|
134
|
+
end
|
|
135
|
+
|
|
103
136
|
def is_ignored_ip_address?
|
|
104
137
|
return false if Split.configuration.ignore_ip_addresses.empty?
|
|
105
138
|
|
|
@@ -6,20 +6,22 @@ module Split
|
|
|
6
6
|
class CookieAdapter
|
|
7
7
|
|
|
8
8
|
def initialize(context)
|
|
9
|
-
@
|
|
9
|
+
@context = context
|
|
10
|
+
@request, @response = context.request, context.response
|
|
11
|
+
@cookies = @request.cookies
|
|
10
12
|
@expires = Time.now + cookie_length_config
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
def [](key)
|
|
14
|
-
hash[key]
|
|
16
|
+
hash[key.to_s]
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
def []=(key, value)
|
|
18
|
-
set_cookie(hash.merge(key => value))
|
|
20
|
+
set_cookie(hash.merge!(key.to_s => value))
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def delete(key)
|
|
22
|
-
set_cookie(hash.tap { |h| h.delete(key) })
|
|
24
|
+
set_cookie(hash.tap { |h| h.delete(key.to_s) })
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def keys
|
|
@@ -28,22 +30,55 @@ module Split
|
|
|
28
30
|
|
|
29
31
|
private
|
|
30
32
|
|
|
31
|
-
def set_cookie(value)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
def set_cookie(value = {})
|
|
34
|
+
cookie_key = :split.to_s
|
|
35
|
+
cookie_value = default_options.merge(value: JSON.generate(value))
|
|
36
|
+
if action_dispatch?
|
|
37
|
+
# The "send" is necessary when we call ab_test from the controller
|
|
38
|
+
# and thus @context is a rails controller, because then "cookies" is
|
|
39
|
+
# a private method.
|
|
40
|
+
@context.send(:cookies)[cookie_key] = cookie_value
|
|
41
|
+
else
|
|
42
|
+
set_cookie_via_rack(cookie_key, cookie_value)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def default_options
|
|
47
|
+
{ expires: @expires, path: '/' }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def set_cookie_via_rack(key, value)
|
|
51
|
+
delete_cookie_header!(@response.header, key, value)
|
|
52
|
+
Rack::Utils.set_cookie_header!(@response.header, key, value)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Use Rack::Utils#make_delete_cookie_header after Rack 2.0.0
|
|
56
|
+
def delete_cookie_header!(header, key, value)
|
|
57
|
+
cookie_header = header['Set-Cookie']
|
|
58
|
+
case cookie_header
|
|
59
|
+
when nil, ''
|
|
60
|
+
cookies = []
|
|
61
|
+
when String
|
|
62
|
+
cookies = cookie_header.split("\n")
|
|
63
|
+
when Array
|
|
64
|
+
cookies = cookie_header
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
cookies.reject! { |cookie| cookie =~ /\A#{Rack::Utils.escape(key)}=/ }
|
|
68
|
+
header['Set-Cookie'] = cookies.join("\n")
|
|
36
69
|
end
|
|
37
70
|
|
|
38
71
|
def hash
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
72
|
+
@hash ||= begin
|
|
73
|
+
if cookies = @cookies[:split.to_s]
|
|
74
|
+
begin
|
|
75
|
+
JSON.parse(cookies)
|
|
76
|
+
rescue JSON::ParserError
|
|
77
|
+
{}
|
|
78
|
+
end
|
|
79
|
+
else
|
|
43
80
|
{}
|
|
44
81
|
end
|
|
45
|
-
else
|
|
46
|
-
{}
|
|
47
82
|
end
|
|
48
83
|
end
|
|
49
84
|
|
|
@@ -51,6 +86,9 @@ module Split
|
|
|
51
86
|
Split.configuration.persistence_cookie_length
|
|
52
87
|
end
|
|
53
88
|
|
|
89
|
+
def action_dispatch?
|
|
90
|
+
defined?(Rails) && @response.is_a?(ActionDispatch::Response)
|
|
91
|
+
end
|
|
54
92
|
end
|
|
55
93
|
end
|
|
56
94
|
end
|
data/lib/split/persistence.rb
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
%w[session_adapter cookie_adapter redis_adapter dual_adapter].each do |f|
|
|
3
|
-
require "split/persistence/#{f}"
|
|
4
|
-
end
|
|
5
2
|
|
|
6
3
|
module Split
|
|
7
4
|
module Persistence
|
|
5
|
+
require 'split/persistence/cookie_adapter'
|
|
6
|
+
require 'split/persistence/dual_adapter'
|
|
7
|
+
require 'split/persistence/redis_adapter'
|
|
8
|
+
require 'split/persistence/session_adapter'
|
|
9
|
+
|
|
8
10
|
ADAPTERS = {
|
|
9
11
|
:cookie => Split::Persistence::CookieAdapter,
|
|
10
12
|
:session => Split::Persistence::SessionAdapter
|
data/lib/split/user.rb
CHANGED
data/lib/split/version.rb
CHANGED
data/lib/split/zscore.rb
CHANGED
data/lib/split.rb
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
alternative
|
|
4
|
-
configuration
|
|
5
|
-
exceptions
|
|
6
|
-
experiment
|
|
7
|
-
experiment_catalog
|
|
8
|
-
extensions
|
|
9
|
-
goals_collection
|
|
10
|
-
helper
|
|
11
|
-
metric
|
|
12
|
-
persistence
|
|
13
|
-
encapsulated_helper
|
|
14
|
-
redis_interface
|
|
15
|
-
trial
|
|
16
|
-
user
|
|
17
|
-
version
|
|
18
|
-
zscore].each do |f|
|
|
19
|
-
require "split/#{f}"
|
|
20
|
-
end
|
|
2
|
+
require 'redis'
|
|
21
3
|
|
|
4
|
+
require 'split/algorithms/block_randomization'
|
|
5
|
+
require 'split/algorithms/weighted_sample'
|
|
6
|
+
require 'split/algorithms/whiplash'
|
|
7
|
+
require 'split/alternative'
|
|
8
|
+
require 'split/configuration'
|
|
9
|
+
require 'split/encapsulated_helper'
|
|
10
|
+
require 'split/exceptions'
|
|
11
|
+
require 'split/experiment'
|
|
12
|
+
require 'split/experiment_catalog'
|
|
13
|
+
require 'split/extensions/string'
|
|
14
|
+
require 'split/goals_collection'
|
|
15
|
+
require 'split/helper'
|
|
16
|
+
require 'split/combined_experiments_helper'
|
|
17
|
+
require 'split/metric'
|
|
18
|
+
require 'split/persistence'
|
|
19
|
+
require 'split/redis_interface'
|
|
20
|
+
require 'split/trial'
|
|
21
|
+
require 'split/user'
|
|
22
|
+
require 'split/version'
|
|
23
|
+
require 'split/zscore'
|
|
22
24
|
require 'split/engine' if defined?(Rails)
|
|
23
25
|
|
|
24
26
|
module Split
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
describe Split::Algorithms::BlockRandomization do
|
|
4
|
+
|
|
5
|
+
let(:experiment) { Split::Experiment.new 'experiment' }
|
|
6
|
+
let(:alternative_A) { Split::Alternative.new 'A', 'experiment' }
|
|
7
|
+
let(:alternative_B) { Split::Alternative.new 'B', 'experiment' }
|
|
8
|
+
let(:alternative_C) { Split::Alternative.new 'C', 'experiment' }
|
|
9
|
+
|
|
10
|
+
before :each do
|
|
11
|
+
allow(experiment).to receive(:alternatives) { [alternative_A, alternative_B, alternative_C] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "should return an alternative" do
|
|
15
|
+
expect(Split::Algorithms::BlockRandomization.choose_alternative(experiment).class).to eq(Split::Alternative)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "should always return the minimum participation option" do
|
|
19
|
+
allow(alternative_A).to receive(:participant_count) { 1 }
|
|
20
|
+
allow(alternative_B).to receive(:participant_count) { 1 }
|
|
21
|
+
allow(alternative_C).to receive(:participant_count) { 0 }
|
|
22
|
+
expect(Split::Algorithms::BlockRandomization.choose_alternative(experiment)).to eq(alternative_C)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "should return one of the minimum participation options when multiple" do
|
|
26
|
+
allow(alternative_A).to receive(:participant_count) { 0 }
|
|
27
|
+
allow(alternative_B).to receive(:participant_count) { 0 }
|
|
28
|
+
allow(alternative_C).to receive(:participant_count) { 0 }
|
|
29
|
+
alternative = Split::Algorithms::BlockRandomization.choose_alternative(experiment)
|
|
30
|
+
expect([alternative_A, alternative_B, alternative_C].include?(alternative)).to be(true)
|
|
31
|
+
end
|
|
32
|
+
end
|
data/spec/alternative_spec.rb
CHANGED
|
@@ -273,5 +273,48 @@ describe Split::Alternative do
|
|
|
273
273
|
expect(control.z_score(goal1)).to eq('N/A')
|
|
274
274
|
expect(control.z_score(goal2)).to eq('N/A')
|
|
275
275
|
end
|
|
276
|
+
|
|
277
|
+
it "should not blow up for Conversion Rates > 1" do
|
|
278
|
+
control = experiment.control
|
|
279
|
+
control.participant_count = 3474
|
|
280
|
+
control.set_completed_count(4244)
|
|
281
|
+
|
|
282
|
+
alternative2.participant_count = 3434
|
|
283
|
+
alternative2.set_completed_count(4358)
|
|
284
|
+
|
|
285
|
+
expect { control.z_score }.not_to raise_error
|
|
286
|
+
expect { alternative2.z_score }.not_to raise_error
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
describe "extra_info" do
|
|
291
|
+
it "reads saved value of recorded_info in redis" do
|
|
292
|
+
saved_recorded_info = {"key_1" => 1, "key_2" => "2"}
|
|
293
|
+
Split.redis.hset "#{alternative.experiment_name}:#{alternative.name}", 'recorded_info', saved_recorded_info.to_json
|
|
294
|
+
extra_info = alternative.extra_info
|
|
295
|
+
|
|
296
|
+
expect(extra_info).to eql(saved_recorded_info)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
describe "record_extra_info" do
|
|
301
|
+
it "saves key" do
|
|
302
|
+
alternative.record_extra_info("signup", 1)
|
|
303
|
+
expect(alternative.extra_info["signup"]).to eql(1)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
it "adds value to saved key's value second argument is number" do
|
|
307
|
+
alternative.record_extra_info("signup", 1)
|
|
308
|
+
alternative.record_extra_info("signup", 2)
|
|
309
|
+
expect(alternative.extra_info["signup"]).to eql(3)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it "sets saved's key value to the second argument if it's a string" do
|
|
313
|
+
alternative.record_extra_info("signup", "Value 1")
|
|
314
|
+
expect(alternative.extra_info["signup"]).to eql("Value 1")
|
|
315
|
+
|
|
316
|
+
alternative.record_extra_info("signup", "Value 2")
|
|
317
|
+
expect(alternative.extra_info["signup"]).to eql("Value 2")
|
|
318
|
+
end
|
|
276
319
|
end
|
|
277
320
|
end
|