split 3.3.0 → 4.0.0.pre
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/.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
|