split 3.0.0 → 4.0.1

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 (82) hide show
  1. checksums.yaml +5 -5
  2. data/.eslintrc +1 -1
  3. data/.github/FUNDING.yml +1 -0
  4. data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
  5. data/.github/dependabot.yml +7 -0
  6. data/.github/workflows/ci.yml +71 -0
  7. data/.rspec +1 -0
  8. data/.rubocop.yml +71 -1044
  9. data/.rubocop_todo.yml +226 -0
  10. data/Appraisals +12 -1
  11. data/CHANGELOG.md +157 -0
  12. data/CODE_OF_CONDUCT.md +3 -3
  13. data/CONTRIBUTING.md +54 -5
  14. data/Gemfile +2 -0
  15. data/LICENSE +1 -1
  16. data/README.md +232 -121
  17. data/Rakefile +2 -0
  18. data/gemfiles/5.0.gemfile +1 -2
  19. data/gemfiles/{4.2.gemfile → 5.1.gemfile} +2 -2
  20. data/gemfiles/5.2.gemfile +9 -0
  21. data/gemfiles/6.0.gemfile +9 -0
  22. data/gemfiles/6.1.gemfile +9 -0
  23. data/gemfiles/7.0.gemfile +9 -0
  24. data/lib/split/algorithms/block_randomization.rb +2 -0
  25. data/lib/split/algorithms/weighted_sample.rb +2 -1
  26. data/lib/split/algorithms/whiplash.rb +3 -2
  27. data/lib/split/alternative.rb +7 -3
  28. data/lib/split/cache.rb +28 -0
  29. data/lib/split/combined_experiments_helper.rb +38 -0
  30. data/lib/split/configuration.rb +24 -13
  31. data/lib/split/dashboard/helpers.rb +3 -2
  32. data/lib/split/dashboard/pagination_helpers.rb +87 -0
  33. data/lib/split/dashboard/paginator.rb +17 -0
  34. data/lib/split/dashboard/public/dashboard.js +10 -0
  35. data/lib/split/dashboard/public/style.css +14 -0
  36. data/lib/split/dashboard/views/_controls.erb +13 -0
  37. data/lib/split/dashboard/views/index.erb +5 -1
  38. data/lib/split/dashboard/views/layout.erb +1 -1
  39. data/lib/split/dashboard.rb +21 -1
  40. data/lib/split/encapsulated_helper.rb +3 -2
  41. data/lib/split/engine.rb +7 -2
  42. data/lib/split/exceptions.rb +1 -0
  43. data/lib/split/experiment.rb +103 -69
  44. data/lib/split/experiment_catalog.rb +1 -3
  45. data/lib/split/extensions/string.rb +1 -0
  46. data/lib/split/goals_collection.rb +2 -0
  47. data/lib/split/helper.rb +42 -9
  48. data/lib/split/metric.rb +2 -1
  49. data/lib/split/persistence/cookie_adapter.rb +58 -15
  50. data/lib/split/persistence/dual_adapter.rb +54 -12
  51. data/lib/split/persistence/redis_adapter.rb +5 -0
  52. data/lib/split/persistence/session_adapter.rb +1 -0
  53. data/lib/split/persistence.rb +4 -2
  54. data/lib/split/redis_interface.rb +9 -30
  55. data/lib/split/trial.rb +25 -17
  56. data/lib/split/user.rb +20 -4
  57. data/lib/split/version.rb +2 -4
  58. data/lib/split/zscore.rb +1 -0
  59. data/lib/split.rb +17 -3
  60. data/spec/alternative_spec.rb +13 -1
  61. data/spec/cache_spec.rb +88 -0
  62. data/spec/combined_experiments_helper_spec.rb +57 -0
  63. data/spec/configuration_spec.rb +17 -15
  64. data/spec/dashboard/pagination_helpers_spec.rb +200 -0
  65. data/spec/dashboard/paginator_spec.rb +37 -0
  66. data/spec/dashboard_helpers_spec.rb +2 -2
  67. data/spec/dashboard_spec.rb +78 -17
  68. data/spec/encapsulated_helper_spec.rb +2 -2
  69. data/spec/experiment_spec.rb +117 -13
  70. data/spec/goals_collection_spec.rb +1 -1
  71. data/spec/helper_spec.rb +211 -112
  72. data/spec/persistence/cookie_adapter_spec.rb +90 -23
  73. data/spec/persistence/dual_adapter_spec.rb +160 -68
  74. data/spec/persistence/redis_adapter_spec.rb +9 -0
  75. data/spec/redis_interface_spec.rb +0 -69
  76. data/spec/spec_helper.rb +5 -6
  77. data/spec/split_spec.rb +7 -7
  78. data/spec/trial_spec.rb +65 -19
  79. data/spec/user_spec.rb +45 -3
  80. data/split.gemspec +20 -10
  81. metadata +61 -35
  82. data/.travis.yml +0 -16
