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,12 +1,15 @@
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
- ActionController::Base.send :include, Split::Helper
7
- ActionController::Base.helper Split::Helper
8
- ActionController::Base.send :include, Split::CombinedExperimentsHelper
9
- ActionController::Base.helper Split::CombinedExperimentsHelper
7
+ ActiveSupport.on_load(:action_controller) do
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
12
+ end
10
13
  end
11
14
  end
12
15
  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
@@ -1,38 +1,34 @@
1
1
  # frozen_string_literal: true
2
+
3
+ require 'rubystats'
4
+
2
5
  module Split
3
6
  class Experiment
4
7
  attr_accessor :name
5
- attr_writer :algorithm
6
- attr_accessor :resettable
7
8
  attr_accessor :goals
8
- attr_accessor :alternatives
9
9
  attr_accessor :alternative_probabilities
10
10
  attr_accessor :metadata
11
11
 
12
+ attr_reader :alternatives
13
+ attr_reader :resettable
14
+
12
15
  DEFAULT_OPTIONS = {
13
16
  :resettable => true
14
17
  }
15
18
 
19
+ def self.find(name)
20
+ Split.cache(:experiments, name) do
21
+ return unless Split.redis.exists?(name)
22
+ Experiment.new(name).tap { |exp| exp.load_from_redis }
23
+ end
24
+ end
25
+
16
26
  def initialize(name, options = {})
17
27
  options = DEFAULT_OPTIONS.merge(options)
18
28
 
19
29
  @name = name.to_s
20
30
 
21
- alternatives = extract_alternatives_from_options(options)
22
-
23
- if alternatives.empty? && (exp_config = Split.configuration.experiment_for(name))
24
- options = {
25
- alternatives: load_alternatives_from_configuration,
26
- goals: Split::GoalsCollection.new(@name).load_from_configuration,
27
- metadata: load_metadata_from_configuration,
28
- resettable: exp_config[:resettable],
29
- algorithm: exp_config[:algorithm]
30
- }
31
- else
32
- options[:alternatives] = alternatives
33
- end
34
-
35
- set_alternatives_and_options(options)
31
+ extract_alternatives_from_options(options)
36
32
  end
37
33
 
38
34
  def self.finished_key(key)
@@ -40,11 +36,15 @@ module Split
40
36
  end
41
37
 
42
38
  def set_alternatives_and_options(options)
43
- self.alternatives = options[:alternatives]
44
- self.goals = options[:goals]
45
- self.resettable = options[:resettable]
46
- self.algorithm = options[:algorithm]
47
- self.metadata = options[:metadata]
39
+ options_with_defaults = DEFAULT_OPTIONS.merge(
40
+ options.reject { |k, v| v.nil? }
41
+ )
42
+
43
+ self.alternatives = options_with_defaults[:alternatives]
44
+ self.goals = options_with_defaults[:goals]
45
+ self.resettable = options_with_defaults[:resettable]
46
+ self.algorithm = options_with_defaults[:algorithm]
47
+ self.metadata = options_with_defaults[:metadata]
48
48
  end
49
49
 
50
50
  def extract_alternatives_from_options(options)
@@ -52,7 +52,7 @@ module Split
52
52
 
53
53
  if alts.length == 1
54
54
  if alts[0].is_a? Hash
55
- alts = alts[0].map{|k,v| {k => v} }
55
+ alts = alts[0].map{|k, v| {k => v} }
56
56
  end
57
57
  end
58
58
 
@@ -81,14 +81,14 @@ module Split
81
81
 
82
82
  if new_record?
83
83
  start unless Split.configuration.start_manually
84
+ persist_experiment_configuration
84
85
  elsif experiment_configuration_has_changed?
85
86
  reset unless Split.configuration.reset_manually
87
+ persist_experiment_configuration
86
88
  end
87
89
 
88
- persist_experiment_configuration if new_record? || experiment_configuration_has_changed?
89
-
90
- redis.hset(experiment_config_key, :resettable, resettable)
91
- redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
90
+ redis.hset(experiment_config_key, :resettable, resettable,
91
+ :algorithm, algorithm.to_s)
92
92
  self
93
93
  end
94
94
 
@@ -101,7 +101,7 @@ module Split
101
101
  end
102
102
 
103
103
  def new_record?
