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.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +1 -0
- data/.rubocop.yml +177 -1
- data/.rubocop_todo.yml +40 -493
- data/.travis.yml +14 -42
- data/CHANGELOG.md +35 -0
- data/Gemfile +1 -0
- data/README.md +19 -1
- data/Rakefile +1 -0
- data/lib/split.rb +8 -2
- 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 +6 -12
- data/lib/split/dashboard.rb +17 -2
- 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/encapsulated_helper.rb +3 -2
- data/lib/split/engine.rb +1 -0
- data/lib/split/exceptions.rb +1 -0
- data/lib/split/experiment.rb +81 -59
- 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.rb +4 -2
- data/lib/split/persistence/cookie_adapter.rb +1 -0
- data/lib/split/persistence/redis_adapter.rb +5 -0
- data/lib/split/persistence/session_adapter.rb +1 -0
- data/lib/split/redis_interface.rb +8 -28
- data/lib/split/trial.rb +20 -10
- data/lib/split/user.rb +14 -2
- data/lib/split/version.rb +2 -4
- data/lib/split/zscore.rb +1 -0
- data/spec/alternative_spec.rb +1 -1
- data/spec/cache_spec.rb +88 -0
- data/spec/configuration_spec.rb +1 -14
- data/spec/dashboard_spec.rb +45 -5
- data/spec/encapsulated_helper_spec.rb +1 -1
- data/spec/experiment_spec.rb +78 -7
- 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 +17 -0
- data/split.gemspec +7 -7
- metadata +23 -34
- data/gemfiles/4.2.gemfile +0 -9
data/.travis.yml
CHANGED
@@ -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.
|
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
|
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
|
data/CHANGELOG.md
CHANGED
@@ -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
data/README.md
CHANGED
@@ -263,7 +263,7 @@ Split.configure do |config|
|
|
263
263
|
end
|
264
264
|
```
|
265
265
|
|
266
|
-
|
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
data/lib/split.rb
CHANGED
@@ -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(:
|
40
|
+
Redis.new(url: server)
|
39
41
|
elsif server.is_a?(Hash)
|
40
|
-
Redis.new(server
|
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
|
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
|
@@ -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
|
-
'
|
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)
|
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
|
@@ -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">
|