split 3.4.1 → 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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.rubocop.yml +177 -1
  4. data/.rubocop_todo.yml +40 -493
  5. data/.travis.yml +14 -42
  6. data/CHANGELOG.md +35 -0
  7. data/Gemfile +1 -0
  8. data/README.md +19 -1
  9. data/Rakefile +1 -0
  10. data/lib/split.rb +8 -2
  11. data/lib/split/algorithms/block_randomization.rb +1 -0
  12. data/lib/split/algorithms/weighted_sample.rb +2 -1
  13. data/lib/split/algorithms/whiplash.rb +3 -2
  14. data/lib/split/alternative.rb +1 -0
  15. data/lib/split/cache.rb +28 -0
  16. data/lib/split/combined_experiments_helper.rb +1 -0
  17. data/lib/split/configuration.rb +6 -12
  18. data/lib/split/dashboard.rb +17 -2
  19. data/lib/split/dashboard/helpers.rb +1 -0
  20. data/lib/split/dashboard/pagination_helpers.rb +1 -0
  21. data/lib/split/dashboard/paginator.rb +1 -0
  22. data/lib/split/dashboard/public/dashboard.js +10 -0
  23. data/lib/split/dashboard/public/style.css +5 -0
  24. data/lib/split/dashboard/views/_controls.erb +13 -0
  25. data/lib/split/encapsulated_helper.rb +3 -2
  26. data/lib/split/engine.rb +1 -0
  27. data/lib/split/exceptions.rb +1 -0
  28. data/lib/split/experiment.rb +81 -59
  29. data/lib/split/experiment_catalog.rb +1 -3
  30. data/lib/split/extensions/string.rb +1 -0
  31. data/lib/split/goals_collection.rb +1 -0
  32. data/lib/split/helper.rb +26 -7
  33. data/lib/split/metric.rb +2 -1
  34. data/lib/split/persistence.rb +4 -2
  35. data/lib/split/persistence/cookie_adapter.rb +1 -0
  36. data/lib/split/persistence/redis_adapter.rb +5 -0
  37. data/lib/split/persistence/session_adapter.rb +1 -0
  38. data/lib/split/redis_interface.rb +8 -28
  39. data/lib/split/trial.rb +20 -10
  40. data/lib/split/user.rb +14 -2
  41. data/lib/split/version.rb +2 -4
  42. data/lib/split/zscore.rb +1 -0
  43. data/spec/alternative_spec.rb +1 -1
  44. data/spec/cache_spec.rb +88 -0
  45. data/spec/configuration_spec.rb +1 -14
  46. data/spec/dashboard_spec.rb +45 -5
  47. data/spec/encapsulated_helper_spec.rb +1 -1
  48. data/spec/experiment_spec.rb +78 -7
  49. data/spec/goals_collection_spec.rb +1 -1
  50. data/spec/helper_spec.rb +68 -32
  51. data/spec/persistence/cookie_adapter_spec.rb +1 -1
  52. data/spec/persistence/redis_adapter_spec.rb +9 -0
  53. data/spec/redis_interface_spec.rb +0 -69
  54. data/spec/spec_helper.rb +5 -6
  55. data/spec/trial_spec.rb +45 -19
  56. data/spec/user_spec.rb +17 -0
  57. data/split.gemspec +7 -7
  58. metadata +23 -34
  59. data/gemfiles/4.2.gemfile +0 -9
@@ -1,60 +1,32 @@
1
1
  language: ruby
2
2
  rvm:
3
- - 1.9.3
4
- - 2.0
5
- - 2.1.10
6
- - 2.2.2
7
- - 2.3.8
8
- - 2.4.9
9
3
  - 2.5.7
10
- - 2.6.5
4
+ - 2.6.6
5
+ - 2.7.1
6
+
7
+ services:
8
+ - redis-server
11
9
 
12
10
  gemfile:
13
- - gemfiles/4.2.gemfile
14
11
  - gemfiles/5.0.gemfile
15
12
  - gemfiles/5.1.gemfile
16
13
  - gemfiles/5.2.gemfile
17
14
  - gemfiles/6.0.gemfile
18
15
 
19
- matrix:
20
- exclude:
21
- - rvm: 1.9.3
22
- gemfile: gemfiles/5.0.gemfile
23
- - rvm: 1.9.3
24
- gemfile: gemfiles/5.1.gemfile
25
- - rvm: 1.9.3
26
- gemfile: gemfiles/5.2.gemfile
27
- - rvm: 1.9.3
28
- gemfile: gemfiles/6.0.gemfile
29
- - rvm: 2.0
30
- gemfile: gemfiles/5.0.gemfile
31
- - rvm: 2.0
32
- gemfile: gemfiles/5.1.gemfile
33
- - rvm: 2.0
34
- gemfile: gemfiles/5.2.gemfile
35
- - rvm: 2.0
36
- gemfile: gemfiles/6.0.gemfile
37
- - rvm: 2.1.10
38
- gemfile: gemfiles/5.0.gemfile
39
- - rvm: 2.1.10
40
- gemfile: gemfiles/5.1.gemfile
41
- - rvm: 2.1.10
42
- gemfile: gemfiles/5.2.gemfile
43
- - rvm: 2.1.10
44
- gemfile: gemfiles/6.0.gemfile
45
- - rvm: 2.2.2
46
- gemfile: gemfiles/6.0.gemfile
47
- - rvm: 2.3.8
48
- gemfile: gemfiles/6.0.gemfile
49
- - rvm: 2.4.9
50
- gemfile: gemfiles/6.0.gemfile
51
-
52
16
  before_install:
