split 3.4.0 → 4.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/dependabot.yml +7 -0
  4. data/.github/workflows/ci.yml +71 -0
  5. data/.rubocop.yml +177 -1
  6. data/.rubocop_todo.yml +40 -493
  7. data/CHANGELOG.md +41 -0
  8. data/Gemfile +1 -0
  9. data/README.md +26 -6
  10. data/Rakefile +1 -0
  11. data/gemfiles/{4.2.gemfile → 6.1.gemfile} +1 -1
  12. data/gemfiles/7.0.gemfile +9 -0
  13. data/lib/split/algorithms/block_randomization.rb +1 -0
  14. data/lib/split/algorithms/weighted_sample.rb +2 -1
  15. data/lib/split/algorithms/whiplash.rb +3 -2
  16. data/lib/split/alternative.rb +1 -0
  17. data/lib/split/cache.rb +28 -0
  18. data/lib/split/combined_experiments_helper.rb +1 -0
  19. data/lib/split/configuration.rb +8 -12
  20. data/lib/split/dashboard/helpers.rb +1 -0
  21. data/lib/split/dashboard/pagination_helpers.rb +1 -0
  22. data/lib/split/dashboard/paginator.rb +1 -0
  23. data/lib/split/dashboard/public/dashboard.js +10 -0
  24. data/lib/split/dashboard/public/style.css +5 -0
  25. data/lib/split/dashboard/views/_controls.erb +13 -0
  26. data/lib/split/dashboard.rb +17 -2
  27. data/lib/split/encapsulated_helper.rb +3 -2
  28. data/lib/split/engine.rb +5 -4
  29. data/lib/split/exceptions.rb +1 -0
  30. data/lib/split/experiment.rb +82 -60
  31. data/lib/split/experiment_catalog.rb +1 -3
  32. data/lib/split/extensions/string.rb +1 -0
  33. data/lib/split/goals_collection.rb +1 -0
  34. data/lib/split/helper.rb +26 -7
  35. data/lib/split/metric.rb +2 -1
  36. data/lib/split/persistence/cookie_adapter.rb +6 -1
  37. data/lib/split/persistence/redis_adapter.rb +5 -0
  38. data/lib/split/persistence/session_adapter.rb +1 -0
  39. data/lib/split/persistence.rb +4 -2
  40. data/lib/split/redis_interface.rb +8 -28
  41. data/lib/split/trial.rb +20 -10
  42. data/lib/split/user.rb +15 -3
  43. data/lib/split/version.rb +2 -4
  44. data/lib/split/zscore.rb +1 -0
  45. data/lib/split.rb +9 -3
  46. data/spec/alternative_spec.rb +1 -1
  47. data/spec/cache_spec.rb +88 -0
  48. data/spec/configuration_spec.rb +17 -15
  49. data/spec/dashboard_spec.rb +45 -5
  50. data/spec/encapsulated_helper_spec.rb +1 -1
  51. data/spec/experiment_spec.rb +78 -13
  52. data/spec/goals_collection_spec.rb +1 -1
  53. data/spec/helper_spec.rb +68 -32
  54. data/spec/persistence/cookie_adapter_spec.rb +1 -1
  55. data/spec/persistence/redis_adapter_spec.rb +9 -0
  56. data/spec/redis_interface_spec.rb +0 -69
  57. data/spec/spec_helper.rb +5 -6
  58. data/spec/trial_spec.rb +45 -19
  59. data/spec/user_spec.rb +34 -3
  60. data/split.gemspec +7 -7
  61. metadata +27 -35
  62. data/.travis.yml +0 -60
