split 3.3.0 → 4.0.0.pre

Sign up to get free protection for your applications and to get access to all the features.
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?