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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.rubocop.yml +177 -1
  4. data/.rubocop_todo.yml +40 -493
  5. data/.travis.yml +14 -42
  6. data/CHANGELOG.md +35 -0
  7. data/Gemfile +1 -0
  8. data/README.md +19 -1
  9. data/Rakefile +1 -0
  10. data/lib/split.rb +8 -2
  11. data/lib/split/algorithms/block_randomization.rb +1 -0
  12. data/lib/split/algorithms/weighted_sample.rb +2 -1
  13. data/lib/split/algorithms/whiplash.rb +3 -2
  14. data/lib/split/alternative.rb +1 -0
  15. data/lib/split/cache.rb +28 -0
  16. data/lib/split/combined_experiments_helper.rb +1 -0
  17. data/lib/split/configuration.rb +6 -12
  18. data/lib/split/dashboard.rb +17 -2
  19. data/lib/split/dashboard/helpers.rb +1 -0
  20. data/lib/split/dashboard/pagination_helpers.rb +1 -0
  21. data/lib/split/dashboard/paginator.rb +1 -0
  22. data/lib/split/dashboard/public/dashboard.js +10 -0
  23. data/lib/split/dashboard/public/style.css +5 -0
  24. data/lib/split/dashboard/views/_controls.erb +13 -0
  25. data/lib/split/encapsulated_helper.rb +3 -2
  26. data/lib/split/engine.rb +1 -0
  27. data/lib/split/exceptions.rb +1 -0
  28. data/lib/split/experiment.rb +81 -59
  29. data/lib/split/experiment_catalog.rb +1 -3
  30. data/lib/split/extensions/string.rb +1 -0
  31. data/lib/split/goals_collection.rb +1 -0
  32. data/lib/split/helper.rb +26 -7
  33. data/lib/split/metric.rb +2 -1
  34. data/lib/split/persistence.rb +4 -2
  35. data/lib/split/persistence/cookie_adapter.rb +1 -0
  36. data/lib/split/persistence/redis_adapter.rb +5 -0
  37. data/lib/split/persistence/session_adapter.rb +1 -0
  38. data/lib/split/redis_interface.rb +8 -28
  39. data/lib/split/trial.rb +20 -10
  40. data/lib/split/user.rb +14 -2
  41. data/lib/split/version.rb +2 -4
  42. data/lib/split/zscore.rb +1 -0
  43. data/spec/alternative_spec.rb +1 -1
  44. data/spec/cache_spec.rb +88 -0
  45. data/spec/configuration_spec.rb +1 -14
  46. data/spec/dashboard_spec.rb +45 -5
  47. data/spec/encapsulated_helper_spec.rb +1 -1
  48. data/spec/experiment_spec.rb +78 -7
  49. data/spec/goals_collection_spec.rb +1 -1
  50. data/spec/helper_spec.rb +68 -32
  51. data/spec/persistence/cookie_adapter_spec.rb +1 -1
  52. data/spec/persistence/redis_adapter_spec.rb +9 -0
  53. data/spec/redis_interface_spec.rb +0 -69
  54. data/spec/spec_helper.rb +5 -6
  55. data/spec/trial_spec.rb +45 -19
  56. data/spec/user_spec.rb +17 -0
  57. data/split.gemspec +7 -7
  58. metadata +23 -34
  59. data/gemfiles/4.2.gemfile +0 -9
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "split/helper"
3
4
 
4
5
  # Split's helper exposes all kinds of methods we don't want to
@@ -28,8 +29,8 @@ module Split
28
29
  end
29
30
  end
30
31
 
31
- def ab_test(*arguments,&block)
32
- split_context_shim.ab_test(*arguments,&block)
32
+ def ab_test(*arguments, &block)
33
+ split_context_shim.ab_test(*arguments, &block)
33
34
  end
34
35
 
35
36
  private
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
4
  class Engine < ::Rails::Engine
4
5
  initializer "split" do |app|
@@ -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,4 +1,7 @@
1
1
  # frozen_string_literal: true
2
+
3
+ require 'rubystats'
4
+
2
5
  module Split
3
6
  class Experiment
4
7
  attr_accessor :name
@@ -13,26 +16,19 @@ module Split
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
 
@@ -87,8 +87,8 @@ module Split
87
87
  persist_experiment_configuration
88
88
  end
89
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,11 +135,13 @@ 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
 
@@ -151,10 +153,11 @@ module Split
151
153
  def winner=(winner_name)
152
154
  redis.hset(:experiment_winner, name, winner_name.to_s)
153
155
  @has_winner = true
156
+ Split.configuration.on_experiment_winner_choose.call(self)
154
157
  end
155
158
 
156
159
  def participant_count
157
- alternatives.inject(0){|sum,a| sum + a.participant_count}
160
+ alternatives.inject(0){|sum, a| sum + a.participant_count}
158
161
  end
159
162
 
160
163
  def control
@@ -164,6 +167,7 @@ module Split
164
167
  def reset_winner
165
168
  redis.hdel(:experiment_winner, name)
166
169
  @has_winner = false
170
+ Split::Cache.clear_key(@name)
167
171
  end
168
172
 
169
173
  def start
@@ -171,13 +175,15 @@ module Split
171
175
  end
172
176
 
173
177
  def start_time
174
- t = redis.hget(:experiment_start_times, @name)
175
- if t
176
- # Check if stored time is an integer
177
- if t =~ /^[-+]?[0-9]+$/
178
- Time.at(t.to_i)
179
- else
180
- 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
181
187
  end
