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.
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