@@ -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.hmset(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
@@ -262,10 +273,11 @@ module Split
262
273
  end
263
274
 
264
275
  def calc_winning_alternatives
265
- # Super simple cache so that we only recalculate winning alternatives once per day
266
- days_since_epoch = Time.now.utc.to_i / 86400
276
+ # Cache the winning alternatives so we recalculate them once per the specified interval.
277
+ intervals_since_epoch =
278
+ Time.now.utc.to_i / Split.configuration.winning_alternative_recalculation_interval
267
279
 
268
- if self.calc_time != days_since_epoch
280
+ if self.calc_time != intervals_since_epoch
269
281
  if goals.empty?
270
282
  self.estimate_winning_alternative
271
283
  else
@@ -274,7 +286,7 @@ module Split
274
286
  end
275
287
  end
276
288
 
277
- self.calc_time = days_since_epoch
289
+ self.calc_time = intervals_since_epoch
278
290
 
279
291
  self.save
280
292
  end
@@ -334,7 +346,7 @@ module Split
334
346
 
335
347
  def find_simulated_winner(simulated_cr_hash)
336
348
  # figure out which alternative had the highest simulated conversion rate
337
- winning_pair = ["",0.0]
349
+ winning_pair = ["", 0.0]
338
350
  simulated_cr_hash.each do |alternative, rate|
339
351
  if rate > winning_pair[1]
340
352
  winning_pair = [alternative, rate]
@@ -345,17 +357,13 @@ module Split
345
357
  end
346
358
 
347
359
  def calc_simulated_conversion_rates(beta_params)
348
- # initialize a random variable (from which to simulate conversion rates ~beta-distributed)
349
- rand = SimpleRandom.new
350
- rand.set_seed
351
-
352
360
  simulated_cr_hash = {}
353
361
 
354
362
  # create a hash which has the conversion rate pulled from each alternative's beta distribution
355
363
  beta_params.each do |alternative, params|
356
364
  alpha = params[0]
357
365
  beta = params[1]
358
- simulated_conversion_rate = rand.beta(alpha, beta)
366
+ simulated_conversion_rate = Rubystats::BetaDistribution.new(alpha, beta).rng
359
367
  simulated_cr_hash[alternative] = simulated_conversion_rate
360
368
  end
361
369
 
@@ -393,6 +401,23 @@ module Split
393
401
  js_id.gsub('/', '--')
394
402
  end
395
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
+
396
421
  protected
397
422
 
398
423
  def experiment_config_key
@@ -419,14 +444,14 @@ module Split
419
444
  end
420
445
 
421
446
  def load_alternatives_from_redis
422
- case redis.type(@name)
423
- when 'set' # convert legacy sets to lists
424
- alts = redis.smembers(@name)
425
- redis.del(@name)
426
- alts.reverse.each {|a| redis.lpush(@name, a) }
427
- redis.lrange(@name, 0, -1)
428
- else
429
- 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)
430
455
  end
431
456
  end
432
457
 
@@ -442,9 +467,14 @@ module Split
442
467
 
443
468
  def persist_experiment_configuration
444
469
  redis_interface.add_to_set(:experiments, name)
445
- redis_interface.persist_list(name, @alternatives.map(&:name))
470
+ redis_interface.persist_list(name, @alternatives.map{|alt| {alt.name => alt.weight}.to_json})
446
471
  goals_collection.save
447
- 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
448
478
  end
449
479
 
450
480
  def remove_experiment_configuration
@@ -455,16 +485,20 @@ module Split
455
485
  end
