split 3.4.1 → 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 (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