split 3.4.0 → 4.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +1 -0
- data/.github/dependabot.yml +7 -0
- data/.github/workflows/ci.yml +71 -0
- data/.rubocop.yml +177 -1
- data/.rubocop_todo.yml +40 -493
- data/CHANGELOG.md +41 -0
- data/Gemfile +1 -0
- data/README.md +26 -6
- data/Rakefile +1 -0
- data/gemfiles/{4.2.gemfile → 6.1.gemfile} +1 -1
- data/gemfiles/7.0.gemfile +9 -0
- data/lib/split/algorithms/block_randomization.rb +1 -0
- data/lib/split/algorithms/weighted_sample.rb +2 -1
- data/lib/split/algorithms/whiplash.rb +3 -2
- data/lib/split/alternative.rb +1 -0
- data/lib/split/cache.rb +28 -0
- data/lib/split/combined_experiments_helper.rb +1 -0
- data/lib/split/configuration.rb +8 -12
- data/lib/split/dashboard/helpers.rb +1 -0
- data/lib/split/dashboard/pagination_helpers.rb +1 -0
- data/lib/split/dashboard/paginator.rb +1 -0
- data/lib/split/dashboard/public/dashboard.js +10 -0
- data/lib/split/dashboard/public/style.css +5 -0
- data/lib/split/dashboard/views/_controls.erb +13 -0
- data/lib/split/dashboard.rb +17 -2
- data/lib/split/encapsulated_helper.rb +3 -2
- data/lib/split/engine.rb +5 -4
- data/lib/split/exceptions.rb +1 -0
- data/lib/split/experiment.rb +82 -60
- data/lib/split/experiment_catalog.rb +1 -3
- data/lib/split/extensions/string.rb +1 -0
- data/lib/split/goals_collection.rb +1 -0
- data/lib/split/helper.rb +26 -7
- data/lib/split/metric.rb +2 -1
- data/lib/split/persistence/cookie_adapter.rb +6 -1
- data/lib/split/persistence/redis_adapter.rb +5 -0
- data/lib/split/persistence/session_adapter.rb +1 -0
- data/lib/split/persistence.rb +4 -2
- data/lib/split/redis_interface.rb +8 -28
- data/lib/split/trial.rb +20 -10
- data/lib/split/user.rb +15 -3
- data/lib/split/version.rb +2 -4
- data/lib/split/zscore.rb +1 -0
- data/lib/split.rb +9 -3
- data/spec/alternative_spec.rb +1 -1
- data/spec/cache_spec.rb +88 -0
- data/spec/configuration_spec.rb +17 -15
- data/spec/dashboard_spec.rb +45 -5
- data/spec/encapsulated_helper_spec.rb +1 -1
- data/spec/experiment_spec.rb +78 -13
- data/spec/goals_collection_spec.rb +1 -1
- data/spec/helper_spec.rb +68 -32
- data/spec/persistence/cookie_adapter_spec.rb +1 -1
- data/spec/persistence/redis_adapter_spec.rb +9 -0
- data/spec/redis_interface_spec.rb +0 -69
- data/spec/spec_helper.rb +5 -6
- data/spec/trial_spec.rb +45 -19
- data/spec/user_spec.rb +34 -3
- data/split.gemspec +7 -7
- metadata +27 -35
- 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
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
|
-
|
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
|
-
|
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
|
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
|
-
|
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,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 '
|
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
|
-
|
20
|
+
Rubystats::BetaDistribution.new(a+fairness_constant, b+fairness_constant).rng
|
20
21
|
end
|
21
22
|
|
22
23
|
def best_guess(alternatives)
|
data/lib/split/alternative.rb
CHANGED
data/lib/split/cache.rb
ADDED
@@ -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
|
data/lib/split/configuration.rb
CHANGED
@@ -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
|
-
'
|
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)
|
@@ -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
|
+
}
|
@@ -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">
|
data/lib/split/dashboard.rb
CHANGED
@@ -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
|
-
|
39
|
-
|
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
|
32
|
-
split_context_shim.ab_test(*arguments
|
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
|