456
486
 
457
487
  def experiment_configuration_has_changed?
458
- existing_alternatives = load_alternatives_from_redis
459
- existing_goals = Split::GoalsCollection.new(@name).load_from_redis
460
- existing_metadata = load_metadata_from_redis
461
- existing_alternatives != @alternatives.map(&:name) ||
462
- existing_goals != @goals ||
463
- 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
464
493
  end
465
494
 
466
495
  def goals_collection
467
496
  Split::GoalsCollection.new(@name, @goals)
468
497
  end
498
+
499
+ def remove_experiment_cohorting
500
+ @cohorting_disabled = false
501
+ redis.hdel(experiment_config_key, :cohorting)
502
+ end
469
503
  end
470
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
 
data/lib/split/helper.rb CHANGED
@@ -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,8 +9,9 @@ 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
14
+ raise(Split::InvalidExperimentsFormatError) unless (Split.configuration.experiments || {}).fetch(experiment.name.to_sym, {})[:combined_experiments].nil?
13
15
  trial = Trial.new(:user => ab_user, :experiment => experiment,
14
16
  :override => override_alternative(experiment.name), :exclude => exclude_visitor?,
15
17
  :disabled => split_generically_disabled?)
@@ -31,8 +33,8 @@ module Split
31
33
  end
32
34
 
33
35
  if block_given?
34
- metadata = trial ? trial.metadata : {}
35
- yield(alternative, metadata)
36
+ metadata = experiment.metadata[alternative] if experiment.metadata
37
+ yield(alternative, metadata || {})
36
38
  else
37
39
  alternative
38
40
  end
@@ -43,15 +45,21 @@ module Split
43
45
  end
44
46
 
45
47
  def finish_experiment(experiment, options = {:reset => true})
48
+ return false if active_experiments[experiment.name].nil?
46
49
  return true if experiment.has_winner?
47
50
  should_reset = experiment.resettable? && options[:reset]
48
51
  if ab_user[experiment.finished_key] && !should_reset
49
52
  return true
50
53
  else
51
54
  alternative_name = ab_user[experiment.key]
52
- trial = Trial.new(:user => ab_user, :experiment => experiment,
53
- :alternative => alternative_name)
54
- 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)
55
63
 
56
64
  if should_reset
57
65
  reset!(experiment)
@@ -68,6 +76,7 @@ module Split
68
76
 
69
77
  if experiments.any?
70
78
  experiments.each do |experiment|
79
+ next if override_present?(experiment.key)
71
80
  finish_experiment(experiment, options.merge(:goals => goals))
72
81
  end
73
82
  end
@@ -78,7 +87,7 @@ module Split
78
87
 
79
88
  def ab_record_extra_info(metric_descriptor, key, value = 1)
80
89
  return if exclude_visitor? || Split.configuration.disabled?
81
- metric_descriptor, goals = normalize_metric(metric_descriptor)
90
+ metric_descriptor, _ = normalize_metric(metric_descriptor)
82
91
  experiments = Metric.possible_experiments(metric_descriptor)
83
92
 
84
93
  if experiments.any?
@@ -96,14 +105,34 @@ module Split
96
105
  Split.configuration.db_failover_on_db_error.call(e)
97
106
  end
98
107
 
108
+ def ab_active_experiments()
109
+ ab_user.active_experiments
110
+ rescue => e
111
+ raise unless Split.configuration.db_failover
112
+ Split.configuration.db_failover_on_db_error.call(e)
113
+ end
114
+
99
115
  def override_present?(experiment_name)
100
- override_alternative(experiment_name)
116
+ override_alternative_by_params(experiment_name) || override_alternative_by_cookies(experiment_name)
101
117
  end
102
118
 
103
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)
104
124
  defined?(params) && params[OVERRIDE_PARAM_NAME] && params[OVERRIDE_PARAM_NAME][experiment_name]
105
125
  end
106
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
+
107
136
  def split_generically_disabled?
108
137
  defined?(params) && params['SPLIT_DISABLE']
109
138
  end
@@ -113,13 +142,17 @@ module Split
113
142
  end
114
143
 
115
144
  def exclude_visitor?
