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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.eslintrc +1 -1
  3. data/.github/FUNDING.yml +1 -0
  4. data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
  5. data/.rspec +1 -0
  6. data/.rubocop.yml +71 -1044
  7. data/.rubocop_todo.yml +226 -0
  8. data/.travis.yml +18 -39
  9. data/Appraisals +4 -0
  10. data/CHANGELOG.md +110 -0
  11. data/CODE_OF_CONDUCT.md +3 -3
  12. data/Gemfile +2 -0
  13. data/README.md +58 -23
  14. data/Rakefile +2 -0
  15. data/gemfiles/{4.2.gemfile → 6.0.gemfile} +1 -1
  16. data/lib/split.rb +16 -3
  17. data/lib/split/algorithms/block_randomization.rb +2 -0
  18. data/lib/split/algorithms/weighted_sample.rb +2 -1
  19. data/lib/split/algorithms/whiplash.rb +3 -2
  20. data/lib/split/alternative.rb +4 -3
  21. data/lib/split/cache.rb +28 -0
  22. data/lib/split/combined_experiments_helper.rb +3 -2
  23. data/lib/split/configuration.rb +15 -14
  24. data/lib/split/dashboard.rb +19 -1
  25. data/lib/split/dashboard/helpers.rb +3 -2
  26. data/lib/split/dashboard/pagination_helpers.rb +4 -4
  27. data/lib/split/dashboard/paginator.rb +1 -0
  28. data/lib/split/dashboard/public/dashboard.js +10 -0
  29. data/lib/split/dashboard/public/style.css +5 -0
  30. data/lib/split/dashboard/views/_controls.erb +13 -0
  31. data/lib/split/dashboard/views/layout.erb +1 -1
  32. data/lib/split/encapsulated_helper.rb +3 -2
  33. data/lib/split/engine.rb +7 -4
  34. data/lib/split/exceptions.rb +1 -0
  35. data/lib/split/experiment.rb +98 -65
  36. data/lib/split/experiment_catalog.rb +1 -3
  37. data/lib/split/extensions/string.rb +1 -0
  38. data/lib/split/goals_collection.rb +2 -0
  39. data/lib/split/helper.rb +30 -10
  40. data/lib/split/metric.rb +2 -1
  41. data/lib/split/persistence.rb +4 -2
  42. data/lib/split/persistence/cookie_adapter.rb +1 -0
  43. data/lib/split/persistence/dual_adapter.rb +54 -12
  44. data/lib/split/persistence/redis_adapter.rb +5 -0
  45. data/lib/split/persistence/session_adapter.rb +1 -0
  46. data/lib/split/redis_interface.rb +9 -28
  47. data/lib/split/trial.rb +25 -17
  48. data/lib/split/user.rb +19 -3
  49. data/lib/split/version.rb +2 -4
  50. data/lib/split/zscore.rb +1 -0
  51. data/spec/alternative_spec.rb +1 -1
  52. data/spec/cache_spec.rb +88 -0
  53. data/spec/configuration_spec.rb +1 -14
  54. data/spec/dashboard/pagination_helpers_spec.rb +3 -1
  55. data/spec/dashboard_helpers_spec.rb +2 -2
  56. data/spec/dashboard_spec.rb +78 -17
  57. data/spec/encapsulated_helper_spec.rb +2 -2
  58. data/spec/experiment_spec.rb +116 -12
  59. data/spec/goals_collection_spec.rb +1 -1
  60. data/spec/helper_spec.rb +191 -112
  61. data/spec/persistence/cookie_adapter_spec.rb +1 -1
  62. data/spec/persistence/dual_adapter_spec.rb +160 -68
  63. data/spec/persistence/redis_adapter_spec.rb +9 -0
  64. data/spec/redis_interface_spec.rb +0 -69
  65. data/spec/spec_helper.rb +5 -6
  66. data/spec/trial_spec.rb +65 -19
  67. data/spec/user_spec.rb +28 -0
  68. data/split.gemspec +9 -9
  69. metadata +34 -28
@@ -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
@@ -8,8 +8,10 @@ module Split
8
8
  require 'split/persistence/session_adapter'
9
9
 
10
10
  ADAPTERS = {
11
- :cookie => Split::Persistence::CookieAdapter,
12
- :session => Split::Persistence::SessionAdapter
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,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "json"
3
4
 
4
5
  module Split
@@ -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
- extend Forwardable
9
- def_delegators :@adapter, :keys, :[], :[]=, :delete
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
- if logged_in.call(context)
26
- @adapter = logged_in_adapter.new(context)
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
- @adapter = logged_out_adapter.new(context)
41
+ @active_adapter.keys
29
42
  end
30
43
  end
31
44
 
32
- def self.with_config(options={})
33
- self.config.merge!(options)
34
- self
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 self.config
38
- @config ||= {}
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,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
4
  module Persistence
4
5
  class SessionAdapter
@@ -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
- max_index = list_length(list_name) - 1
10
- list_values.each_with_index do |value, index|
11
- if index > max_index
12
- add_to_list(list_name, value)
13
- else
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
- def remove_last_item_from_list(list_name)
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) unless redis.sismember(set_name, value)
23
+ redis.sadd(set_name, value)
43
24
  end
44
25
 
45
26
  private
@@ -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
- attr_accessor :metadata
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
- @alternative_choosen = false
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!(goals=[], context = nil)
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 @alternative_choosen
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
- value = @user[@experiment.key]
72
- if value
73
- self.alternative = value
74
- else
75
- self.alternative = @experiment.next_alternative
76
-
77
- # Increment the number of participants since we are actually choosing a new alternative
78
- self.alternative.increment_participation
79
-
80
- run_callback context, Split.configuration.on_trial_choose
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
- @user[@experiment.key] = alternative.name if should_store_alternative?
86
- @alternative_choosen = true
87
- run_callback context, Split.configuration.on_trial unless @options[:disabled] || Split.configuration.disabled?
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
 
@@ -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
- count_control = experiments.count {|k,v| k == experiment_key || v == 'control'}
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)
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
- MAJOR = 3
4
- MINOR = 3
5
- PATCH = 0
6
- VERSION = [MAJOR, MINOR, PATCH].join('.')
4
+ VERSION = "4.0.0.pre"
7
5
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
4
  class Zscore
4
5
 
@@ -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
@@ -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