104
- !redis.exists(name)
104
+ ExperimentCatalog.find(name).nil?
105
105
  end
106
106
 
107
107
  def ==(obj)
@@ -135,24 +135,29 @@ module Split
135
135
  end
136
136
 
137
137
  def winner
138
- experiment_winner = redis.hget(:experiment_winner, name)
139
- if experiment_winner
140
- Split::Alternative.new(experiment_winner, name)
141
- else
142
- nil
138
+ Split.cache(:experiment_winner, name) do
139
+ experiment_winner = redis.hget(:experiment_winner, name)
140
+ if experiment_winner
141
+ Split::Alternative.new(experiment_winner, name)
142
+ else
143
+ nil
144
+ end
143
145
  end
144
146
  end
145
147
 
146
148
  def has_winner?
147
- !winner.nil?
149
+ return @has_winner if defined? @has_winner
150
+ @has_winner = !winner.nil?
148
151
  end
149
152
 
150
153
  def winner=(winner_name)
151
154
  redis.hset(:experiment_winner, name, winner_name.to_s)
155
+ @has_winner = true
156
+ Split.configuration.on_experiment_winner_choose.call(self)
152
157
  end
153
158
 
154
159
  def participant_count
155
- alternatives.inject(0){|sum,a| sum + a.participant_count}
160
+ alternatives.inject(0){|sum, a| sum + a.participant_count}
156
161
  end
157
162
 
158
163
  def control
@@ -161,6 +166,8 @@ module Split
161
166
 
162
167
  def reset_winner
163
168
  redis.hdel(:experiment_winner, name)
169
+ @has_winner = false
170
+ Split::Cache.clear_key(@name)
164
171
  end
165
172
 
166
173
  def start
@@ -168,13 +175,15 @@ module Split
168
175
  end
169
176
 
170
177
  def start_time
171
- t = redis.hget(:experiment_start_times, @name)
172
- if t
173
- # Check if stored time is an integer
174
- if t =~ /^[-+]?[0-9]+$/
175
- Time.at(t.to_i)
176
- else
177
- Time.parse(t)
178
+ Split.cache(:experiment_start_times, @name) do
179
+ t = redis.hget(:experiment_start_times, @name)
180
+ if t
181
+ # Check if stored time is an integer
182
+ if t =~ /^[-+]?[0-9]+$/
183
+ Time.at(t.to_i)
184
+ else
185
+ Time.parse(t)
186
+ end
178
187
  end
179
188
  end
180
189
  end
@@ -225,6 +234,7 @@ module Split
225
234
 
226
235
  def reset
227
236
  Split.configuration.on_before_experiment_reset.call(self)
237
+ Split::Cache.clear_key(@name)
228
238
  alternatives.each(&:reset)
229
239
  reset_winner
230
240
  Split.configuration.on_experiment_reset.call(self)
@@ -238,6 +248,7 @@ module Split
238
248
  end
239
249
  reset_winner
240
250
  redis.srem(:experiments, name)
251
+ remove_experiment_cohorting
241
252
  remove_experiment_configuration
242
253
  Split.configuration.on_experiment_delete.call(self)
243
254
  increment_version
@@ -335,7 +346,7 @@ module Split
335
346
 
336
347
  def find_simulated_winner(simulated_cr_hash)
337
348
  # figure out which alternative had the highest simulated conversion rate
338
- winning_pair = ["",0.0]
349
+ winning_pair = ["", 0.0]
339
350
  simulated_cr_hash.each do |alternative, rate|
340
351
  if rate > winning_pair[1]
341
352
  winning_pair = [alternative, rate]
@@ -346,17 +357,13 @@ module Split
346
357
  end
347
358
 
348
359
  def calc_simulated_conversion_rates(beta_params)
349
- # initialize a random variable (from which to simulate conversion rates ~beta-distributed)
350
- rand = SimpleRandom.new
351
- rand.set_seed
352
-
353
360
  simulated_cr_hash = {}
354
361
 
355
362
  # create a hash which has the conversion rate pulled from each alternative's beta distribution
356
363
  beta_params.each do |alternative, params|
357
364
  alpha = params[0]
358
365
  beta = params[1]
359
- simulated_conversion_rate = rand.beta(alpha, beta)
366
+ simulated_conversion_rate = Rubystats::BetaDistribution.new(alpha, beta).rng
360
367
  simulated_cr_hash[alternative] = simulated_conversion_rate