53
17
  - gem uninstall -v '>= 2' -i $(rvm gemdir)@global -ax bundler || true
54
18
  - gem install bundler --version=1.17.3
55
19
 
20
+ before_script:
21
+ - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
22
+ - chmod +x ./cc-test-reporter
23
+ - ./cc-test-reporter before-build
24
+
56
25
  script:
57
- - RAILS_ENV=test bundle exec rake spec && bundle exec codeclimate-test-reporter
26
+ - RAILS_ENV=test bundle exec rake spec
27
+
28
+ after_script:
29
+ - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT
58
30
 
59
31
  cache: bundler
60
32
  sudo: false
@@ -1,3 +1,38 @@
1
+ ## 4.0.0.pre
2
+
3
+ Bugfixes:
4
+ - ab_test must return metadata on error or if split is disabled/excluded user (@andrehjr, #622)
5
+ - Fix versioned experiments when used with allow_multiple_experiments=control (@andrehjr, #613)
6
+ - Only block Pinterest bot (@huoxito, #606)
7
+ - Respect experiment defaults when loading experiments in initializer. (@mattwd7, #599)
8
+ - Removes metadata key when it updated to nil (@andrehjr, #633)
9
+
10
+ Features:
11
+ - Make goals accessible via on_trial_complete callbacks (@robin-phung, #625)
12
+ - Replace usage of SimpleRandom with RubyStats(Used for Beta Distribution RNG) (@andrehjr, #616)
13
+ - Introduce enable/disable experiment cohorting (@robin-phung, #615)
14
+ - Add on_experiment_winner_choose callback (@GenaMinenkov, #574)
15
+ - Add Split::Cache to reduce load on Redis (@rdh, #648)
16
+ - Caching based optimization in the experiment#save path (@amangup, #652)
17
+
18
+ Misc:
19
+ - Drop support for Ruby < 2.5 (@andrehjr, #627)
20
+ - Drop support for Rails < 5 (@andrehkr, #607)
21
+ - Bump minimum required redis to 4.2 (@andrehjr, #628)
22
+ - Removed repeated loading from config (@robin-phung, #619)
23
+ - Simplify RedisInterface usage when persisting Experiment alternatives (@andrehjr, #632)
24
+ - Remove redis_url impl. Deprecated on version 2.2 (@andrehjr, #631)
25
+ - Remove thread_safe config as redis-rb is thread_safe by default (@andrehjr, #630)
26
+ - Fix typo of in `Split::Trial` class variable (TomasBarry, #644)
27
+ - Single HSET to update values, instead of multiple ones (@andrehjr, #640)
28
+ - Remove 'set' parsing for alternatives. Sets were used as storage and deprecated on 0.x (@andrehjr, #639)
29
+ - Adding documentation related to what is stored on cookies. (@andrehjr, #634)
30
+
31
+ ## 3.4.1 (November 12th, 2019)
32
+
33
+ Bugfixes:
34
+ - Reference ActionController directly when including split helpers, to avoid breaking Rails API Controllers (@andrehjr, #602)
35
+
1
36
  ## 3.4.0 (November 9th, 2019)
2
37
 
3
38
  Features:
data/Gemfile CHANGED
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  source "https://rubygems.org"
3
4
 
4
5
  gemspec
data/README.md CHANGED
@@ -263,7 +263,7 @@ Split.configure do |config|
263
263
  end
264
264
  ```
265
265
 
266
- By default, cookies will expire in 1 year. To change that, set the `persistence_cookie_length` in the configuration (unit of time in seconds).
266
+ When using the cookie persistence, Split stores data into an anonymous tracking cookie named 'split', which expires in 1 year. To change that, set the `persistence_cookie_length` in the configuration (unit of time in seconds).
267
267
 
268
268
  ```ruby
269
269
  Split.configure do |config|
@@ -272,6 +272,8 @@ Split.configure do |config|
272
272
  end
273
273
  ```
274
274
 
275
+ The data stored consists of the experiment name and the variants the user is in. Example: { "experiment_name" => "variant_a" }
276
+
275
277
  __Note:__ Using cookies depends on `ActionDispatch::Cookies` or any identical API
276
278
 
277
279
  #### Redis
@@ -386,6 +388,8 @@ Split.configure do |config|
386
388
  # before experiment reset or deleted
387
389
  config.on_before_experiment_reset = -> (example) { # Do something on reset }
388
390
  config.on_before_experiment_delete = -> (experiment) { # Do something else on delete }
391
+ # after experiment winner had been set
392
+ config.on_experiment_winner_choose = -> (experiment) { # Do something on winner choose }
389
393
  end
390
394
  ```
391
395
 
@@ -752,6 +756,20 @@ split_config = YAML.load_file(Rails.root.join('config', 'split.yml'))
752
756
  Split.redis = split_config[Rails.env]
753
757
  ```
754
758
 
759
+ ### Redis Caching (v4.0+)
760
+
761
+ In some high-volume usage scenarios, Redis load can be incurred by repeated
762
+ fetches for fairly static data. Enabling caching will reduce this load.
763
+
764
+ ```ruby
765
+ Split.configuration.cache = true
766
+ ````
767
+
768
+ This currently caches:
769
+ - `Split::ExperimentCatalog.find`
770
+ - `Split::Experiment.start_time`
771
+ - `Split::Experiment.winner`
772
+
755
773
  ## Namespaces
756
774
 
757
775
  If you're running multiple, separate instances of Split you may want
data/Rakefile CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env rake
2
2
  # frozen_string_literal: true
3
+
3
4
  require 'bundler/gem_tasks'
4
5
  require 'rspec/core/rake_task'
5
6
  require 'appraisal'
@@ -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,10 @@ 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.
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  # Selects alternative with minimum count of participants
3
4
  # If all counts are even (i.e. all are minimum), samples from all possible alternatives
4
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
@@ -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,4 +1,5 @@
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)
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
4
  class Configuration
4
5
  attr_accessor :ignore_ip_addresses
@@ -20,12 +21,14 @@ module Split
20
21
  attr_accessor :on_experiment_reset
21
22
  attr_accessor :on_experiment_delete
22
23
  attr_accessor :on_before_experiment_reset
24
+ attr_accessor :on_experiment_winner_choose
23
25
  attr_accessor :on_before_experiment_delete
24
26
  attr_accessor :include_rails_helper
25
27
  attr_accessor :beta_probability_simulations
26
28
  attr_accessor :winning_alternative_recalculation_interval
27
29
  attr_accessor :redis
28
30
  attr_accessor :dashboard_pagination_default_per_page
31
+ attr_accessor :cache
29
32
 
30
33
  attr_reader :experiments
31
34
 
@@ -84,7 +87,7 @@ module Split
84
87
  'LinkedInBot' => 'LinkedIn bot',
85
88
  'LongURL' => 'URL expander service',
86
89
  'NING' => 'NING - Yet Another Twitter Swarmer',
87
- 'Pinterest' => 'Pinterest Bot',
90
+ 'Pinterestbot' => 'Pinterest Bot',
88
91
  'redditbot' => 'Reddit Bot',
89
92
  'ShortLinkTranslate' => 'Link shortener',
90
93
  'Slackbot' => 'Slackbot link expander',
@@ -173,7 +176,7 @@ module Split
173
176
  end
174
177
 
175
178
  def normalize_alternatives(alternatives)
176
- 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|
177
180
  p, n = a
178
181
  if percent = value_for(v, :percent)
179
182
  [p + percent, n + 1]
@@ -216,6 +219,7 @@ module Split
216
219
  @on_experiment_delete = proc{|experiment|}
217
220
  @on_before_experiment_reset = proc{|experiment|}
218
221
  @on_before_experiment_delete = proc{|experiment|}
222
+ @on_experiment_winner_choose = proc{|experiment|}
219
223
  @db_failover_allow_parameter_override = false
220
224
  @allow_multiple_experiments = false
221
225
  @enabled = true
@@ -230,16 +234,6 @@ module Split
230
234
  @dashboard_pagination_default_per_page = 10
231
235
  end
232
236
 
233
- def redis_url=(value)
234
- warn '[DEPRECATED] `redis_url=` is deprecated in favor of `redis=`'
235
- self.redis = value
236
- end
237
-
238
- def redis_url
239
- warn '[DEPRECATED] `redis_url` is deprecated in favor of `redis`'
240
- self.redis
241
- end
242
-
243
237
  private
244
238
 
245
239
  def value_for(hash, key)
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'sinatra/base'
3
4
  require 'split'
4
5
  require 'bigdecimal'
@@ -35,8 +36,11 @@ module Split
35
36
  post '/force_alternative' do
36
37
  experiment = Split::ExperimentCatalog.find(params[:experiment])
37
38
  alternative = Split::Alternative.new(params[:alternative], experiment.name)
38
- alternative.increment_participation
39
- Split::User.new(self)[experiment.key] = alternative.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
+
40
44
  redirect url('/')
41
45
  end
42
46
 
@@ -65,6 +69,17 @@ module Split
65
69
  redirect url('/')
66
70
  end
67
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
+
68
83
  delete '/experiment' do
69
84
  @experiment = Split::ExperimentCatalog.find(params[:experiment])
70
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)
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'split/dashboard/paginator'
3
4
 
4
5
  module Split
@@ -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">