split 3.4.0 → 4.0.1

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