361
368
  end
362
369
 
@@ -394,6 +401,23 @@ module Split
394
401
  js_id.gsub('/', '--')
395
402
  end
396
403
 
404
+ def cohorting_disabled?
405
+ @cohorting_disabled ||= begin
406
+ value = redis.hget(experiment_config_key, :cohorting)
407
+ value.nil? ? false : value.downcase == "true"
408
+ end
409
+ end
410
+
411
+ def disable_cohorting
412
+ @cohorting_disabled = true
413
+ redis.hset(experiment_config_key, :cohorting, true)
414
+ end
415
+
416
+ def enable_cohorting
417
+ @cohorting_disabled = false
418
+ redis.hset(experiment_config_key, :cohorting, false)
419
+ end
420
+
397
421
  protected
398
422
 
399
423
  def experiment_config_key
@@ -420,14 +444,14 @@ module Split
420
444
  end
421
445
 
422
446
  def load_alternatives_from_redis
423
- case redis.type(@name)
424
- when 'set' # convert legacy sets to lists
425
- alts = redis.smembers(@name)
426
- redis.del(@name)
427
- alts.reverse.each {|a| redis.lpush(@name, a) }
428
- redis.lrange(@name, 0, -1)
429
- else
430
- redis.lrange(@name, 0, -1)
447
+ alternatives = redis.lrange(@name, 0, -1)
448
+ alternatives.map do |alt|
449
+ alt = begin
450
+ JSON.parse(alt)
451
+ rescue
452
+ alt
453
+ end
454
+ Split::Alternative.new(alt, @name)
431
455
  end
432
456
  end
433
457
 
@@ -443,9 +467,14 @@ module Split
443
467
 
444
468
  def persist_experiment_configuration
445
469
  redis_interface.add_to_set(:experiments, name)
446
- redis_interface.persist_list(name, @alternatives.map(&:name))
470
+ redis_interface.persist_list(name, @alternatives.map{|alt| {alt.name => alt.weight}.to_json})
447
471
  goals_collection.save
448
- redis.set(metadata_key, @metadata.to_json) unless @metadata.nil?
472
+
473
+ if @metadata
474
+ redis.set(metadata_key, @metadata.to_json)
475
+ else
476
+ delete_metadata
477
+ end
449
478
  end
450
479
 
451
480
  def remove_experiment_configuration
@@ -456,16 +485,20 @@ module Split
456
485
  end
457
486
 
458
487
  def experiment_configuration_has_changed?
459
- existing_alternatives = load_alternatives_from_redis
460
- existing_goals = Split::GoalsCollection.new(@name).load_from_redis
461
- existing_metadata = load_metadata_from_redis
462
- existing_alternatives != @alternatives.map(&:name) ||
463
- existing_goals != @goals ||
464
- existing_metadata != @metadata
488
+ existing_experiment = Experiment.find(@name)
489
+
490
+ existing_experiment.alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
491
+ existing_experiment.goals != @goals ||
492
+ existing_experiment.metadata != @metadata
465
493
  end
466
494
 
467
495
  def goals_collection
468
496
  Split::GoalsCollection.new(@name, @goals)
469
497
  end
498
+
499
+ def remove_experiment_cohorting
500
+ @cohorting_disabled = false
501
+ redis.hdel(experiment_config_key, :cohorting)
502
+ end
470
503
  end
471
504
  end
@@ -13,8 +13,7 @@ module Split
13
13
  end
14
14
 
15
15
  def self.find(name)
16
- return unless Split.redis.exists(name)
17
- Experiment.new(name).tap { |exp| exp.load_from_redis }
16
+ Experiment.find(name)
18
17
  end
19
18
 
20
19
  def self.find_or_initialize(metric_descriptor, control = nil, *alternatives)
@@ -46,6 +45,5 @@ module Split
46
45
  return experiment_name, goals
47
46
  end
48
47
  private_class_method :normalize_experiment
49
-
50
48
  end
51
49
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  class String
3
4
  # Constatntize is often provided by ActiveSupport, but ActiveSupport is not a dependency of Split.
4
5
  unless method_defined?(:constantize)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Split
2
4
  class GoalsCollection
3
5
 
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
4
  module Helper
4
5
  OVERRIDE_PARAM_NAME = "ab_test"
