split 3.0.0 → 3.4.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/.eslintrc +1 -1
- data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
- data/.rspec +1 -0
- data/.rubocop.yml +6 -1155
- data/.rubocop_todo.yml +679 -0
- data/.travis.yml +46 -2
- data/Appraisals +12 -1
- data/CHANGELOG.md +116 -0
- data/CODE_OF_CONDUCT.md +3 -3
- data/CONTRIBUTING.md +54 -5
- data/Gemfile +1 -0
- data/LICENSE +1 -1
- data/README.md +209 -118
- data/Rakefile +1 -0
- data/gemfiles/4.2.gemfile +1 -1
- data/gemfiles/5.0.gemfile +1 -2
- data/gemfiles/5.1.gemfile +9 -0
- data/gemfiles/5.2.gemfile +9 -0
- data/gemfiles/6.0.gemfile +9 -0
- data/lib/split/algorithms/block_randomization.rb +1 -0
- data/lib/split/alternative.rb +6 -3
- data/lib/split/combined_experiments_helper.rb +37 -0
- data/lib/split/configuration.rb +17 -2
- data/lib/split/dashboard/helpers.rb +2 -2
- data/lib/split/dashboard/pagination_helpers.rb +86 -0
- data/lib/split/dashboard/paginator.rb +16 -0
- data/lib/split/dashboard/public/style.css +9 -0
- data/lib/split/dashboard/views/index.erb +5 -1
- data/lib/split/dashboard/views/layout.erb +1 -1
- data/lib/split/dashboard.rb +6 -1
- data/lib/split/engine.rb +6 -2
- data/lib/split/experiment.rb +34 -22
- data/lib/split/goals_collection.rb +1 -0
- data/lib/split/helper.rb +17 -3
- data/lib/split/persistence/cookie_adapter.rb +53 -15
- data/lib/split/persistence/dual_adapter.rb +54 -12
- data/lib/split/redis_interface.rb +2 -3
- data/lib/split/trial.rb +4 -6
- data/lib/split/user.rb +5 -1
- data/lib/split/version.rb +1 -1
- data/lib/split.rb +9 -1
- data/spec/alternative_spec.rb +12 -0
- data/spec/combined_experiments_helper_spec.rb +57 -0
- data/spec/dashboard/pagination_helpers_spec.rb +200 -0
- data/spec/dashboard/paginator_spec.rb +37 -0
- data/spec/dashboard_helpers_spec.rb +2 -2
- data/spec/dashboard_spec.rb +37 -16
- data/spec/encapsulated_helper_spec.rb +1 -1
- data/spec/experiment_spec.rb +45 -6
- data/spec/helper_spec.rb +143 -80
- data/spec/persistence/cookie_adapter_spec.rb +90 -23
- data/spec/persistence/dual_adapter_spec.rb +160 -68
- data/spec/split_spec.rb +7 -7
- data/spec/trial_spec.rb +20 -0
- data/spec/user_spec.rb +11 -0
- data/split.gemspec +17 -7
- metadata +53 -19
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Split
|
|
3
|
+
module CombinedExperimentsHelper
|
|
4
|
+
def ab_combined_test(metric_descriptor, control = nil, *alternatives)
|
|
5
|
+
return nil unless experiment = find_combined_experiment(metric_descriptor)
|
|
6
|
+
raise(Split::InvalidExperimentsFormatError, "Unable to find experiment #{metric_descriptor} in configuration") if experiment[:combined_experiments].nil?
|
|
7
|
+
|
|
8
|
+
alternative = nil
|
|
9
|
+
weighted_alternatives = nil
|
|
10
|
+
experiment[:combined_experiments].each do |combined_experiment|
|
|
11
|
+
if alternative.nil?
|
|
12
|
+
if control
|
|
13
|
+
alternative = ab_test(combined_experiment, control, alternatives)
|
|
14
|
+
else
|
|
15
|
+
normalized_alternatives = Split::Configuration.new.normalize_alternatives(experiment[:alternatives])
|
|
16
|
+
alternative = ab_test(combined_experiment, normalized_alternatives[0], *normalized_alternatives[1])
|
|
17
|
+
end
|
|
18
|
+
else
|
|
19
|
+
weighted_alternatives ||= experiment[:alternatives].each_with_object({}) do |alt, memo|
|
|
20
|
+
alt = Alternative.new(alt, experiment[:name]).name
|
|
21
|
+
memo[alt] = (alt == alternative ? 1 : 0)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
ab_test(combined_experiment, [weighted_alternatives])
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
alternative
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def find_combined_experiment(metric_descriptor)
|
|
31
|
+
raise(Split::InvalidExperimentsFormatError, 'Invalid descriptor class (String or Symbol required)') unless metric_descriptor.class == String || metric_descriptor.class == Symbol
|
|
32
|
+
raise(Split::InvalidExperimentsFormatError, 'Enable configuration') unless Split.configuration.enabled
|
|
33
|
+
raise(Split::InvalidExperimentsFormatError, 'Enable `allow_multiple_experiments`') unless Split.configuration.allow_multiple_experiments
|
|
34
|
+
Split::configuration.experiments[metric_descriptor.to_sym]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/split/configuration.rb
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
module Split
|
|
3
3
|
class Configuration
|
|
4
|
-
attr_accessor :bots
|
|
5
|
-
attr_accessor :robot_regex
|
|
6
4
|
attr_accessor :ignore_ip_addresses
|
|
7
5
|
attr_accessor :ignore_filter
|
|
8
6
|
attr_accessor :db_failover
|
|
@@ -25,10 +23,15 @@ module Split
|
|
|
25
23
|
attr_accessor :on_before_experiment_delete
|
|
26
24
|
attr_accessor :include_rails_helper
|
|
27
25
|
attr_accessor :beta_probability_simulations
|
|
26
|
+
attr_accessor :winning_alternative_recalculation_interval
|
|
28
27
|
attr_accessor :redis
|
|
28
|
+
attr_accessor :dashboard_pagination_default_per_page
|
|
29
29
|
|
|
30
30
|
attr_reader :experiments
|
|
31
31
|
|
|
32
|
+
attr_writer :bots
|
|
33
|
+
attr_writer :robot_regex
|
|
34
|
+
|
|
32
35
|
def bots
|
|
33
36
|
@bots ||= {
|
|
34
37
|
# Indexers
|
|
@@ -48,7 +51,9 @@ module Split
|
|
|
48
51
|
'spider' => 'generic web spider',
|
|
49
52
|
'UnwindFetchor' => 'Gnip crawler',
|
|
50
53
|
'WordPress' => 'WordPress spider',
|
|
54
|
+
'YandexAccessibilityBot' => 'Yandex accessibility spider',
|
|
51
55
|
'YandexBot' => 'Yandex spider',
|
|
56
|
+
'YandexMobileBot' => 'Yandex mobile spider',
|
|
52
57
|
'ZIBB' => 'ZIBB spider',
|
|
53
58
|
|
|
54
59
|
# HTTP libraries
|
|
@@ -58,12 +63,14 @@ module Split
|
|
|
58
63
|
'ColdFusion' => 'ColdFusion http library',
|
|
59
64
|
'EventMachine HttpClient' => 'Ruby http library',
|
|
60
65
|
'Go http package' => 'Go http library',
|
|
66
|
+
'Go-http-client' => 'Go http library',
|
|
61
67
|
'Java' => 'Generic Java http library',
|
|
62
68
|
'libwww-perl' => 'Perl client-server library loved by script kids',
|
|
63
69
|
'lwp-trivial' => 'Another Perl library loved by script kids',
|
|
64
70
|
'Python-urllib' => 'Python http library',
|
|
65
71
|
'PycURL' => 'Python http library',
|
|
66
72
|
'Test Certificate Info' => 'C http library?',
|
|
73
|
+
'Typhoeus' => 'Ruby http library',
|
|
67
74
|
'Wget' => 'wget unix CLI http client',
|
|
68
75
|
|
|
69
76
|
# URL expanders / previewers
|
|
@@ -71,12 +78,16 @@ module Split
|
|
|
71
78
|
'bitlybot' => 'bit.ly bot',
|
|
72
79
|
'bot@linkfluence.net' => 'Linkfluence bot',
|
|
73
80
|
'facebookexternalhit' => 'facebook bot',
|
|
81
|
+
'Facebot' => 'Facebook crawler',
|
|
74
82
|
'Feedfetcher-Google' => 'Google Feedfetcher',
|
|
75
83
|
'https://developers.google.com/+/web/snippet' => 'Google+ Snippet Fetcher',
|
|
84
|
+
'LinkedInBot' => 'LinkedIn bot',
|
|
76
85
|
'LongURL' => 'URL expander service',
|
|
77
86
|
'NING' => 'NING - Yet Another Twitter Swarmer',
|
|
87
|
+
'Pinterest' => 'Pinterest Bot',
|
|
78
88
|
'redditbot' => 'Reddit Bot',
|
|
79
89
|
'ShortLinkTranslate' => 'Link shortener',
|
|
90
|
+
'Slackbot' => 'Slackbot link expander',
|
|
80
91
|
'TweetmemeBot' => 'TweetMeMe Crawler',
|
|
81
92
|
'Twitterbot' => 'Twitter URL expander',
|
|
82
93
|
'UnwindFetch' => 'Gnip URL expander',
|
|
@@ -84,10 +95,12 @@ module Split
|
|
|
84
95
|
|
|
85
96
|
# Uptime monitoring
|
|
86
97
|
'check_http' => 'Nagios monitor',
|
|
98
|
+
'GoogleStackdriverMonitoring' => 'Google Cloud monitor',
|
|
87
99
|
'NewRelicPinger' => 'NewRelic monitor',
|
|
88
100
|
'Panopta' => 'Monitoring service',
|
|
89
101
|
'Pingdom' => 'Pingdom monitoring',
|
|
90
102
|
'SiteUptime' => 'Site monitoring services',
|
|
103
|
+
'UptimeRobot' => 'Monitoring service',
|
|
91
104
|
|
|
92
105
|
# ???
|
|
93
106
|
'DigitalPersona Fingerprint Software' => 'HP Fingerprint scanner',
|
|
@@ -212,7 +225,9 @@ module Split
|
|
|
212
225
|
@algorithm = Split::Algorithms::WeightedSample
|
|
213
226
|
@include_rails_helper = true
|
|
214
227
|
@beta_probability_simulations = 10000
|
|
228
|
+
@winning_alternative_recalculation_interval = 60 * 60 * 24 # 1 day
|
|
215
229
|
@redis = ENV.fetch(ENV.fetch('REDIS_PROVIDER', 'REDIS_URL'), 'redis://localhost:6379')
|
|
230
|
+
@dashboard_pagination_default_per_page = 10
|
|
216
231
|
end
|
|
217
232
|
|
|
218
233
|
def redis_url=(value)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'split/dashboard/paginator'
|
|
3
|
+
|
|
4
|
+
module Split
|
|
5
|
+
module DashboardPaginationHelpers
|
|
6
|
+
def pagination_per
|
|
7
|
+
default_per_page = Split.configuration.dashboard_pagination_default_per_page
|
|
8
|
+
@pagination_per ||= (params[:per] || default_per_page).to_i
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def page_number
|
|
12
|
+
@page_number ||= (params[:page] || 1).to_i
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def paginated(collection)
|
|
16
|
+
Split::DashboardPaginator.new(collection, page_number, pagination_per).paginate
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pagination(collection)
|
|
20
|
+
html = []
|
|
21
|
+
html << first_page_tag if show_first_page_tag?
|
|
22
|
+
html << ellipsis_tag if show_first_ellipsis_tag?
|
|
23
|
+
html << prev_page_tag if show_prev_page_tag?
|
|
24
|
+
html << current_page_tag
|
|
25
|
+
html << next_page_tag if show_next_page_tag?(collection)
|
|
26
|
+
html << ellipsis_tag if show_last_ellipsis_tag?(collection)
|
|
27
|
+
html << last_page_tag(collection) if show_last_page_tag?(collection)
|
|
28
|
+
html.join
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def show_first_page_tag?
|
|
34
|
+
page_number > 2
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def first_page_tag
|
|
38
|
+
%Q(<a href="#{url.chop}?page=1&per=#{pagination_per}">1</a>)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def show_first_ellipsis_tag?
|
|
42
|
+
page_number >= 4
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ellipsis_tag
|
|
46
|
+
'<span>...</span>'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def show_prev_page_tag?
|
|
50
|
+
page_number > 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def prev_page_tag
|
|
54
|
+
%Q(<a href="#{url.chop}?page=#{page_number - 1}&per=#{pagination_per}">#{page_number - 1}</a>)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def current_page_tag
|
|
58
|
+
"<span><b>#{page_number}</b></span>"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def show_next_page_tag?(collection)
|
|
62
|
+
(page_number * pagination_per) < collection.count
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def next_page_tag
|
|
66
|
+
%Q(<a href="#{url.chop}?page=#{page_number + 1}&per=#{pagination_per}">#{page_number + 1}</a>)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def show_last_ellipsis_tag?(collection)
|
|
70
|
+
(total_pages(collection) - page_number) >= 3
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def total_pages(collection)
|
|
74
|
+
collection.count / pagination_per + ((collection.count % pagination_per).zero? ? 0 : 1)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def show_last_page_tag?(collection)
|
|
78
|
+
page_number < (total_pages(collection) - 1)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def last_page_tag(collection)
|
|
82
|
+
total = total_pages(collection)
|
|
83
|
+
%Q(<a href="#{url.chop}?page=#{total}&per=#{pagination_per}">#{total}</a>)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
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
|
+
}
|
|
@@ -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
|
|
@@ -31,7 +33,10 @@ module Split
|
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
post '/force_alternative' do
|
|
34
|
-
Split::
|
|
36
|
+
experiment = Split::ExperimentCatalog.find(params[:experiment])
|
|
37
|
+
alternative = Split::Alternative.new(params[:alternative], experiment.name)
|
|
38
|
+
alternative.increment_participation
|
|
39
|
+
Split::User.new(self)[experiment.key] = alternative.name
|
|
35
40
|
redirect url('/')
|
|
36
41
|
end
|
|
37
42
|
|
data/lib/split/engine.rb
CHANGED
|
@@ -3,8 +3,12 @@ module Split
|
|
|
3
3
|
class Engine < ::Rails::Engine
|
|
4
4
|
initializer "split" do |app|
|
|
5
5
|
if Split.configuration.include_rails_helper
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
ActiveSupport.on_load(:action_controller) do
|
|
7
|
+
include Split::Helper
|
|
8
|
+
helper Split::Helper
|
|
9
|
+
include Split::CombinedExperimentsHelper
|
|
10
|
+
helper Split::CombinedExperimentsHelper
|
|
11
|
+
end
|
|
8
12
|
end
|
|
9
13
|
end
|
|
10
14
|
end
|
data/lib/split/experiment.rb
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
module Split
|
|
3
3
|
class Experiment
|
|
4
4
|
attr_accessor :name
|
|
5
|
-
attr_writer :algorithm
|
|
6
|
-
attr_accessor :resettable
|
|
7
5
|
attr_accessor :goals
|
|
8
|
-
attr_accessor :alternatives
|
|
9
6
|
attr_accessor :alternative_probabilities
|
|
10
7
|
attr_accessor :metadata
|
|
11
8
|
|
|
9
|
+
attr_reader :alternatives
|
|
10
|
+
attr_reader :resettable
|
|
11
|
+
|
|
12
12
|
DEFAULT_OPTIONS = {
|
|
13
13
|
:resettable => true
|
|
14
14
|
}
|
|
@@ -25,7 +25,7 @@ module Split
|
|
|
25
25
|
alternatives: load_alternatives_from_configuration,
|
|
26
26
|
goals: Split::GoalsCollection.new(@name).load_from_configuration,
|
|
27
27
|
metadata: load_metadata_from_configuration,
|
|
28
|
-
resettable: exp_config
|
|
28
|
+
resettable: exp_config.fetch(:resettable, true),
|
|
29
29
|
algorithm: exp_config[:algorithm]
|
|
30
30
|
}
|
|
31
31
|
else
|
|
@@ -62,7 +62,7 @@ module Split
|
|
|
62
62
|
alts = load_alternatives_from_configuration
|
|
63
63
|
options[:goals] = Split::GoalsCollection.new(@name).load_from_configuration
|
|
64
64
|
options[:metadata] = load_metadata_from_configuration
|
|
65
|
-
options[:resettable] = exp_config
|
|
65
|
+
options[:resettable] = exp_config.fetch(:resettable, true)
|
|
66
66
|
options[:algorithm] = exp_config[:algorithm]
|
|
67
67
|
end
|
|
68
68
|
end
|
|
@@ -81,12 +81,12 @@ module Split
|
|
|
81
81
|
|
|
82
82
|
if new_record?
|
|
83
83
|
start unless Split.configuration.start_manually
|
|
84
|
+
persist_experiment_configuration
|
|
84
85
|
elsif experiment_configuration_has_changed?
|
|
85
86
|
reset unless Split.configuration.reset_manually
|
|
87
|
+
persist_experiment_configuration
|
|
86
88
|
end
|
|
87
89
|
|
|
88
|
-
persist_experiment_configuration if new_record? || experiment_configuration_has_changed?
|
|
89
|
-
|
|
90
90
|
redis.hset(experiment_config_key, :resettable, resettable)
|
|
91
91
|
redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
|
|
92
92
|
self
|
|
@@ -144,11 +144,13 @@ module Split
|
|
|
144
144
|
end
|
|
145
145
|
|
|
146
146
|
def has_winner?
|
|
147
|
-
|
|
147
|
+
return @has_winner if defined? @has_winner
|
|
148
|
+
@has_winner = !winner.nil?
|
|
148
149
|
end
|
|
149
150
|
|
|
150
151
|
def winner=(winner_name)
|
|
151
152
|
redis.hset(:experiment_winner, name, winner_name.to_s)
|
|
153
|
+
@has_winner = true
|
|
152
154
|
end
|
|
153
155
|
|
|
154
156
|
def participant_count
|
|
@@ -161,6 +163,7 @@ module Split
|
|
|
161
163
|
|
|
162
164
|
def reset_winner
|
|
163
165
|
redis.hdel(:experiment_winner, name)
|
|
166
|
+
@has_winner = false
|
|
164
167
|
end
|
|
165
168
|
|
|
166
169
|
def start
|
|
@@ -262,10 +265,11 @@ module Split
|
|
|
262
265
|
end
|
|
263
266
|
|
|
264
267
|
def calc_winning_alternatives
|
|
265
|
-
#
|
|
266
|
-
|
|
268
|
+
# Cache the winning alternatives so we recalculate them once per the specified interval.
|
|
269
|
+
intervals_since_epoch =
|
|
270
|
+
Time.now.utc.to_i / Split.configuration.winning_alternative_recalculation_interval
|
|
267
271
|
|
|
268
|
-
if self.calc_time !=
|
|
272
|
+
if self.calc_time != intervals_since_epoch
|
|
269
273
|
if goals.empty?
|
|
270
274
|
self.estimate_winning_alternative
|
|
271
275
|
else
|
|
@@ -274,7 +278,7 @@ module Split
|
|
|
274
278
|
end
|
|
275
279
|
end
|
|
276
280
|
|
|
277
|
-
self.calc_time =
|
|
281
|
+
self.calc_time = intervals_since_epoch
|
|
278
282
|
|
|
279
283
|
self.save
|
|
280
284
|
end
|
|
@@ -419,14 +423,22 @@ module Split
|
|
|
419
423
|
end
|
|
420
424
|
|
|
421
425
|
def load_alternatives_from_redis
|
|
422
|
-
case redis.type(@name)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
426
|
+
alternatives = case redis.type(@name)
|
|
427
|
+
when 'set' # convert legacy sets to lists
|
|
428
|
+
alts = redis.smembers(@name)
|
|
429
|
+
redis.del(@name)
|
|
430
|
+
alts.reverse.each {|a| redis.lpush(@name, a) }
|
|
431
|
+
redis.lrange(@name, 0, -1)
|
|
432
|
+
else
|
|
433
|
+
redis.lrange(@name, 0, -1)
|
|
434
|
+
end
|
|
435
|
+
alternatives.map do |alt|
|
|
436
|
+
alt = begin
|
|
437
|
+
JSON.parse(alt)
|
|
438
|
+
rescue
|
|
439
|
+
alt
|
|
440
|
+
end
|
|
441
|
+
Split::Alternative.new(alt, @name)
|
|
430
442
|
end
|
|
431
443
|
end
|
|
432
444
|
|
|
@@ -442,7 +454,7 @@ module Split
|
|
|
442
454
|
|
|
443
455
|
def persist_experiment_configuration
|
|
444
456
|
redis_interface.add_to_set(:experiments, name)
|
|
445
|
-
redis_interface.persist_list(name, @alternatives.map
|
|
457
|
+
redis_interface.persist_list(name, @alternatives.map{|alt| {alt.name => alt.weight}.to_json})
|
|
446
458
|
goals_collection.save
|
|
447
459
|
redis.set(metadata_key, @metadata.to_json) unless @metadata.nil?
|
|
448
460
|
end
|
|
@@ -458,7 +470,7 @@ module Split
|
|
|
458
470
|
existing_alternatives = load_alternatives_from_redis
|
|
459
471
|
existing_goals = Split::GoalsCollection.new(@name).load_from_redis
|
|
460
472
|
existing_metadata = load_metadata_from_redis
|
|
461
|
-
existing_alternatives != @alternatives.map(&:
|
|
473
|
+
existing_alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
|
|
462
474
|
existing_goals != @goals ||
|
|
463
475
|
existing_metadata != @metadata
|
|
464
476
|
end
|
data/lib/split/helper.rb
CHANGED
|
@@ -8,8 +8,9 @@ module Split
|
|
|
8
8
|
def ab_test(metric_descriptor, control = nil, *alternatives)
|
|
9
9
|
begin
|
|
10
10
|
experiment = ExperimentCatalog.find_or_initialize(metric_descriptor, control, *alternatives)
|
|
11
|
-
alternative = if Split.configuration.enabled
|
|
11
|
+
alternative = if Split.configuration.enabled && !exclude_visitor?
|
|
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?)
|
|
@@ -43,6 +44,7 @@ module Split
|
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
def finish_experiment(experiment, options = {:reset => true})
|
|
47
|
+
return false if active_experiments[experiment.name].nil?
|
|
46
48
|
return true if experiment.has_winner?
|
|
47
49
|
should_reset = experiment.resettable? && options[:reset]
|
|
48
50
|
if ab_user[experiment.finished_key] && !should_reset
|
|
@@ -78,7 +80,7 @@ module Split
|
|
|
78
80
|
|
|
79
81
|
def ab_record_extra_info(metric_descriptor, key, value = 1)
|
|
80
82
|
return if exclude_visitor? || Split.configuration.disabled?
|
|
81
|
-
metric_descriptor,
|
|
83
|
+
metric_descriptor, _ = normalize_metric(metric_descriptor)
|
|
82
84
|
experiments = Metric.possible_experiments(metric_descriptor)
|
|
83
85
|
|
|
84
86
|
if experiments.any?
|
|
@@ -96,6 +98,14 @@ module Split
|
|
|
96
98
|
Split.configuration.db_failover_on_db_error.call(e)
|
|
97
99
|
end
|
|
98
100
|
|
|
101
|
+
def ab_active_experiments()
|
|
102
|
+
ab_user.active_experiments
|
|
103
|
+
rescue => e
|
|
104
|
+
raise unless Split.configuration.db_failover
|
|
105
|
+
Split.configuration.db_failover_on_db_error.call(e)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
|
|
99
109
|
def override_present?(experiment_name)
|
|
100
110
|
override_alternative(experiment_name)
|
|
101
111
|
end
|
|
@@ -113,13 +123,17 @@ module Split
|
|
|
113
123
|
end
|
|
114
124
|
|
|
115
125
|
def exclude_visitor?
|
|
116
|
-
|
|
126
|
+
defined?(request) && (instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?)
|
|
117
127
|
end
|
|
118
128
|
|
|
119
129
|
def is_robot?
|
|
120
130
|
defined?(request) && request.user_agent =~ Split.configuration.robot_regex
|
|
121
131
|
end
|
|
122
132
|
|
|
133
|
+
def is_preview?
|
|
134
|
+
defined?(request) && defined?(request.headers) && request.headers['x-purpose'] == 'preview'
|
|
135
|
+
end
|
|
136
|
+
|
|
123
137
|
def is_ignored_ip_address?
|
|
124
138
|
return false if Split.configuration.ignore_ip_addresses.empty?
|
|
125
139
|
|
|
@@ -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
|