split 3.4.1 → 4.0.0.pre

Sign up to get free protection for your applications and to get access to all the features.
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">