@@ -8,7 +9,7 @@ module Split
8
9
  def ab_test(metric_descriptor, control = nil, *alternatives)
9
10
  begin
10
11
  experiment = ExperimentCatalog.find_or_initialize(metric_descriptor, control, *alternatives)
11
- alternative = if Split.configuration.enabled
12
+ alternative = if Split.configuration.enabled && !exclude_visitor?
12
13
  experiment.save
13
14
  raise(Split::InvalidExperimentsFormatError) unless (Split.configuration.experiments || {}).fetch(experiment.name.to_sym, {})[:combined_experiments].nil?
14
15
  trial = Trial.new(:user => ab_user, :experiment => experiment,
@@ -32,8 +33,8 @@ module Split
32
33
  end
33
34
 
34
35
  if block_given?
35
- metadata = trial ? trial.metadata : {}
36
- yield(alternative, metadata)
36
+ metadata = experiment.metadata[alternative] if experiment.metadata
37
+ yield(alternative, metadata || {})
37
38
  else
38
39
  alternative
39
40
  end
@@ -44,15 +45,21 @@ module Split
44
45
  end
45
46
 
46
47
  def finish_experiment(experiment, options = {:reset => true})
48
+ return false if active_experiments[experiment.name].nil?
47
49
  return true if experiment.has_winner?
48
50
  should_reset = experiment.resettable? && options[:reset]
49
51
  if ab_user[experiment.finished_key] && !should_reset
50
52
  return true
51
53
  else
52
54
  alternative_name = ab_user[experiment.key]
53
- trial = Trial.new(:user => ab_user, :experiment => experiment,
54
- :alternative => alternative_name)
55
- trial.complete!(options[:goals], self)
55
+ trial = Trial.new(
56
+ :user => ab_user,
57
+ :experiment => experiment,
58
+ :alternative => alternative_name,
59
+ :goals => options[:goals],
60
+ )
61
+
62
+ trial.complete!(self)
56
63
 
57
64
  if should_reset
58
65
  reset!(experiment)
@@ -69,6 +76,7 @@ module Split
69
76
 
70
77
  if experiments.any?
71
78
  experiments.each do |experiment|
79
+ next if override_present?(experiment.key)
72
80
  finish_experiment(experiment, options.merge(:goals => goals))
73
81
  end
74
82
  end
@@ -79,7 +87,7 @@ module Split
79
87
 
80
88
  def ab_record_extra_info(metric_descriptor, key, value = 1)
81
89
  return if exclude_visitor? || Split.configuration.disabled?
82
- metric_descriptor, goals = normalize_metric(metric_descriptor)
90
+ metric_descriptor, _ = normalize_metric(metric_descriptor)
83
91
  experiments = Metric.possible_experiments(metric_descriptor)
84
92
 
85
93
  if experiments.any?
@@ -104,15 +112,27 @@ module Split
104
112
  Split.configuration.db_failover_on_db_error.call(e)
105
113
  end
106
114
 
107
-
108
115
  def override_present?(experiment_name)
109
- override_alternative(experiment_name)
116
+ override_alternative_by_params(experiment_name) || override_alternative_by_cookies(experiment_name)
110
117
  end
111
118
 
112
119
  def override_alternative(experiment_name)
120
+ override_alternative_by_params(experiment_name) || override_alternative_by_cookies(experiment_name)
121
+ end
122
+
123
+ def override_alternative_by_params(experiment_name)
113
124
  defined?(params) && params[OVERRIDE_PARAM_NAME] && params[OVERRIDE_PARAM_NAME][experiment_name]
114
125
  end
115
126
 
127
+ def override_alternative_by_cookies(experiment_name)
128
+ return unless defined?(request)
129
+
130
+ if request.cookies && request.cookies.key?('split_override')
131
+ experiments = JSON.parse(request.cookies['split_override']) rescue {}
132
+ experiments[experiment_name]
133
+ end
134
+ end
135
+
116
136
  def split_generically_disabled?
117
137
  defined?(params) && params['SPLIT_DISABLE']
118
138
  end
@@ -122,7 +142,7 @@ module Split
122
142
  end
123
143
 
124
144
  def exclude_visitor?
125
- instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?
145
+ defined?(request) && (instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?)
126
146
  end
127
147
 
128
148
  def is_robot?