split 3.3.0 → 4.0.0.pre
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.
- checksums.yaml +4 -4
- data/.eslintrc +1 -1
- data/.github/FUNDING.yml +1 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
- data/.rspec +1 -0
- data/.rubocop.yml +71 -1044
- data/.rubocop_todo.yml +226 -0
- data/.travis.yml +18 -39
- data/Appraisals +4 -0
- data/CHANGELOG.md +110 -0
- data/CODE_OF_CONDUCT.md +3 -3
- data/Gemfile +2 -0
- data/README.md +58 -23
- data/Rakefile +2 -0
- data/gemfiles/{4.2.gemfile → 6.0.gemfile} +1 -1
- data/lib/split.rb +16 -3
- data/lib/split/algorithms/block_randomization.rb +2 -0
- data/lib/split/algorithms/weighted_sample.rb +2 -1
- data/lib/split/algorithms/whiplash.rb +3 -2
- data/lib/split/alternative.rb +4 -3
- data/lib/split/cache.rb +28 -0
- data/lib/split/combined_experiments_helper.rb +3 -2
- data/lib/split/configuration.rb +15 -14
- data/lib/split/dashboard.rb +19 -1
- data/lib/split/dashboard/helpers.rb +3 -2
- data/lib/split/dashboard/pagination_helpers.rb +4 -4
- 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/views/layout.erb +1 -1
- data/lib/split/encapsulated_helper.rb +3 -2
- data/lib/split/engine.rb +7 -4
- data/lib/split/exceptions.rb +1 -0
- data/lib/split/experiment.rb +98 -65
- data/lib/split/experiment_catalog.rb +1 -3
- data/lib/split/extensions/string.rb +1 -0
- data/lib/split/goals_collection.rb +2 -0
- data/lib/split/helper.rb +30 -10
- 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/dual_adapter.rb +54 -12
- data/lib/split/persistence/redis_adapter.rb +5 -0
- data/lib/split/persistence/session_adapter.rb +1 -0
- data/lib/split/redis_interface.rb +9 -28
- data/lib/split/trial.rb +25 -17
- data/lib/split/user.rb +19 -3
- 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/pagination_helpers_spec.rb +3 -1
- data/spec/dashboard_helpers_spec.rb +2 -2
- data/spec/dashboard_spec.rb +78 -17
- data/spec/encapsulated_helper_spec.rb +2 -2
- data/spec/experiment_spec.rb +116 -12
- data/spec/goals_collection_spec.rb +1 -1
- data/spec/helper_spec.rb +191 -112
- data/spec/persistence/cookie_adapter_spec.rb +1 -1
- data/spec/persistence/dual_adapter_spec.rb +160 -68
- 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 +65 -19
- data/spec/user_spec.rb +28 -0
- data/split.gemspec +9 -9
- metadata +34 -28
data/lib/split/metric.rb
CHANGED
@@ -1,11 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
module Split
|
3
4
|
class Metric
|
4
5
|
attr_accessor :name
|
5
6
|
attr_accessor :experiments
|
6
7
|
|
7
8
|
def initialize(attrs = {})
|
8
|
-
attrs.each do |key,value|
|
9
|
+
attrs.each do |key, value|
|
9
10
|
if self.respond_to?("#{key}=")
|
10
11
|
self.send("#{key}=", value)
|
11
12
|
end
|
data/lib/split/persistence.rb
CHANGED
@@ -8,8 +8,10 @@ module Split
|
|
8
8
|
require 'split/persistence/session_adapter'
|
9
9
|
|
10
10
|
ADAPTERS = {
|
11
|
-
:
|
12
|
-
:
|
11
|
+
cookie: Split::Persistence::CookieAdapter,
|
12
|
+
session: Split::Persistence::SessionAdapter,
|
13
|
+
redis: Split::Persistence::RedisAdapter,
|
14
|
+
dual_adapter: Split::Persistence::DualAdapter
|
13
15
|
}.freeze
|
14
16
|
|
15
17
|
def self.adapter
|
@@ -1,12 +1,16 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require 'forwardable'
|
4
|
-
|
5
3
|
module Split
|
6
4
|
module Persistence
|
7
5
|
class DualAdapter
|
8
|
-
|
9
|
-
|
6
|
+
def self.with_config(options={})
|
7
|
+
self.config.merge!(options)
|
8
|
+
self
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.config
|
12
|
+
@config ||= {}
|
13
|
+
end
|
10
14
|
|
11
15
|
def initialize(context)
|
12
16
|
if logged_in = self.class.config[:logged_in]
|
@@ -22,22 +26,60 @@ module Split
|
|
22
26
|
raise "Please configure :logged_out_adapter"
|
23
27
|
end
|
24
28
|
|
25
|
-
|
26
|
-
|
29
|
+
@fallback_to_logged_out_adapter =
|
30
|
+
self.class.config[:fallback_to_logged_out_adapter] || false
|
31
|
+
@logged_in = logged_in.call(context)
|
32
|
+
@logged_in_adapter = logged_in_adapter.new(context)
|
33
|
+
@logged_out_adapter = logged_out_adapter.new(context)
|
34
|
+
@active_adapter = @logged_in ? @logged_in_adapter : @logged_out_adapter
|
35
|
+
end
|
36
|
+
|
37
|
+
def keys
|
38
|
+
if @fallback_to_logged_out_adapter
|
39
|
+
(@logged_in_adapter.keys + @logged_out_adapter.keys).uniq
|
27
40
|
else
|
28
|
-
@
|
41
|
+
@active_adapter.keys
|
29
42
|
end
|
30
43
|
end
|
31
44
|
|
32
|
-
def
|
33
|
-
|
34
|
-
|
45
|
+
def [](key)
|
46
|
+
if @fallback_to_logged_out_adapter
|
47
|
+
@logged_in && @logged_in_adapter[key] || @logged_out_adapter[key]
|
48
|
+
else
|
49
|
+
@active_adapter[key]
|
50
|
+
end
|
35
51
|
end
|
36
52
|
|
37
|
-
def
|
38
|
-
@
|
53
|
+
def []=(key, value)
|
54
|
+
if @fallback_to_logged_out_adapter
|
55
|
+
@logged_in_adapter[key] = value if @logged_in
|
56
|
+
old_value = @logged_out_adapter[key]
|
57
|
+
@logged_out_adapter[key] = value
|
58
|
+
|
59
|
+
decrement_participation(key, old_value) if decrement_participation?(old_value, value)
|
60
|
+
else
|
61
|
+
@active_adapter[key] = value
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def delete(key)
|
66
|
+
if @fallback_to_logged_out_adapter
|
67
|
+
@logged_in_adapter.delete(key)
|
68
|
+
@logged_out_adapter.delete(key)
|
69
|
+
else
|
70
|
+
@active_adapter.delete(key)
|
71
|
+
end
|
39
72
|
end
|
40
73
|
|
74
|
+
private
|
75
|
+
|
76
|
+
def decrement_participation?(old_value, value)
|
77
|
+
!old_value.nil? && !value.nil? && old_value != value
|
78
|
+
end
|
79
|
+
|
80
|
+
def decrement_participation(key, value)
|
81
|
+
Split.redis.hincrby("#{key}:#{value}", 'participant_count', -1)
|
82
|
+
end
|
41
83
|
end
|
42
84
|
end
|
43
85
|
end
|
@@ -1,4 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
module Split
|
3
4
|
module Persistence
|
4
5
|
class RedisAdapter
|
@@ -39,6 +40,10 @@ module Split
|
|
39
40
|
Split.redis.hkeys(redis_key)
|
40
41
|
end
|
41
42
|
|
43
|
+
def self.find(user_id)
|
44
|
+
new(nil, user_id)
|
45
|
+
end
|
46
|
+
|
42
47
|
def self.with_config(options={})
|
43
48
|
self.config.merge!(options)
|
44
49
|
self
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Split
|
2
4
|
# Simplifies the interface to Redis.
|
3
5
|
class RedisInterface
|
@@ -6,40 +8,19 @@ module Split
|
|
6
8
|
end
|
7
9
|
|
8
10
|
def persist_list(list_name, list_values)
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
set_list_index(list_name, index, value)
|
11
|
+
if list_values.length > 0
|
12
|
+
redis.multi do |multi|
|
13
|
+
tmp_list = "#{list_name}_tmp"
|
14
|
+
multi.rpush(tmp_list, list_values)
|
15
|
+
multi.rename(tmp_list, list_name)
|
15
16
|
end
|
16
17
|
end
|
17
|
-
make_list_length(list_name, list_values.length)
|
18
|
-
list_values
|
19
|
-
end
|
20
|
-
|
21
|
-
def add_to_list(list_name, value)
|
22
|
-
redis.rpush(list_name, value)
|
23
|
-
end
|
24
|
-
|
25
|
-
def set_list_index(list_name, index, value)
|
26
|
-
redis.lset(list_name, index, value)
|
27
|
-
end
|
28
|
-
|
29
|
-
def list_length(list_name)
|
30
|
-
redis.llen(list_name)
|
31
|
-
end
|
32
18
|
|
33
|
-
|
34
|
-
redis.rpop(list_name)
|
35
|
-
end
|
36
|
-
|
37
|
-
def make_list_length(list_name, new_length)
|
38
|
-
redis.ltrim(list_name, 0, new_length - 1)
|
19
|
+
list_values
|
39
20
|
end
|
40
21
|
|
41
22
|
def add_to_set(set_name, value)
|
42
|
-
redis.sadd(set_name, value)
|
23
|
+
redis.sadd(set_name, value)
|
43
24
|
end
|
44
25
|
|
45
26
|
private
|
data/lib/split/trial.rb
CHANGED
@@ -1,18 +1,21 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
module Split
|
3
4
|
class Trial
|
5
|
+
attr_accessor :goals
|
4
6
|
attr_accessor :experiment
|
5
|
-
|
7
|
+
attr_writer :metadata
|
6
8
|
|
7
9
|
def initialize(attrs = {})
|
8
10
|
self.experiment = attrs.delete(:experiment)
|
9
11
|
self.alternative = attrs.delete(:alternative)
|
10
12
|
self.metadata = attrs.delete(:metadata)
|
13
|
+
self.goals = attrs.delete(:goals) || []
|
11
14
|
|
12
15
|
@user = attrs.delete(:user)
|
13
16
|
@options = attrs
|
14
17
|
|
15
|
-
@
|
18
|
+
@alternative_chosen = false
|
16
19
|
end
|
17
20
|
|
18
21
|
def metadata
|
@@ -33,7 +36,7 @@ module Split
|
|
33
36
|
end
|
34
37
|
end
|
35
38
|
|
36
|
-
def complete!(
|
39
|
+
def complete!(context = nil)
|
37
40
|
if alternative
|
38
41
|
if Array(goals).empty?
|
39
42
|
alternative.increment_completion
|
@@ -51,8 +54,9 @@ module Split
|
|
51
54
|
def choose!(context = nil)
|
52
55
|
@user.cleanup_old_experiments!
|
53
56
|
# Only run the process once
|
54
|
-
return alternative if @
|
57
|
+
return alternative if @alternative_chosen
|
55
58
|
|
59
|
+
new_participant = @user[@experiment.key].nil?
|
56
60
|
if override_is_alternative?
|
57
61
|
self.alternative = @options[:override]
|
58
62
|
if should_store_alternative? && !@user[@experiment.key]
|
@@ -68,23 +72,27 @@ module Split
|
|
68
72
|
if exclude_user?
|
69
73
|
self.alternative = @experiment.control
|
70
74
|
else
|
71
|
-
|
72
|
-
if
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
75
|
+
self.alternative = @user[@experiment.key]
|
76
|
+
if alternative.nil?
|
77
|
+
if @experiment.cohorting_disabled?
|
78
|
+
self.alternative = @experiment.control
|
79
|
+
else
|
80
|
+
self.alternative = @experiment.next_alternative
|
81
|
+
|
82
|
+
# Increment the number of participants since we are actually choosing a new alternative
|
83
|
+
self.alternative.increment_participation
|
84
|
+
|
85
|
+
run_callback context, Split.configuration.on_trial_choose
|
86
|
+
end
|
81
87
|
end
|
82
88
|
end
|
83
89
|
end
|
84
90
|
|
85
|
-
|
86
|
-
|
87
|
-
|
91
|
+
new_participant_and_cohorting_disabled = new_participant && @experiment.cohorting_disabled?
|
92
|
+
|
93
|
+
@user[@experiment.key] = alternative.name unless @experiment.has_winner? || !should_store_alternative? || new_participant_and_cohorting_disabled
|
94
|
+
@alternative_chosen = true
|
95
|
+
run_callback context, Split.configuration.on_trial unless @options[:disabled] || Split.configuration.disabled? || new_participant_and_cohorting_disabled
|
88
96
|
alternative
|
89
97
|
end
|
90
98
|
|
data/lib/split/user.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'forwardable'
|
2
4
|
|
3
5
|
module Split
|
@@ -6,11 +8,13 @@ module Split
|
|
6
8
|
def_delegators :@user, :keys, :[], :[]=, :delete
|
7
9
|
attr_reader :user
|
8
10
|
|
9
|
-
def initialize(context, adapter=nil)
|
11
|
+
def initialize(context, adapter = nil)
|
10
12
|
@user = adapter || Split::Persistence.adapter.new(context)
|
13
|
+
@cleaned_up = false
|
11
14
|
end
|
12
15
|
|
13
16
|
def cleanup_old_experiments!
|
17
|
+
return if @cleaned_up
|
14
18
|
keys_without_finished(user.keys).each do |key|
|
15
19
|
experiment = ExperimentCatalog.find key_without_version(key)
|
16
20
|
if experiment.nil? || experiment.has_winner? || experiment.start_time.nil?
|
@@ -18,12 +22,14 @@ module Split
|
|
18
22
|
user.delete Experiment.finished_key(key)
|
19
23
|
end
|
20
24
|
end
|
25
|
+
@cleaned_up = true
|
21
26
|
end
|
22
27
|
|
23
28
|
def max_experiments_reached?(experiment_key)
|
24
29
|
if Split.configuration.allow_multiple_experiments == 'control'
|
25
30
|
experiments = active_experiments
|
26
|
-
|
31
|
+
experiment_key_without_version = key_without_version(experiment_key)
|
32
|
+
count_control = experiments.count {|k, v| k == experiment_key_without_version || v == 'control'}
|
27
33
|
experiments.size > count_control
|
28
34
|
else
|
29
35
|
!Split.configuration.allow_multiple_experiments &&
|
@@ -38,7 +44,7 @@ module Split
|
|
38
44
|
|
39
45
|
def active_experiments
|
40
46
|
experiment_pairs = {}
|
41
|
-
user.keys.each do |key|
|
47
|
+
keys_without_finished(user.keys).each do |key|
|
42
48
|
Metric.possible_experiments(key_without_version(key)).each do |experiment|
|
43
49
|
if !experiment.has_winner?
|
44
50
|
experiment_pairs[key_without_version(key)] = user[key]
|
@@ -48,6 +54,16 @@ module Split
|
|
48
54
|
experiment_pairs
|
49
55
|
end
|
50
56
|
|
57
|
+
def self.find(user_id, adapter)
|
58
|
+
adapter = adapter.is_a?(Symbol) ? Split::Persistence::ADAPTERS[adapter] : adapter
|
59
|
+
|
60
|
+
if adapter.respond_to?(:find)
|
61
|
+
User.new(nil, adapter.find(user_id))
|
62
|
+
else
|
63
|
+
nil
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
51
67
|
private
|
52
68
|
|
53
69
|
def keys_without_experiment(keys, experiment_key)
|
data/lib/split/version.rb
CHANGED
data/lib/split/zscore.rb
CHANGED
data/spec/alternative_spec.rb
CHANGED
@@ -126,7 +126,7 @@ describe Split::Alternative do
|
|
126
126
|
|
127
127
|
it "should save to redis" do
|
128
128
|
alternative.save
|
129
|
-
expect(Split.redis.exists('basket_text:Basket')).to be true
|
129
|
+
expect(Split.redis.exists?('basket_text:Basket')).to be true
|
130
130
|
end
|
131
131
|
|
132
132
|
it "should increment participation count" do
|
data/spec/cache_spec.rb
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe Split::Cache do
|
5
|
+
|
6
|
+
let(:namespace) { :test_namespace }
|
7
|
+
let(:key) { :test_key }
|
8
|
+
let(:now) { 1606189017 }
|
9
|
+
|
10
|
+
before { allow(Time).to receive(:now).and_return(now) }
|
11
|
+
|
12
|
+
describe 'clear' do
|
13
|
+
|
14
|
+
before { Split.configuration.cache = true }
|
15
|
+
|
16
|
+
it 'clears the cache' do
|
17
|
+
expect(Time).to receive(:now).and_return(now).exactly(2).times
|
18
|
+
Split::Cache.fetch(namespace, key) { Time.now }
|
19
|
+
Split::Cache.clear
|
20
|
+
Split::Cache.fetch(namespace, key) { Time.now }
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
describe 'clear_key' do
|
25
|
+
before { Split.configuration.cache = true }
|
26
|
+
|
27
|
+
it 'clears the cache' do
|
28
|
+
expect(Time).to receive(:now).and_return(now).exactly(3).times
|
29
|
+
Split::Cache.fetch(namespace, :key1) { Time.now }
|
30
|
+
Split::Cache.fetch(namespace, :key2) { Time.now }
|
31
|
+
Split::Cache.clear_key(:key1)
|
32
|
+
|
33
|
+
Split::Cache.fetch(namespace, :key1) { Time.now }
|
34
|
+
Split::Cache.fetch(namespace, :key2) { Time.now }
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe 'fetch' do
|
39
|
+
|
40
|
+
subject { Split::Cache.fetch(namespace, key) { Time.now } }
|
41
|
+
|
42
|
+
context 'when cache disabled' do
|
43
|
+
|
44
|
+
before { Split.configuration.cache = false }
|
45
|
+
|
46
|
+
it 'returns the yield' do
|
47
|
+
expect(subject).to eql(now)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'yields every time' do
|
51
|
+
expect(Time).to receive(:now).and_return(now).exactly(2).times
|
52
|
+
Split::Cache.fetch(namespace, key) { Time.now }
|
53
|
+
Split::Cache.fetch(namespace, key) { Time.now }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
context 'when cache enabled' do
|
58
|
+
|
59
|
+
before { Split.configuration.cache = true }
|
60
|
+
|
61
|
+
it 'returns the yield' do
|
62
|
+
expect(subject).to eql(now)
|
63
|
+
end
|
64
|
+
|
65
|
+
it 'yields once' do
|
66
|
+
expect(Time).to receive(:now).and_return(now).once
|
67
|
+
Split::Cache.fetch(namespace, key) { Time.now }
|
68
|
+
Split::Cache.fetch(namespace, key) { Time.now }
|
69
|
+
end
|
70
|
+
|
71
|
+
it 'honors namespace' do
|
72
|
+
expect(Split::Cache.fetch(:a, key) { :a }).to eql(:a)
|
73
|
+
expect(Split::Cache.fetch(:b, key) { :b }).to eql(:b)
|
74
|
+
|
75
|
+
expect(Split::Cache.fetch(:a, key) { :a }).to eql(:a)
|
76
|
+
expect(Split::Cache.fetch(:b, key) { :b }).to eql(:b)
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'honors key' do
|
80
|
+
expect(Split::Cache.fetch(namespace, :a) { :a }).to eql(:a)
|
81
|
+
expect(Split::Cache.fetch(namespace, :b) { :b }).to eql(:b)
|
82
|
+
|
83
|
+
expect(Split::Cache.fetch(namespace, :a) { :a }).to eql(:a)
|
84
|
+
expect(Split::Cache.fetch(namespace, :b) { :b }).to eql(:b)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|