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.
Files changed (69) 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/.rspec +1 -0
  6. data/.rubocop.yml +71 -1044
  7. data/.rubocop_todo.yml +226 -0
  8. data/.travis.yml +18 -39
  9. data/Appraisals +4 -0
  10. data/CHANGELOG.md +110 -0
  11. data/CODE_OF_CONDUCT.md +3 -3
  12. data/Gemfile +2 -0
  13. data/README.md +58 -23
  14. data/Rakefile +2 -0
  15. data/gemfiles/{4.2.gemfile → 6.0.gemfile} +1 -1
  16. data/lib/split.rb +16 -3
  17. data/lib/split/algorithms/block_randomization.rb +2 -0
  18. data/lib/split/algorithms/weighted_sample.rb +2 -1
  19. data/lib/split/algorithms/whiplash.rb +3 -2
  20. data/lib/split/alternative.rb +4 -3
  21. data/lib/split/cache.rb +28 -0
  22. data/lib/split/combined_experiments_helper.rb +3 -2
  23. data/lib/split/configuration.rb +15 -14
  24. data/lib/split/dashboard.rb +19 -1
  25. data/lib/split/dashboard/helpers.rb +3 -2
  26. data/lib/split/dashboard/pagination_helpers.rb +4 -4
  27. data/lib/split/dashboard/paginator.rb +1 -0
  28. data/lib/split/dashboard/public/dashboard.js +10 -0
  29. data/lib/split/dashboard/public/style.css +5 -0
  30. data/lib/split/dashboard/views/_controls.erb +13 -0
  31. data/lib/split/dashboard/views/layout.erb +1 -1
  32. data/lib/split/encapsulated_helper.rb +3 -2
  33. data/lib/split/engine.rb +7 -4
  34. data/lib/split/exceptions.rb +1 -0
  35. data/lib/split/experiment.rb +98 -65
  36. data/lib/split/experiment_catalog.rb +1 -3
  37. data/lib/split/extensions/string.rb +1 -0
  38. data/lib/split/goals_collection.rb +2 -0
  39. data/lib/split/helper.rb +30 -10
  40. data/lib/split/metric.rb +2 -1
  41. data/lib/split/persistence.rb +4 -2
  42. data/lib/split/persistence/cookie_adapter.rb +1 -0
  43. data/lib/split/persistence/dual_adapter.rb +54 -12
  44. data/lib/split/persistence/redis_adapter.rb +5 -0
  45. data/lib/split/persistence/session_adapter.rb +1 -0
  46. data/lib/split/redis_interface.rb +9 -28
  47. data/lib/split/trial.rb +25 -17
  48. data/lib/split/user.rb +19 -3
  49. data/lib/split/version.rb +2 -4
  50. data/lib/split/zscore.rb +1 -0
  51. data/spec/alternative_spec.rb +1 -1
  52. data/spec/cache_spec.rb +88 -0
  53. data/spec/configuration_spec.rb +1 -14
  54. data/spec/dashboard/pagination_helpers_spec.rb +3 -1
  55. data/spec/dashboard_helpers_spec.rb +2 -2
  56. data/spec/dashboard_spec.rb +78 -17
  57. data/spec/encapsulated_helper_spec.rb +2 -2
  58. data/spec/experiment_spec.rb +116 -12
  59. data/spec/goals_collection_spec.rb +1 -1
  60. data/spec/helper_spec.rb +191 -112
  61. data/spec/persistence/cookie_adapter_spec.rb +1 -1
  62. data/spec/persistence/dual_adapter_spec.rb +160 -68
  63. data/spec/persistence/redis_adapter_spec.rb +9 -0
  64. data/spec/redis_interface_spec.rb +0 -69
  65. data/spec/spec_helper.rb +5 -6
  66. data/spec/trial_spec.rb +65 -19
  67. data/spec/user_spec.rb +28 -0
  68. data/split.gemspec +9 -9
  69. metadata +34 -28
data/Rakefile CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env rake
2
+ # frozen_string_literal: true
3
+
2
4
  require 'bundler/gem_tasks'
3
5
  require 'rspec/core/rake_task'
4
6
  require 'appraisal'
@@ -4,6 +4,6 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "appraisal"
6
6
  gem "codeclimate-test-reporter"
7
- gem "rails", "~> 4.2"
7
+ gem "rails", "~> 6.0"
8
8
 
9
9
  gemspec path: "../"
@@ -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(:url => server, :thread_safe => true)
40
+ Redis.new(url: server)
39
41
  elsif server.is_a?(Hash)
40
- Redis.new(server.merge(:thread_safe => true))
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
- Split.configure {}
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,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Selects alternative with minimum count of participants
2
4
  # If all counts are even (i.e. all are minimum), samples from all possible alternatives
3
5
 
@@ -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 '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
@@ -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
- 'Pinterest' => 'Pinterest Bot',
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
- 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
234
+ @dashboard_pagination_default_per_page = 10
234
235
  end
235
236
 
236
237
  private
@@ -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
  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 "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