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/Rakefile
CHANGED
data/lib/split.rb
CHANGED
@@ -1,10 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
require 'redis'
|
3
4
|
|
4
5
|
require 'split/algorithms/block_randomization'
|
5
6
|
require 'split/algorithms/weighted_sample'
|
6
7
|
require 'split/algorithms/whiplash'
|
7
8
|
require 'split/alternative'
|
9
|
+
require 'split/cache'
|
8
10
|
require 'split/configuration'
|
9
11
|
require 'split/encapsulated_helper'
|
10
12
|
require 'split/exceptions'
|
@@ -35,9 +37,9 @@ module Split
|
|
35
37
|
# `Redis::DistRedis`, or `Redis::Namespace`.
|
36
38
|
def redis=(server)
|
37
39
|
@redis = if server.is_a?(String)
|
38
|
-
Redis.new(:
|
40
|
+
Redis.new(url: server)
|
39
41
|
elsif server.is_a?(Hash)
|
40
|
-
Redis.new(server
|
42
|
+
Redis.new(server)
|
41
43
|
elsif server.respond_to?(:smembers)
|
42
44
|
server
|
43
45
|
else
|
@@ -64,6 +66,17 @@ module Split
|
|
64
66
|
self.configuration ||= Configuration.new
|
65
67
|
yield(configuration)
|
66
68
|
end
|
69
|
+
|
70
|
+
def cache(namespace, key, &block)
|
71
|
+
Split::Cache.fetch(namespace, key, &block)
|
72
|
+
end
|
67
73
|
end
|
68
74
|
|
69
|
-
|
75
|
+
# Check to see if being run in a Rails application. If so, wait until before_initialize to run configuration so Gems that create ENV variables have the chance to initialize first.
|
76
|
+
if defined?(::Rails)
|
77
|
+
class Railtie < Rails::Railtie
|
78
|
+
config.before_initialize { Split.configure {} }
|
79
|
+
end
|
80
|
+
else
|
81
|
+
Split.configure {}
|
82
|
+
end
|
@@ -1,4 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
module Split
|
3
4
|
module Algorithms
|
4
5
|
module WeightedSample
|
@@ -8,7 +9,7 @@ module Split
|
|
8
9
|
total = weights.inject(:+)
|
9
10
|
point = rand * total
|
10
11
|
|
11
|
-
experiment.alternatives.zip(weights).each do |n,w|
|
12
|
+
experiment.alternatives.zip(weights).each do |n, w|
|
12
13
|
return n if w >= point
|
13
14
|
point -= w
|
14
15
|
end
|
@@ -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
|
@@ -22,14 +21,20 @@ module Split
|
|
22
21
|
attr_accessor :on_experiment_reset
|
23
22
|
attr_accessor :on_experiment_delete
|
24
23
|
attr_accessor :on_before_experiment_reset
|
24
|
+
attr_accessor :on_experiment_winner_choose
|
25
25
|
attr_accessor :on_before_experiment_delete
|
26
26
|
attr_accessor :include_rails_helper
|
27
27
|
attr_accessor :beta_probability_simulations
|
28
28
|
attr_accessor :winning_alternative_recalculation_interval
|
29
29
|
attr_accessor :redis
|
30
|
+
attr_accessor :dashboard_pagination_default_per_page
|
31
|
+
attr_accessor :cache
|
30
32
|
|
31
33
|
attr_reader :experiments
|
32
34
|
|
35
|
+
attr_writer :bots
|
36
|
+
attr_writer :robot_regex
|
37
|
+
|
33
38
|
def bots
|
34
39
|
@bots ||= {
|
35
40
|
# Indexers
|
@@ -61,12 +66,14 @@ module Split
|
|
61
66
|
'ColdFusion' => 'ColdFusion http library',
|
62
67
|
'EventMachine HttpClient' => 'Ruby http library',
|
63
68
|
'Go http package' => 'Go http library',
|
69
|
+
'Go-http-client' => 'Go http library',
|
64
70
|
'Java' => 'Generic Java http library',
|
65
71
|
'libwww-perl' => 'Perl client-server library loved by script kids',
|
66
72
|
'lwp-trivial' => 'Another Perl library loved by script kids',
|
67
73
|
'Python-urllib' => 'Python http library',
|
68
74
|
'PycURL' => 'Python http library',
|
69
75
|
'Test Certificate Info' => 'C http library?',
|
76
|
+
'Typhoeus' => 'Ruby http library',
|
70
77
|
'Wget' => 'wget unix CLI http client',
|
71
78
|
|
72
79
|
# URL expanders / previewers
|
@@ -80,7 +87,7 @@ module Split
|
|
80
87
|
'LinkedInBot' => 'LinkedIn bot',
|
81
88
|
'LongURL' => 'URL expander service',
|
82
89
|
'NING' => 'NING - Yet Another Twitter Swarmer',
|
83
|
-
'
|
90
|
+
'Pinterestbot' => 'Pinterest Bot',
|
84
91
|
'redditbot' => 'Reddit Bot',
|
85
92
|
'ShortLinkTranslate' => 'Link shortener',
|
86
93
|
'Slackbot' => 'Slackbot link expander',
|
@@ -91,10 +98,12 @@ module Split
|
|
91
98
|
|
92
99
|
# Uptime monitoring
|
93
100
|
'check_http' => 'Nagios monitor',
|
101
|
+
'GoogleStackdriverMonitoring' => 'Google Cloud monitor',
|
94
102
|
'NewRelicPinger' => 'NewRelic monitor',
|
95
103
|
'Panopta' => 'Monitoring service',
|
96
104
|
'Pingdom' => 'Pingdom monitoring',
|
97
105
|
'SiteUptime' => 'Site monitoring services',
|
106
|
+
'UptimeRobot' => 'Monitoring service',
|
98
107
|
|
99
108
|
# ???
|
100
109
|
'DigitalPersona Fingerprint Software' => 'HP Fingerprint scanner',
|
@@ -167,7 +176,7 @@ module Split
|
|
167
176
|
end
|
168
177
|
|
169
178
|
def normalize_alternatives(alternatives)
|
170
|
-
given_probability, num_with_probability = alternatives.inject([0,0]) do |a,v|
|
179
|
+
given_probability, num_with_probability = alternatives.inject([0, 0]) do |a, v|
|
171
180
|
p, n = a
|
172
181
|
if percent = value_for(v, :percent)
|
173
182
|
[p + percent, n + 1]
|
@@ -210,6 +219,7 @@ module Split
|
|
210
219
|
@on_experiment_delete = proc{|experiment|}
|
211
220
|
@on_before_experiment_reset = proc{|experiment|}
|
212
221
|
@on_before_experiment_delete = proc{|experiment|}
|
222
|
+
@on_experiment_winner_choose = proc{|experiment|}
|
213
223
|
@db_failover_allow_parameter_override = false
|
214
224
|
@allow_multiple_experiments = false
|
215
225
|
@enabled = true
|
@@ -221,16 +231,7 @@ module Split
|
|
221
231
|
@beta_probability_simulations = 10000
|
222
232
|
@winning_alternative_recalculation_interval = 60 * 60 * 24 # 1 day
|
223
233
|
@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
|
234
|
+
@dashboard_pagination_default_per_page = 10
|
234
235
|
end
|
235
236
|
|
236
237
|
private
|
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
|
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">
|
@@ -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
|