182
188
  end
183
189
  end
@@ -228,6 +234,7 @@ module Split
228
234
 
229
235
  def reset
230
236
  Split.configuration.on_before_experiment_reset.call(self)
237
+ Split::Cache.clear_key(@name)
231
238
  alternatives.each(&:reset)
232
239
  reset_winner
233
240
  Split.configuration.on_experiment_reset.call(self)
@@ -241,6 +248,7 @@ module Split
241
248
  end
242
249
  reset_winner
243
250
  redis.srem(:experiments, name)
251
+ remove_experiment_cohorting
244
252
  remove_experiment_configuration
245
253
  Split.configuration.on_experiment_delete.call(self)
246
254
  increment_version
@@ -338,7 +346,7 @@ module Split
338
346
 
339
347
  def find_simulated_winner(simulated_cr_hash)
340
348
  # figure out which alternative had the highest simulated conversion rate
341
- winning_pair = ["",0.0]
349
+ winning_pair = ["", 0.0]
342
350
  simulated_cr_hash.each do |alternative, rate|
343
351
  if rate > winning_pair[1]
344
352
  winning_pair = [alternative, rate]
@@ -349,17 +357,13 @@ module Split
349
357
  end
350
358
 
351
359
  def calc_simulated_conversion_rates(beta_params)
352
- # initialize a random variable (from which to simulate conversion rates ~beta-distributed)
353
- rand = SimpleRandom.new
354
- rand.set_seed
355
-
356
360
  simulated_cr_hash = {}
357
361
 
358
362
  # create a hash which has the conversion rate pulled from each alternative's beta distribution
359
363
  beta_params.each do |alternative, params|
360
364
  alpha = params[0]
361
365
  beta = params[1]
362
- simulated_conversion_rate = rand.beta(alpha, beta)
366
+ simulated_conversion_rate = Rubystats::BetaDistribution.new(alpha, beta).rng
363
367
  simulated_cr_hash[alternative] = simulated_conversion_rate
364
368
  end
365
369
 
@@ -397,6 +401,23 @@ module Split
397
401
  js_id.gsub('/', '--')
398
402
  end
399
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
+
400
421
  protected
401
422
 
402
423
  def experiment_config_key
@@ -423,15 +444,7 @@ module Split
423
444
  end
424
445
 
425
446
  def load_alternatives_from_redis
426
- alternatives = case redis.type(@name)
427
- when 'set' # convert legacy sets to lists
428
- alts = redis.smembers(@name)
429
- redis.del(@name)
430
- alts.reverse.each {|a| redis.lpush(@name, a) }
431
- redis.lrange(@name, 0, -1)
432
- else
433
- redis.lrange(@name, 0, -1)
434
- end
447
+ alternatives = redis.lrange(@name, 0, -1)
435
448
  alternatives.map do |alt|
436
449
  alt = begin
437
450
  JSON.parse(alt)
@@ -456,7 +469,12 @@ module Split
456
469
  redis_interface.add_to_set(:experiments, name)
457
470
  redis_interface.persist_list(name, @alternatives.map{|alt| {alt.name => alt.weight}.to_json})
458
471
  goals_collection.save
459
- 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
460
478
  end
461
479
 
462
480
  def remove_experiment_configuration
@@ -467,16 +485,20 @@ module Split
467
485
  end
468
486
 
469
487
  def experiment_configuration_has_changed?
470
- existing_alternatives = load_alternatives_from_redis
471
- existing_goals = Split::GoalsCollection.new(@name).load_from_redis
472
- existing_metadata = load_metadata_from_redis
473
- existing_alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
474
- existing_goals != @goals ||
475
- 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
476
493
  end
477
494
 
478
495
  def goals_collection
479
496
  Split::GoalsCollection.new(@name, @goals)
480
497
  end
498
+
499
+ def remove_experiment_cohorting
500
+ @cohorting_disabled = false
501
+ redis.hdel(experiment_config_key, :cohorting)
502
+ end
481
503
  end
482
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,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
4
  class GoalsCollection
4
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"
@@ -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
@@ -51,9 +52,14 @@ module Split
51
52
  return true
52
53
  else
53
54
  alternative_name = ab_user[experiment.key]
54
- trial = Trial.new(:user => ab_user, :experiment => experiment,
55
- :alternative => alternative_name)
56
- 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)
57
63
 
58
64
  if should_reset
59
65
  reset!(experiment)
@@ -70,6 +76,7 @@ module Split
70
76
 
71
77
  if experiments.any?
72
78
  experiments.each do |experiment|
79
+ next if override_present?(experiment.key)
73
80
  finish_experiment(experiment, options.merge(:goals => goals))
74
81
  end
75
82
  end
@@ -105,15 +112,27 @@ module Split
105
112
  Split.configuration.db_failover_on_db_error.call(e)
106
113
  end
107
114
 
108
-
109
115
  def override_present?(experiment_name)
110
- override_alternative(experiment_name)
116
+ override_alternative_by_params(experiment_name) || override_alternative_by_cookies(experiment_name)
111
117
  end
112
118
 
113
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)
114
124
  defined?(params) && params[OVERRIDE_PARAM_NAME] && params[OVERRIDE_PARAM_NAME][experiment_name]
115
125
  end
116
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
+
117
136
  def split_generically_disabled?
118
137
  defined?(params) && params['SPLIT_DISABLE']
119
138
  end
@@ -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,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