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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.eslintrc +1 -1
  3. data/.github/FUNDING.yml +1 -0
  4. data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
  5. data/.github/dependabot.yml +7 -0
  6. data/.github/workflows/ci.yml +71 -0
  7. data/.rspec +1 -0
  8. data/.rubocop.yml +71 -1044
  9. data/.rubocop_todo.yml +226 -0
  10. data/Appraisals +4 -0
  11. data/CHANGELOG.md +116 -0
  12. data/CODE_OF_CONDUCT.md +3 -3
  13. data/Gemfile +2 -0
  14. data/README.md +63 -26
  15. data/Rakefile +2 -0
  16. data/gemfiles/{4.2.gemfile → 6.0.gemfile} +1 -1
  17. data/gemfiles/6.1.gemfile +9 -0
  18. data/gemfiles/7.0.gemfile +9 -0
  19. data/lib/split/algorithms/block_randomization.rb +2 -0
  20. data/lib/split/algorithms/weighted_sample.rb +2 -1
  21. data/lib/split/algorithms/whiplash.rb +3 -2
  22. data/lib/split/alternative.rb +4 -3
  23. data/lib/split/cache.rb +28 -0
  24. data/lib/split/combined_experiments_helper.rb +3 -2
  25. data/lib/split/configuration.rb +17 -14
  26. data/lib/split/dashboard/helpers.rb +3 -2
  27. data/lib/split/dashboard/pagination_helpers.rb +4 -4
  28. data/lib/split/dashboard/paginator.rb +1 -0
  29. data/lib/split/dashboard/public/dashboard.js +10 -0
  30. data/lib/split/dashboard/public/style.css +5 -0
  31. data/lib/split/dashboard/views/_controls.erb +13 -0
  32. data/lib/split/dashboard/views/layout.erb +1 -1
  33. data/lib/split/dashboard.rb +19 -1
  34. data/lib/split/encapsulated_helper.rb +3 -2
  35. data/lib/split/engine.rb +7 -4
  36. data/lib/split/exceptions.rb +1 -0
  37. data/lib/split/experiment.rb +98 -65
  38. data/lib/split/experiment_catalog.rb +1 -3
  39. data/lib/split/extensions/string.rb +1 -0
  40. data/lib/split/goals_collection.rb +2 -0
  41. data/lib/split/helper.rb +30 -10
  42. data/lib/split/metric.rb +2 -1
  43. data/lib/split/persistence/cookie_adapter.rb +6 -1
  44. data/lib/split/persistence/dual_adapter.rb +54 -12
  45. data/lib/split/persistence/redis_adapter.rb +5 -0
  46. data/lib/split/persistence/session_adapter.rb +1 -0
  47. data/lib/split/persistence.rb +4 -2
  48. data/lib/split/redis_interface.rb +9 -28
  49. data/lib/split/trial.rb +25 -17
  50. data/lib/split/user.rb +20 -4
  51. data/lib/split/version.rb +2 -4
  52. data/lib/split/zscore.rb +1 -0
  53. data/lib/split.rb +16 -3
  54. data/spec/alternative_spec.rb +1 -1
  55. data/spec/cache_spec.rb +88 -0
  56. data/spec/configuration_spec.rb +17 -15
  57. data/spec/dashboard/pagination_helpers_spec.rb +3 -1
  58. data/spec/dashboard_helpers_spec.rb +2 -2
  59. data/spec/dashboard_spec.rb +78 -17
  60. data/spec/encapsulated_helper_spec.rb +2 -2
  61. data/spec/experiment_spec.rb +116 -12
  62. data/spec/goals_collection_spec.rb +1 -1
  63. data/spec/helper_spec.rb +191 -112
  64. data/spec/persistence/cookie_adapter_spec.rb +1 -1
  65. data/spec/persistence/dual_adapter_spec.rb +160 -68
  66. data/spec/persistence/redis_adapter_spec.rb +9 -0
  67. data/spec/redis_interface_spec.rb +0 -69
  68. data/spec/spec_helper.rb +5 -6
  69. data/spec/trial_spec.rb +65 -19
  70. data/spec/user_spec.rb +45 -3
  71. data/split.gemspec +9 -9
  72. metadata +38 -29
  73. 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 'simple-random'
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
- s = SimpleRandom.new; s.set_seed; s.beta(a+fairness_constant, b+fairness_constant)
20
+ Rubystats::BetaDistribution.new(a+fairness_constant, b+fairness_constant).rng
20
21
  end
21
22
 
22
23
  def best_guess(alternatives)
@@ -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 (count, goal = nil)
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
- z_score = Split::Zscore.calculate(p_a, n_a, p_c, n_c)
126
+ Split::Zscore.calculate(p_a, n_a, p_c, n_c)
126
127
  end
127
128
 
128
129
  def extra_info
@@ -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, 'Unable to find experiment #{metric_descriptor} in configuration') if experiment[:combined_experiments].nil?
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
- experiment = Split::configuration.experiments[metric_descriptor.to_sym]
35
+ Split::configuration.experiments[metric_descriptor.to_sym]
35
36
  end
36
37
  end
37
38
  end
@@ -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
- 'Pinterest' => 'Pinterest Bot',
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
- end
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.new(number.to_s)
23
+ BigDecimal(number.to_s)
23
24
  rescue ArgumentError
24
- BigDecimal.new(0)
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
- @pagination_per ||= (params[:per] || DEFAULT_PER).to_i
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 << last_page_tagcollection if show_last_page_tag?(collection)
28
+ html << last_page_tag(collection) if show_last_page_tag?(collection)
29
29
  html.join
30
30
  end
31
31
 
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
4
  class DashboardPaginator
4
5
  def initialize(collection, page_number, per)
@@ -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
+ }
@@ -326,3 +326,8 @@ a.button.green:focus, button.green:focus, input[type="submit"].green:focus {
326
326
  display: inline-block;
327
327
  padding: 5px;
328
328
  }
329
+
330
+ .divider {
331
+ display: inline-block;
332
+ margin-left: 10px;
333
+ }
@@ -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">
@@ -21,7 +21,7 @@
21
21
  </div>
22
22
 
23
23
  <div id="footer">
24
- <p>Powered by <a href="http://github.com/splitrb/split">Split</a> v<%=Split::VERSION %></p>
24
+ <p>Powered by <a href="https://github.com/splitrb/split">Split</a> v<%=Split::VERSION %></p>
25
25
  </div>
26
26
  </body>
27
27
  </html>
@@ -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::User.new(self)[params[:experiment]] = params[:alternative]
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,&block)
32
- split_context_shim.ab_test(*arguments,&block)
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
- ActionController::Base.send :include, Split::Helper
7
- ActionController::Base.helper Split::Helper
8
- ActionController::Base.send :include, Split::CombinedExperimentsHelper
9
- ActionController::Base.helper Split::CombinedExperimentsHelper
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
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
4
  class InvalidPersistenceAdapterError < StandardError; end
4
5
  class ExperimentNotFound < StandardError; end