116
- instance_eval(&Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot?
145
+ defined?(request) && (instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?)
117
146
  end
118
147
 
119
148
  def is_robot?
120
149
  defined?(request) && request.user_agent =~ Split.configuration.robot_regex
121
150
  end
122
151
 
152
+ def is_preview?
153
+ defined?(request) && defined?(request.headers) && request.headers['x-purpose'] == 'preview'
154
+ end
155
+
123
156
  def is_ignored_ip_address?
124
157
  return false if Split.configuration.ignore_ip_addresses.empty?
125
158
 
data/lib/split/metric.rb CHANGED
@@ -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
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "json"
3
4
 
4
5
  module Split
@@ -6,20 +7,22 @@ module Split
6
7
  class CookieAdapter
7
8
 
8
9
  def initialize(context)
9
- @cookies = context.send(:cookies)
10
+ @context = context
11
+ @request, @response = context.request, context.response
12
+ @cookies = @request.cookies
10
13
  @expires = Time.now + cookie_length_config
11
14
  end
12
15
 
13
16
  def [](key)
14
- hash[key]
17
+ hash[key.to_s]
15
18
  end
16
19
 
17
20
  def []=(key, value)
18
- set_cookie(hash.merge(key => value))
21
+ set_cookie(hash.merge!(key.to_s => value))
19
22
  end
20
23
 
21
24
  def delete(key)
22
- set_cookie(hash.tap { |h| h.delete(key) })
25
+ set_cookie(hash.tap { |h| h.delete(key.to_s) })
23
26
  end
24
27
 
25
28
  def keys
@@ -28,22 +31,55 @@ module Split
28
31
 
29
32
  private
30
33
 
31
- def set_cookie(value)
32
- @cookies[:split] = {
33
- :value => JSON.generate(value),
34
- :expires => @expires
35
- }
34
+ def set_cookie(value = {})
35
+ cookie_key = :split.to_s
36
+ cookie_value = default_options.merge(value: JSON.generate(value))
37
+ if action_dispatch?
38
+ # The "send" is necessary when we call ab_test from the controller
39
+ # and thus @context is a rails controller, because then "cookies" is
40
+ # a private method.
41
+ @context.send(:cookies)[cookie_key] = cookie_value
42
+ else
43
+ set_cookie_via_rack(cookie_key, cookie_value)
44
+ end
45
+ end
46
+
47
+ def default_options
48
+ { expires: @expires, path: '/', domain: cookie_domain_config }.compact
49
+ end
50
+
51
+ def set_cookie_via_rack(key, value)
52
+ delete_cookie_header!(@response.header, key, value)
53
+ Rack::Utils.set_cookie_header!(@response.header, key, value)
54
+ end
55
+
56
+ # Use Rack::Utils#make_delete_cookie_header after Rack 2.0.0
57
+ def delete_cookie_header!(header, key, value)
58
+ cookie_header = header['Set-Cookie']
59
+ case cookie_header
60
+ when nil, ''
61
+ cookies = []
62
+ when String
63
+ cookies = cookie_header.split("\n")
64
+ when Array
65
+ cookies = cookie_header
66
+ end
67
+
68
+ cookies.reject! { |cookie| cookie =~ /\A#{Rack::Utils.escape(key)}=/ }
69
+ header['Set-Cookie'] = cookies.join("\n")
36
70
  end
37
71
 
38
72
  def hash
39
- if @cookies[:split]
40
- begin
41
- JSON.parse(@cookies[:split])
42
- rescue JSON::ParserError
73
+ @hash ||= begin
74
+ if cookies = @cookies[:split.to_s]
75
+ begin
76
+ JSON.parse(cookies)
77
+ rescue JSON::ParserError
78
+ {}
79
+ end
80
+ else
43
81
  {}
44
82
  end
45
- else
46
- {}
47
83
  end
48
84
  end
49
85
 
@@ -51,6 +87,13 @@ module Split
51
87
  Split.configuration.persistence_cookie_length
52
88
  end
53
89
 
90
+ def cookie_domain_config
91
+ Split.configuration.persistence_cookie_domain
92
+ end
93
+
94
+ def action_dispatch?
95
+ defined?(Rails) && @response.is_a?(ActionDispatch::Response)
96
+ end
54
97
  end
55
98
  end
56
99
  end