split 3.3.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 +4 -4
- 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 +4 -0
- data/CHANGELOG.md +116 -0
- data/CODE_OF_CONDUCT.md +3 -3
- data/Gemfile +2 -0
- data/README.md +63 -26
- data/Rakefile +2 -0
- data/gemfiles/{4.2.gemfile → 6.0.gemfile} +1 -1
- 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 +4 -3
- data/lib/split/cache.rb +28 -0
- data/lib/split/combined_experiments_helper.rb +3 -2
- data/lib/split/configuration.rb +17 -14
- 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/dashboard.rb +19 -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/cookie_adapter.rb +6 -1
- 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 -28
- 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 +16 -3
- data/spec/alternative_spec.rb +1 -1
- data/spec/cache_spec.rb +88 -0
- data/spec/configuration_spec.rb +17 -15
- 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 +45 -3
- data/split.gemspec +9 -9
- metadata +38 -29
- data/.travis.yml +0 -53
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
# A multi-armed bandit implementation inspired by
|
|
3
4
|
# @aaronsw and victorykit/whiplash
|
|
4
|
-
require '
|
|
5
|
+
require 'rubystats'
|
|
5
6
|
|
|
6
7
|
module Split
|
|
7
8
|
module Algorithms
|
|
@@ -16,7 +17,7 @@ module Split
|
|
|
16
17
|
def arm_guess(participants, completions)
|
|
17
18
|
a = [participants, 0].max
|
|
18
19
|
b = [participants-completions, 0].max
|
|
19
|
-
|
|
20
|
+
Rubystats::BetaDistribution.new(a+fairness_constant, b+fairness_constant).rng
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def best_guess(alternatives)
|
data/lib/split/alternative.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Split
|
|
3
4
|
class Alternative
|
|
4
5
|
attr_accessor :name
|
|
@@ -15,7 +16,7 @@ module Split
|
|
|
15
16
|
@name = name
|
|
16
17
|
@weight = 1
|
|
17
18
|
end
|
|
18
|
-
p_winner = 0.0
|
|
19
|
+
@p_winner = 0.0
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def to_s
|
|
@@ -75,7 +76,7 @@ module Split
|
|
|
75
76
|
return field
|
|
76
77
|
end
|
|
77
78
|
|
|
78
|
-
def set_completed_count
|
|
79
|
+
def set_completed_count(count, goal = nil)
|
|
79
80
|
field = set_field(goal)
|
|
80
81
|
Split.redis.hset(key, field, count.to_i)
|
|
81
82
|
end
|
|
@@ -122,7 +123,7 @@ module Split
|
|
|
122
123
|
# can't calculate zscore for P(x) > 1
|
|
123
124
|
return 'N/A' if p_a > 1 || p_c > 1
|
|
124
125
|
|
|
125
|
-
|
|
126
|
+
Split::Zscore.calculate(p_a, n_a, p_c, n_c)
|
|
126
127
|
end
|
|
127
128
|
|
|
128
129
|
def extra_info
|
data/lib/split/cache.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Split
|
|
4
|
+
class Cache
|
|
5
|
+
|
|
6
|
+
def self.clear
|
|
7
|
+
@cache = nil
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.fetch(namespace, key)
|
|
11
|
+
return yield unless Split.configuration.cache
|
|
12
|
+
|
|
13
|
+
@cache ||= {}
|
|
14
|
+
@cache[namespace] ||= {}
|
|
15
|
+
|
|
16
|
+
value = @cache[namespace][key]
|
|
17
|
+
return value if value
|
|
18
|
+
|
|
19
|
+
@cache[namespace][key] = yield
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.clear_key(key)
|
|
23
|
+
@cache&.keys&.each do |namespace|
|
|
24
|
+
@cache[namespace]&.delete(key)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Split
|
|
3
4
|
module CombinedExperimentsHelper
|
|
4
5
|
def ab_combined_test(metric_descriptor, control = nil, *alternatives)
|
|
5
6
|
return nil unless experiment = find_combined_experiment(metric_descriptor)
|
|
6
|
-
raise(Split::InvalidExperimentsFormatError,
|
|
7
|
+
raise(Split::InvalidExperimentsFormatError, "Unable to find experiment #{metric_descriptor} in configuration") if experiment[:combined_experiments].nil?
|
|
7
8
|
|
|
8
9
|
alternative = nil
|
|
9
10
|
weighted_alternatives = nil
|
|
@@ -31,7 +32,7 @@ module Split
|
|
|
31
32
|
raise(Split::InvalidExperimentsFormatError, 'Invalid descriptor class (String or Symbol required)') unless metric_descriptor.class == String || metric_descriptor.class == Symbol
|
|
32
33
|
raise(Split::InvalidExperimentsFormatError, 'Enable configuration') unless Split.configuration.enabled
|
|
33
34
|
raise(Split::InvalidExperimentsFormatError, 'Enable `allow_multiple_experiments`') unless Split.configuration.allow_multiple_experiments
|
|
34
|
-
|
|
35
|
+
Split::configuration.experiments[metric_descriptor.to_sym]
|
|
35
36
|
end
|
|
36
37
|
end
|
|
37
38
|
end
|
data/lib/split/configuration.rb
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Split
|
|
3
4
|
class Configuration
|
|
4
|
-
attr_accessor :bots
|
|
5
|
-
attr_accessor :robot_regex
|
|
6
5
|
attr_accessor :ignore_ip_addresses
|
|
7
6
|
attr_accessor :ignore_filter
|
|
8
7
|
attr_accessor :db_failover
|
|
@@ -12,6 +11,7 @@ module Split
|
|
|
12
11
|
attr_accessor :enabled
|
|
13
12
|
attr_accessor :persistence
|
|
14
13
|
attr_accessor :persistence_cookie_length
|
|
14
|
+
attr_accessor :persistence_cookie_domain
|
|
15
15
|
attr_accessor :algorithm
|
|
16
16
|
attr_accessor :store_override
|
|
17
17
|
attr_accessor :start_manually
|
|
@@ -22,14 +22,20 @@ module Split
|
|
|
22
22
|
attr_accessor :on_experiment_reset
|
|
23
23
|
attr_accessor :on_experiment_delete
|
|
24
24
|
attr_accessor :on_before_experiment_reset
|
|
25
|
+
attr_accessor :on_experiment_winner_choose
|
|
25
26
|
attr_accessor :on_before_experiment_delete
|
|
26
27
|
attr_accessor :include_rails_helper
|
|
27
28
|
attr_accessor :beta_probability_simulations
|
|
28
29
|
attr_accessor :winning_alternative_recalculation_interval
|
|
29
30
|
attr_accessor :redis
|
|
31
|
+
attr_accessor :dashboard_pagination_default_per_page
|
|
32
|
+
attr_accessor :cache
|
|
30
33
|
|
|
31
34
|
attr_reader :experiments
|
|
32
35
|
|
|
36
|
+
attr_writer :bots
|
|
37
|
+
attr_writer :robot_regex
|
|
38
|
+
|
|
33
39
|
def bots
|
|
34
40
|
@bots ||= {
|
|
35
41
|
# Indexers
|
|
@@ -61,12 +67,14 @@ module Split
|
|
|
61
67
|
'ColdFusion' => 'ColdFusion http library',
|
|
62
68
|
'EventMachine HttpClient' => 'Ruby http library',
|
|
63
69
|
'Go http package' => 'Go http library',
|
|
70
|
+
'Go-http-client' => 'Go http library',
|
|
64
71
|
'Java' => 'Generic Java http library',
|
|
65
72
|
'libwww-perl' => 'Perl client-server library loved by script kids',
|
|
66
73
|
'lwp-trivial' => 'Another Perl library loved by script kids',
|
|
67
74
|
'Python-urllib' => 'Python http library',
|
|
68
75
|
'PycURL' => 'Python http library',
|
|
69
76
|
'Test Certificate Info' => 'C http library?',
|
|
77
|
+
'Typhoeus' => 'Ruby http library',
|
|
70
78
|
'Wget' => 'wget unix CLI http client',
|
|
71
79
|
|
|
72
80
|
# URL expanders / previewers
|
|
@@ -80,7 +88,7 @@ module Split
|
|
|
80
88
|
'LinkedInBot' => 'LinkedIn bot',
|
|
81
89
|
'LongURL' => 'URL expander service',
|
|
82
90
|
'NING' => 'NING - Yet Another Twitter Swarmer',
|
|
83
|
-
'
|
|
91
|
+
'Pinterestbot' => 'Pinterest Bot',
|
|
84
92
|
'redditbot' => 'Reddit Bot',
|
|
85
93
|
'ShortLinkTranslate' => 'Link shortener',
|
|
86
94
|
'Slackbot' => 'Slackbot link expander',
|
|
@@ -91,10 +99,12 @@ module Split
|
|
|
91
99
|
|
|
92
100
|
# Uptime monitoring
|
|
93
101
|
'check_http' => 'Nagios monitor',
|
|
102
|
+
'GoogleStackdriverMonitoring' => 'Google Cloud monitor',
|
|
94
103
|
'NewRelicPinger' => 'NewRelic monitor',
|
|
95
104
|
'Panopta' => 'Monitoring service',
|
|
96
105
|
'Pingdom' => 'Pingdom monitoring',
|
|
97
106
|
'SiteUptime' => 'Site monitoring services',
|
|
107
|
+
'UptimeRobot' => 'Monitoring service',
|
|
98
108
|
|
|
99
109
|
# ???
|
|
100
110
|
'DigitalPersona Fingerprint Software' => 'HP Fingerprint scanner',
|
|
@@ -167,7 +177,7 @@ module Split
|
|
|
167
177
|
end
|
|
168
178
|
|
|
169
179
|
def normalize_alternatives(alternatives)
|
|
170
|
-
given_probability, num_with_probability = alternatives.inject([0,0]) do |a,v|
|
|
180
|
+
given_probability, num_with_probability = alternatives.inject([0, 0]) do |a, v|
|
|
171
181
|
p, n = a
|
|
172
182
|
if percent = value_for(v, :percent)
|
|
173
183
|
[p + percent, n + 1]
|
|
@@ -210,27 +220,20 @@ module Split
|
|
|
210
220
|
@on_experiment_delete = proc{|experiment|}
|
|
211
221
|
@on_before_experiment_reset = proc{|experiment|}
|
|
212
222
|
@on_before_experiment_delete = proc{|experiment|}
|
|
223
|
+
@on_experiment_winner_choose = proc{|experiment|}
|
|
213
224
|
@db_failover_allow_parameter_override = false
|
|
214
225
|
@allow_multiple_experiments = false
|
|
215
226
|
@enabled = true
|
|
216
227
|
@experiments = {}
|
|
217
228
|
@persistence = Split::Persistence::SessionAdapter
|
|
218
229
|
@persistence_cookie_length = 31536000 # One year from now
|
|
230
|
+
@persistence_cookie_domain = nil
|
|
219
231
|
@algorithm = Split::Algorithms::WeightedSample
|
|
220
232
|
@include_rails_helper = true
|
|
221
233
|
@beta_probability_simulations = 10000
|
|
222
234
|
@winning_alternative_recalculation_interval = 60 * 60 * 24 # 1 day
|
|
223
235
|
@redis = ENV.fetch(ENV.fetch('REDIS_PROVIDER', 'REDIS_URL'), 'redis://localhost:6379')
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def redis_url=(value)
|
|
227
|
-
warn '[DEPRECATED] `redis_url=` is deprecated in favor of `redis=`'
|
|
228
|
-
self.redis = value
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def redis_url
|
|
232
|
-
warn '[DEPRECATED] `redis_url` is deprecated in favor of `redis`'
|
|
233
|
-
self.redis
|
|
236
|
+
@dashboard_pagination_default_per_page = 10
|
|
234
237
|
end
|
|
235
238
|
|
|
236
239
|
private
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Split
|
|
3
4
|
module DashboardHelpers
|
|
4
5
|
def h(text)
|
|
@@ -19,9 +20,9 @@ module Split
|
|
|
19
20
|
|
|
20
21
|
def round(number, precision = 2)
|
|
21
22
|
begin
|
|
22
|
-
BigDecimal
|
|
23
|
+
BigDecimal(number.to_s)
|
|
23
24
|
rescue ArgumentError
|
|
24
|
-
BigDecimal
|
|
25
|
+
BigDecimal(0)
|
|
25
26
|
end.round(precision).to_f
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require 'split/dashboard/paginator'
|
|
3
4
|
|
|
4
5
|
module Split
|
|
5
6
|
module DashboardPaginationHelpers
|
|
6
|
-
DEFAULT_PER = 10
|
|
7
|
-
|
|
8
7
|
def pagination_per
|
|
9
|
-
|
|
8
|
+
default_per_page = Split.configuration.dashboard_pagination_default_per_page
|
|
9
|
+
@pagination_per ||= (params[:per] || default_per_page).to_i
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def page_number
|
|
@@ -25,7 +25,7 @@ module Split
|
|
|
25
25
|
html << current_page_tag
|
|
26
26
|
html << next_page_tag if show_next_page_tag?(collection)
|
|
27
27
|
html << ellipsis_tag if show_last_ellipsis_tag?(collection)
|
|
28
|
-
html <<
|
|
28
|
+
html << last_page_tag(collection) if show_last_page_tag?(collection)
|
|
29
29
|
html.join
|
|
30
30
|
end
|
|
31
31
|
|
|
@@ -22,3 +22,13 @@ function confirmReopen() {
|
|
|
22
22
|
var agree = confirm("This will reopen the experiment. Are you sure?");
|
|
23
23
|
return agree ? true : false;
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
function confirmEnableCohorting(){
|
|
27
|
+
var agree = confirm("This will enable the cohorting of the experiment. Are you sure?");
|
|
28
|
+
return agree ? true : false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function confirmDisableCohorting(){
|
|
32
|
+
var agree = confirm("This will disable the cohorting of the experiment. Note: Existing participants will continue to receive their alternative and may continue to convert. Are you sure?");
|
|
33
|
+
return agree ? true : false;
|
|
34
|
+
}
|
|
@@ -2,7 +2,20 @@
|
|
|
2
2
|
<form action="<%= url "/reopen?experiment=#{experiment.name}" %>" method='post' onclick="return confirmReopen()">
|
|
3
3
|
<input type="submit" value="Reopen Experiment">
|
|
4
4
|
</form>
|
|
5
|
+
<% else %>
|
|
6
|
+
<% if experiment.cohorting_disabled? %>
|
|
7
|
+
<form action="<%= url "/update_cohorting?experiment=#{experiment.name}" %>" method='post' onclick="return confirmEnableCohorting()">
|
|
8
|
+
<input type="hidden" name="cohorting_action" value="enable">
|
|
9
|
+
<input type="submit" value="Enable Cohorting" class="green">
|
|
10
|
+
</form>
|
|
11
|
+
<% else %>
|
|
12
|
+
<form action="<%= url "/update_cohorting?experiment=#{experiment.name}" %>" method='post' onclick="return confirmDisableCohorting()">
|
|
13
|
+
<input type="hidden" name="cohorting_action" value="disable">
|
|
14
|
+
<input type="submit" value="Disable Cohorting" class="red">
|
|
15
|
+
</form>
|
|
16
|
+
<% end %>
|
|
5
17
|
<% end %>
|
|
18
|
+
<span class="divider">|</span>
|
|
6
19
|
<% if experiment.start_time %>
|
|
7
20
|
<form action="<%= url "/reset?experiment=#{experiment.name}" %>" method='post' onclick="return confirmReset()">
|
|
8
21
|
<input type="submit" value="Reset Data">
|
data/lib/split/dashboard.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require 'sinatra/base'
|
|
3
4
|
require 'split'
|
|
4
5
|
require 'bigdecimal'
|
|
@@ -33,7 +34,13 @@ module Split
|
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
post '/force_alternative' do
|
|
36
|
-
Split::
|
|
37
|
+
experiment = Split::ExperimentCatalog.find(params[:experiment])
|
|
38
|
+
alternative = Split::Alternative.new(params[:alternative], experiment.name)
|
|
39
|
+
|
|
40
|
+
cookies = JSON.parse(request.cookies['split_override']) rescue {}
|
|
41
|
+
cookies[experiment.name] = alternative.name
|
|
42
|
+
response.set_cookie('split_override', { value: cookies.to_json, path: '/' })
|
|
43
|
+
|
|
37
44
|
redirect url('/')
|
|
38
45
|
end
|
|
39
46
|
|
|
@@ -62,6 +69,17 @@ module Split
|
|
|
62
69
|
redirect url('/')
|
|
63
70
|
end
|
|
64
71
|
|
|
72
|
+
post '/update_cohorting' do
|
|
73
|
+
@experiment = Split::ExperimentCatalog.find(params[:experiment])
|
|
74
|
+
case params[:cohorting_action].downcase
|
|
75
|
+
when "enable"
|
|
76
|
+
@experiment.enable_cohorting
|
|
77
|
+
when "disable"
|
|
78
|
+
@experiment.disable_cohorting
|
|
79
|
+
end
|
|
80
|
+
redirect url('/')
|
|
81
|
+
end
|
|
82
|
+
|
|
65
83
|
delete '/experiment' do
|
|
66
84
|
@experiment = Split::ExperimentCatalog.find(params[:experiment])
|
|
67
85
|
@experiment.delete
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require "split/helper"
|
|
3
4
|
|
|
4
5
|
# Split's helper exposes all kinds of methods we don't want to
|
|
@@ -28,8 +29,8 @@ module Split
|
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
def ab_test(*arguments
|
|
32
|
-
split_context_shim.ab_test(*arguments
|
|
32
|
+
def ab_test(*arguments, &block)
|
|
33
|
+
split_context_shim.ab_test(*arguments, &block)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
private
|
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
|