data/CHANGELOG.md CHANGED
@@ -1,3 +1,44 @@
1
+ ## 4.0.1 (December 30th, 2021)
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
+ - Force experiment does not count for metrics (@andrehjr, #637)
10
+ - Fix cleanup_old_versions! misbehaviour (@serggl, #661)
11
+
12
+ Features:
13
+ - Make goals accessible via on_trial_complete callbacks (@robin-phung, #625)
14
+ - Replace usage of SimpleRandom with RubyStats(Used for Beta Distribution RNG) (@andrehjr, #616)
15
+ - Introduce enable/disable experiment cohorting (@robin-phung, #615)
16
+ - Add on_experiment_winner_choose callback (@GenaMinenkov, #574)
17
+ - Add Split::Cache to reduce load on Redis (@rdh, #648)
18
+ - Caching based optimization in the experiment#save path (@amangup, #652)
19
+ - Adds config option for cookie domain (@joedelia, #664)
20
+
21
+ Misc:
22
+ - Drop support for Ruby < 2.5 (@andrehjr, #627)
23
+ - Drop support for Rails < 5 (@andrehjr, #607)
24
+ - Bump minimum required redis to 4.2 (@andrehjr, #628)
25
+ - Removed repeated loading from config (@robin-phung, #619)
26
+ - Simplify RedisInterface usage when persisting Experiment alternatives (@andrehjr, #632)
27
+ - Remove redis_url impl. Deprecated on version 2.2 (@andrehjr, #631)
28
+ - Remove thread_safe config as redis-rb is thread_safe by default (@andrehjr, #630)
29
+ - Fix typo of in `Split::Trial` class variable (TomasBarry, #644)
30
+ - Single HSET to update values, instead of multiple ones (@andrehjr, #640)
31
+ - Use Redis#hmset to keep compatibility with Redis < 4.0 (@andrehjr, #659)
32
+ - Remove 'set' parsing for alternatives. Sets were used as storage and deprecated on 0.x (@andrehjr, #639)
33
+ - Adding documentation related to what is stored on cookies. (@andrehjr, #634)
34
+ - Keep railtie defined under the Split gem namespace (@avit, #666)
35
+ - Update RSpec helper to support block syntax (@clowder, #665)
36
+
37
+ ## 3.4.1 (November 12th, 2019)
38
+
39
+ Bugfixes:
40
+ - Reference ActionController directly when including split helpers, to avoid breaking Rails API Controllers (@andrehjr, #602)
41
+
1
42
  ## 3.4.0 (November 9th, 2019)
2
43
 
3
44
  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
@@ -1,7 +1,7 @@
1
1
  # [Split](https://libraries.io/rubygems/split)
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/split.svg)](http://badge.fury.io/rb/split)
4
- [![Build Status](https://secure.travis-ci.org/splitrb/split.svg?branch=master)](https://travis-ci.org/splitrb/split)
4
+ ![Build status](https://github.com/splitrb/split/actions/workflows/ci.yml/badge.svg?branch=main)
5
5
  [![Code Climate](https://codeclimate.com/github/splitrb/split/badges/gpa.svg)](https://codeclimate.com/github/splitrb/split)
6
6
  [![Test Coverage](https://codeclimate.com/github/splitrb/split/badges/coverage.svg)](https://codeclimate.com/github/splitrb/split/coverage)
7
7
  [![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme)
@@ -175,8 +175,10 @@ module SplitHelper
175
175
  # use_ab_test(signup_form: "single_page", pricing: "show_enterprise_prices")
176
176
  #
177
177
  def use_ab_test(alternatives_by_experiment)
178
- allow_any_instance_of(Split::Helper).to receive(:ab_test) do |_receiver, experiment|
179
- alternatives_by_experiment.fetch(experiment) { |key| raise "Unknown experiment '#{key}'" }
178
+ allow_any_instance_of(Split::Helper).to receive(:ab_test) do |_receiver, experiment, &block|
179
+ variant = alternatives_by_experiment.fetch(experiment) { |key| raise "Unknown experiment '#{key}'" }
180
+ block.call(variant) unless block.nil?
181
+ variant
180
182
  end
181
183
  end
182
184
  end
@@ -263,7 +265,7 @@ Split.configure do |config|
263
265
  end
264
266
  ```
265
267
 
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).
268
+ 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
269
 
268
270
  ```ruby
269
271
  Split.configure do |config|
@@ -272,6 +274,8 @@ Split.configure do |config|
272
274
  end
273
275
  ```
274
276
 
277
+ The data stored consists of the experiment name and the variants the user is in. Example: { "experiment_name" => "variant_a" }
278
+
275
279
  __Note:__ Using cookies depends on `ActionDispatch::Cookies` or any identical API
276
280
 
277
281
  #### Redis
@@ -386,6 +390,8 @@ Split.configure do |config|
386
390
  # before experiment reset or deleted
387
391
  config.on_before_experiment_reset = -> (example) { # Do something on reset }
388
392
  config.on_before_experiment_delete = -> (experiment) { # Do something else on delete }
393
+ # after experiment winner had been set
394
+ config.on_experiment_winner_choose = -> (experiment) { # Do something on winner choose }
389
395
  end
390
396
  ```
391
397
 
@@ -644,7 +650,7 @@ The API to define goals for an experiment is this:
644
650
  ab_test({link_color: ["purchase", "refund"]}, "red", "blue")
645
651
  ```
646
652
 
647
- or you can you can define them in a configuration file:
653
+ or you can define them in a configuration file:
648
654
 
649
655
  ```ruby
650
656
  Split.configure do |config|
@@ -752,6 +758,20 @@ split_config = YAML.load_file(Rails.root.join('config', 'split.yml'))
752
758
  Split.redis = split_config[Rails.env]
753
759
  ```
754
760
 
761
+ ### Redis Caching (v4.0+)
762
+
763
+ In some high-volume usage scenarios, Redis load can be incurred by repeated
764
+ fetches for fairly static data. Enabling caching will reduce this load.
765
+
766
+ ```ruby
767
+ Split.configuration.cache = true
768
+ ````
769
+
770
+ This currently caches:
771
+ - `Split::ExperimentCatalog.find`
772
+ - `Split::Experiment.start_time`
773
+ - `Split::Experiment.winner`
774
+
755
775
  ## Namespaces
756
776
 
757
777
  If you're running multiple, separate instances of Split you may want
@@ -768,7 +788,7 @@ library. To configure Split to use `Redis::Namespace`, do the following:
768
788
  ```
769
789
 
770
790
  2. Configure `Split.redis` to use a `Redis::Namespace` instance (possible in an
771
- intializer):
791
+ initializer):
772
792
 
773
793
  ```ruby
774
794
  redis = Redis.new(url: ENV['REDIS_URL']) # or whatever config you 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'
@@ -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.1"
8
8
 
9
9
  gemspec path: "../"
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "codeclimate-test-reporter"
7
+ gem "rails", "~> 7.0"
8
+
9
+ gemspec path: "../"
@@ -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
@@ -10,6 +11,7 @@ module Split
10
11
  attr_accessor :enabled
11
12
  attr_accessor :persistence
12
13
  attr_accessor :persistence_cookie_length
14
+ attr_accessor :persistence_cookie_domain
13
15
  attr_accessor :algorithm
14
16
  attr_accessor :store_override
15
17
  attr_accessor :start_manually
@@ -20,12 +22,14 @@ module Split
20
22
  attr_accessor :on_experiment_reset
21
23
  attr_accessor :on_experiment_delete
22
24
  attr_accessor :on_before_experiment_reset
25
+ attr_accessor :on_experiment_winner_choose
23
26
  attr_accessor :on_before_experiment_delete
24
27
  attr_accessor :include_rails_helper
25
28
  attr_accessor :beta_probability_simulations
26
29
  attr_accessor :winning_alternative_recalculation_interval
27
30
  attr_accessor :redis
28
31
  attr_accessor :dashboard_pagination_default_per_page
32
+ attr_accessor :cache
29
33
 
30
34
  attr_reader :experiments
31
35
 
@@ -84,7 +88,7 @@ module Split
84
88
  'LinkedInBot' => 'LinkedIn bot',
85
89
  'LongURL' => 'URL expander service',
86
90
  'NING' => 'NING - Yet Another Twitter Swarmer',
87
- 'Pinterest' => 'Pinterest Bot',
91
+ 'Pinterestbot' => 'Pinterest Bot',
88
92
  'redditbot' => 'Reddit Bot',
89
93
  'ShortLinkTranslate' => 'Link shortener',
90
94
  'Slackbot' => 'Slackbot link expander',
@@ -173,7 +177,7 @@ module Split
173
177
  end
174
178
 
175
179
  def normalize_alternatives(alternatives)
176
- 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|
177
181
  p, n = a
178
182
  if percent = value_for(v, :percent)
179
183
  [p + percent, n + 1]
@@ -216,12 +220,14 @@ module Split
216
220
  @on_experiment_delete = proc{|experiment|}
217
221
  @on_before_experiment_reset = proc{|experiment|}
218
222
  @on_before_experiment_delete = proc{|experiment|}
223
+ @on_experiment_winner_choose = proc{|experiment|}
219
224
  @db_failover_allow_parameter_override = false
220
225
  @allow_multiple_experiments = false
221
226
  @enabled = true
222
227
  @experiments = {}
223
228
  @persistence = Split::Persistence::SessionAdapter
224
229
  @persistence_cookie_length = 31536000 # One year from now
230
+ @persistence_cookie_domain = nil
225
231
  @algorithm = Split::Algorithms::WeightedSample
226
232
  @include_rails_helper = true
227
233
  @beta_probability_simulations = 10000
@@ -230,16 +236,6 @@ module Split
230
236
  @dashboard_pagination_default_per_page = 10
231
237
  end
232
238
 
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
239
  private
244
240
 
245
241
  def value_for(hash, key)
@@ -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">
@@ -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
  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,13 +1,14 @@
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
7
  ActiveSupport.on_load(:action_controller) do
7
- include Split::Helper
8
- helper Split::Helper
9
- include Split::CombinedExperimentsHelper
10
- helper Split::CombinedExperimentsHelper
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
11
12
  end
12
13
  end
13
14
  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