split 3.0.0 → 3.4.0

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 (58) hide show
  1. checksums.yaml +5 -5
  2. data/.eslintrc +1 -1
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
  4. data/.rspec +1 -0
  5. data/.rubocop.yml +6 -1155
  6. data/.rubocop_todo.yml +679 -0
  7. data/.travis.yml +46 -2
  8. data/Appraisals +12 -1
  9. data/CHANGELOG.md +116 -0
  10. data/CODE_OF_CONDUCT.md +3 -3
  11. data/CONTRIBUTING.md +54 -5
  12. data/Gemfile +1 -0
  13. data/LICENSE +1 -1
  14. data/README.md +209 -118
  15. data/Rakefile +1 -0
  16. data/gemfiles/4.2.gemfile +1 -1
  17. data/gemfiles/5.0.gemfile +1 -2
  18. data/gemfiles/5.1.gemfile +9 -0
  19. data/gemfiles/5.2.gemfile +9 -0
  20. data/gemfiles/6.0.gemfile +9 -0
  21. data/lib/split/algorithms/block_randomization.rb +1 -0
  22. data/lib/split/alternative.rb +6 -3
  23. data/lib/split/combined_experiments_helper.rb +37 -0
  24. data/lib/split/configuration.rb +17 -2
  25. data/lib/split/dashboard/helpers.rb +2 -2
  26. data/lib/split/dashboard/pagination_helpers.rb +86 -0
  27. data/lib/split/dashboard/paginator.rb +16 -0
  28. data/lib/split/dashboard/public/style.css +9 -0
  29. data/lib/split/dashboard/views/index.erb +5 -1
  30. data/lib/split/dashboard/views/layout.erb +1 -1
  31. data/lib/split/dashboard.rb +6 -1
  32. data/lib/split/engine.rb +6 -2
  33. data/lib/split/experiment.rb +34 -22
  34. data/lib/split/goals_collection.rb +1 -0
  35. data/lib/split/helper.rb +17 -3
  36. data/lib/split/persistence/cookie_adapter.rb +53 -15
  37. data/lib/split/persistence/dual_adapter.rb +54 -12
  38. data/lib/split/redis_interface.rb +2 -3
  39. data/lib/split/trial.rb +4 -6
  40. data/lib/split/user.rb +5 -1
  41. data/lib/split/version.rb +1 -1
  42. data/lib/split.rb +9 -1
  43. data/spec/alternative_spec.rb +12 -0
  44. data/spec/combined_experiments_helper_spec.rb +57 -0
  45. data/spec/dashboard/pagination_helpers_spec.rb +200 -0
  46. data/spec/dashboard/paginator_spec.rb +37 -0
  47. data/spec/dashboard_helpers_spec.rb +2 -2
  48. data/spec/dashboard_spec.rb +37 -16
  49. data/spec/encapsulated_helper_spec.rb +1 -1
  50. data/spec/experiment_spec.rb +45 -6
  51. data/spec/helper_spec.rb +143 -80
  52. data/spec/persistence/cookie_adapter_spec.rb +90 -23
  53. data/spec/persistence/dual_adapter_spec.rb +160 -68
  54. data/spec/split_spec.rb +7 -7
  55. data/spec/trial_spec.rb +20 -0
  56. data/spec/user_spec.rb +11 -0
  57. data/split.gemspec +17 -7
  58. metadata +53 -19
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ module CombinedExperimentsHelper
4
+ def ab_combined_test(metric_descriptor, control = nil, *alternatives)
5
+ return nil unless experiment = find_combined_experiment(metric_descriptor)
6
+ raise(Split::InvalidExperimentsFormatError, "Unable to find experiment #{metric_descriptor} in configuration") if experiment[:combined_experiments].nil?
7
+
8
+ alternative = nil
9
+ weighted_alternatives = nil
10
+ experiment[:combined_experiments].each do |combined_experiment|
11
+ if alternative.nil?
12
+ if control
13
+ alternative = ab_test(combined_experiment, control, alternatives)
14
+ else
15
+ normalized_alternatives = Split::Configuration.new.normalize_alternatives(experiment[:alternatives])
16
+ alternative = ab_test(combined_experiment, normalized_alternatives[0], *normalized_alternatives[1])
17
+ end
18
+ else
19
+ weighted_alternatives ||= experiment[:alternatives].each_with_object({}) do |alt, memo|
20
+ alt = Alternative.new(alt, experiment[:name]).name
21
+ memo[alt] = (alt == alternative ? 1 : 0)
22
+ end
23
+
24
+ ab_test(combined_experiment, [weighted_alternatives])
25
+ end
26
+ end
27
+ alternative
28
+ end
29
+
30
+ def find_combined_experiment(metric_descriptor)
31
+ raise(Split::InvalidExperimentsFormatError, 'Invalid descriptor class (String or Symbol required)') unless metric_descriptor.class == String || metric_descriptor.class == Symbol
32
+ raise(Split::InvalidExperimentsFormatError, 'Enable configuration') unless Split.configuration.enabled
33
+ raise(Split::InvalidExperimentsFormatError, 'Enable `allow_multiple_experiments`') unless Split.configuration.allow_multiple_experiments
34
+ Split::configuration.experiments[metric_descriptor.to_sym]
35
+ end
36
+ end
37
+ end
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  module Split
3
3
  class Configuration
4
- attr_accessor :bots
5
- attr_accessor :robot_regex
6
4
  attr_accessor :ignore_ip_addresses
7
5
  attr_accessor :ignore_filter
8
6
  attr_accessor :db_failover
@@ -25,10 +23,15 @@ module Split
25
23
  attr_accessor :on_before_experiment_delete
26
24
  attr_accessor :include_rails_helper
27
25
  attr_accessor :beta_probability_simulations
26
+ attr_accessor :winning_alternative_recalculation_interval
28
27
  attr_accessor :redis
28
+ attr_accessor :dashboard_pagination_default_per_page
29
29
 
30
30
  attr_reader :experiments
31
31
 
32
+ attr_writer :bots
33
+ attr_writer :robot_regex
34
+
32
35
  def bots
33
36
  @bots ||= {
34
37
  # Indexers
@@ -48,7 +51,9 @@ module Split
48
51
  'spider' => 'generic web spider',
49
52
  'UnwindFetchor' => 'Gnip crawler',
50
53
  'WordPress' => 'WordPress spider',
54
+ 'YandexAccessibilityBot' => 'Yandex accessibility spider',
51
55
  'YandexBot' => 'Yandex spider',
56
+ 'YandexMobileBot' => 'Yandex mobile spider',
52
57
  'ZIBB' => 'ZIBB spider',
53
58
 
54
59
  # HTTP libraries
@@ -58,12 +63,14 @@ module Split
58
63
  'ColdFusion' => 'ColdFusion http library',
59
64
  'EventMachine HttpClient' => 'Ruby http library',
60
65
  'Go http package' => 'Go http library',
66
+ 'Go-http-client' => 'Go http library',
61
67
  'Java' => 'Generic Java http library',
62
68
  'libwww-perl' => 'Perl client-server library loved by script kids',
63
69
  'lwp-trivial' => 'Another Perl library loved by script kids',
64
70
  'Python-urllib' => 'Python http library',
65
71
  'PycURL' => 'Python http library',
66
72
  'Test Certificate Info' => 'C http library?',
73
+ 'Typhoeus' => 'Ruby http library',
67
74
  'Wget' => 'wget unix CLI http client',
68
75
 
69
76
  # URL expanders / previewers
@@ -71,12 +78,16 @@ module Split
71
78
  'bitlybot' => 'bit.ly bot',
72
79
  'bot@linkfluence.net' => 'Linkfluence bot',
73
80
  'facebookexternalhit' => 'facebook bot',
81
+ 'Facebot' => 'Facebook crawler',
74
82
  'Feedfetcher-Google' => 'Google Feedfetcher',
75
83
  'https://developers.google.com/+/web/snippet' => 'Google+ Snippet Fetcher',
84
+ 'LinkedInBot' => 'LinkedIn bot',
76
85
  'LongURL' => 'URL expander service',
77
86
  'NING' => 'NING - Yet Another Twitter Swarmer',
87
+ 'Pinterest' => 'Pinterest Bot',
78
88
  'redditbot' => 'Reddit Bot',
79
89
  'ShortLinkTranslate' => 'Link shortener',
90
+ 'Slackbot' => 'Slackbot link expander',
80
91
  'TweetmemeBot' => 'TweetMeMe Crawler',
81
92
  'Twitterbot' => 'Twitter URL expander',
82
93
  'UnwindFetch' => 'Gnip URL expander',
@@ -84,10 +95,12 @@ module Split
84
95
 
85
96
  # Uptime monitoring
86
97
  'check_http' => 'Nagios monitor',
98
+ 'GoogleStackdriverMonitoring' => 'Google Cloud monitor',
87
99
  'NewRelicPinger' => 'NewRelic monitor',
88
100
  'Panopta' => 'Monitoring service',
89
101
  'Pingdom' => 'Pingdom monitoring',
90
102
  'SiteUptime' => 'Site monitoring services',
103
+ 'UptimeRobot' => 'Monitoring service',
91
104
 
92
105
  # ???
93
106
  'DigitalPersona Fingerprint Software' => 'HP Fingerprint scanner',
@@ -212,7 +225,9 @@ module Split
212
225
  @algorithm = Split::Algorithms::WeightedSample
213
226
  @include_rails_helper = true
214
227
  @beta_probability_simulations = 10000
228
+ @winning_alternative_recalculation_interval = 60 * 60 * 24 # 1 day
215
229
  @redis = ENV.fetch(ENV.fetch('REDIS_PROVIDER', 'REDIS_URL'), 'redis://localhost:6379')
230
+ @dashboard_pagination_default_per_page = 10
216
231
  end
217
232
 
218
233
  def redis_url=(value)
@@ -19,9 +19,9 @@ module Split
19
19
 
20
20
  def round(number, precision = 2)
21
21
  begin
22
- BigDecimal.new(number.to_s)
22
+ BigDecimal(number.to_s)
23
23
  rescue ArgumentError
24
- BigDecimal.new(0)
24
+ BigDecimal(0)
25
25
  end.round(precision).to_f
26
26
  end
27
27
 
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+ require 'split/dashboard/paginator'
3
+
4
+ module Split
5
+ module DashboardPaginationHelpers
6
+ def pagination_per
7
+ default_per_page = Split.configuration.dashboard_pagination_default_per_page
8
+ @pagination_per ||= (params[:per] || default_per_page).to_i
9
+ end
10
+
11
+ def page_number
12
+ @page_number ||= (params[:page] || 1).to_i
13
+ end
14
+
15
+ def paginated(collection)
16
+ Split::DashboardPaginator.new(collection, page_number, pagination_per).paginate
17
+ end
18
+
19
+ def pagination(collection)
20
+ html = []
21
+ html << first_page_tag if show_first_page_tag?
22
+ html << ellipsis_tag if show_first_ellipsis_tag?
23
+ html << prev_page_tag if show_prev_page_tag?
24
+ html << current_page_tag
25
+ html << next_page_tag if show_next_page_tag?(collection)
26
+ html << ellipsis_tag if show_last_ellipsis_tag?(collection)
27
+ html << last_page_tag(collection) if show_last_page_tag?(collection)
28
+ html.join
29
+ end
30
+
31
+ private
32
+
33
+ def show_first_page_tag?
34
+ page_number > 2
35
+ end
36
+
37
+ def first_page_tag
38
+ %Q(<a href="#{url.chop}?page=1&per=#{pagination_per}">1</a>)
39
+ end
40
+
41
+ def show_first_ellipsis_tag?
42
+ page_number >= 4
43
+ end
44
+
45
+ def ellipsis_tag
46
+ '<span>...</span>'
47
+ end
48
+
49
+ def show_prev_page_tag?
50
+ page_number > 1
51
+ end
52
+
53
+ def prev_page_tag
54
+ %Q(<a href="#{url.chop}?page=#{page_number - 1}&per=#{pagination_per}">#{page_number - 1}</a>)
55
+ end
56
+
57
+ def current_page_tag
58
+ "<span><b>#{page_number}</b></span>"
59
+ end
60
+
61
+ def show_next_page_tag?(collection)
62
+ (page_number * pagination_per) < collection.count
63
+ end
64
+
65
+ def next_page_tag
66
+ %Q(<a href="#{url.chop}?page=#{page_number + 1}&per=#{pagination_per}">#{page_number + 1}</a>)
67
+ end
68
+
69
+ def show_last_ellipsis_tag?(collection)
70
+ (total_pages(collection) - page_number) >= 3
71
+ end
72
+
73
+ def total_pages(collection)
74
+ collection.count / pagination_per + ((collection.count % pagination_per).zero? ? 0 : 1)
75
+ end
76
+
77
+ def show_last_page_tag?(collection)
78
+ page_number < (total_pages(collection) - 1)
79
+ end
80
+
81
+ def last_page_tag(collection)
82
+ total = total_pages(collection)
83
+ %Q(<a href="#{url.chop}?page=#{total}&per=#{pagination_per}">#{total}</a>)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ class DashboardPaginator
4
+ def initialize(collection, page_number, per)
5
+ @collection = collection
6
+ @page_number = page_number
7
+ @per = per
8
+ end
9
+
10
+ def paginate
11
+ to = @page_number * @per
12
+ from = to - @per
13
+ @collection[from...to]
14
+ end
15
+ end
16
+ end
@@ -317,3 +317,12 @@ a.button.green:focus, button.green:focus, input[type="submit"].green:focus {
317
317
  }
318
318
 
319
319
 
320
+ .pagination {
321
+ text-align: center;
322
+ font-size: 15px;
323
+ }
324
+
325
+ .pagination a, .paginaton span {
326
+ display: inline-block;
327
+ padding: 5px;
328
+ }
@@ -6,7 +6,7 @@
6
6
  <input type="button" id="toggle-active" value="Hide active" />
7
7
  <input type="button" id="clear-filter" value="Clear filters" />
8
8
 
9
- <% @experiments.each do |experiment| %>
9
+ <% paginated(@experiments).each do |experiment| %>
10
10
  <% if experiment.goals.empty? %>
11
11
  <%= erb :_experiment, :locals => {:goal => nil, :experiment => experiment} %>
12
12
  <% else %>
@@ -16,6 +16,10 @@
16
16
  <% end %>
17
17
  <% end %>
18
18
  <% end %>
19
+
20
+ <div class="pagination">
21
+ <%= pagination(@experiments) %>
22
+ </div>
19
23
  <% else %>
20
24
  <p class="intro">No experiments have started yet, you need to define them in your code and introduce them to your users.</p>
21
25
  <p class="intro">Check out the <a href='https://github.com/splitrb/split#readme'>Readme</a> for more help getting started.</p>
@@ -21,7 +21,7 @@
21
21
  </div>
22
22
 
23
23
  <div id="footer">
24
- <p>Powered by <a href="http://github.com/splitrb/split">Split</a> v<%=Split::VERSION %></p>
24
+ <p>Powered by <a href="https://github.com/splitrb/split">Split</a> v<%=Split::VERSION %></p>
25
25
  </div>
26
26
  </body>
27
27
  </html>
@@ -3,6 +3,7 @@ require 'sinatra/base'
3
3
  require 'split'
4
4
  require 'bigdecimal'
5
5
  require 'split/dashboard/helpers'
6
+ require 'split/dashboard/pagination_helpers'
6
7
 
7
8
  module Split
8
9
  class Dashboard < Sinatra::Base
@@ -14,6 +15,7 @@ module Split
14
15
  set :method_override, true
15
16
 
16
17
  helpers Split::DashboardHelpers
18
+ helpers Split::DashboardPaginationHelpers
17
19
 
18
20
  get '/' do
19
21
  # Display experiments without a winner at the top of the dashboard
@@ -31,7 +33,10 @@ module Split
31
33
  end
32
34
 
33
35
  post '/force_alternative' do
34
- Split::User.new(self)[params[:experiment]] = params[:alternative]
36
+ experiment = Split::ExperimentCatalog.find(params[:experiment])
37
+ alternative = Split::Alternative.new(params[:alternative], experiment.name)
38
+ alternative.increment_participation
39
+ Split::User.new(self)[experiment.key] = alternative.name
35
40
  redirect url('/')
36
41
  end
37
42
 
data/lib/split/engine.rb CHANGED
@@ -3,8 +3,12 @@ module Split
3
3
  class Engine < ::Rails::Engine
4
4
  initializer "split" do |app|
5
5
  if Split.configuration.include_rails_helper
6
- ActionController::Base.send :include, Split::Helper
7
- ActionController::Base.helper Split::Helper
6
+ ActiveSupport.on_load(:action_controller) do
7
+ include Split::Helper
8
+ helper Split::Helper
9
+ include Split::CombinedExperimentsHelper
10
+ helper Split::CombinedExperimentsHelper
11
+ end
8
12
  end
9
13
  end
10
14
  end
@@ -2,13 +2,13 @@
2
2
  module Split
3
3
  class Experiment
4
4
  attr_accessor :name
5
- attr_writer :algorithm
6
- attr_accessor :resettable
7
5
  attr_accessor :goals
8
- attr_accessor :alternatives
9
6
  attr_accessor :alternative_probabilities
10
7
  attr_accessor :metadata
11
8
 
9
+ attr_reader :alternatives
10
+ attr_reader :resettable
11
+
12
12
  DEFAULT_OPTIONS = {
13
13
  :resettable => true
14
14
  }
@@ -25,7 +25,7 @@ module Split
25
25
  alternatives: load_alternatives_from_configuration,
26
26
  goals: Split::GoalsCollection.new(@name).load_from_configuration,
27
27
  metadata: load_metadata_from_configuration,
28
- resettable: exp_config[:resettable],
28
+ resettable: exp_config.fetch(:resettable, true),
29
29
  algorithm: exp_config[:algorithm]
30
30
  }
31
31
  else
@@ -62,7 +62,7 @@ module Split
62
62
  alts = load_alternatives_from_configuration
63
63
  options[:goals] = Split::GoalsCollection.new(@name).load_from_configuration
64
64
  options[:metadata] = load_metadata_from_configuration
65
- options[:resettable] = exp_config[:resettable]
65
+ options[:resettable] = exp_config.fetch(:resettable, true)
66
66
  options[:algorithm] = exp_config[:algorithm]
67
67
  end
68
68
  end
@@ -81,12 +81,12 @@ 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
90
  redis.hset(experiment_config_key, :resettable, resettable)
91
91
  redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
92
92
  self
@@ -144,11 +144,13 @@ module Split
144
144
  end
145
145
 
146
146
  def has_winner?
147
- !winner.nil?
147
+ return @has_winner if defined? @has_winner
148
+ @has_winner = !winner.nil?
148
149
  end
149
150
 
150
151
  def winner=(winner_name)
151
152
  redis.hset(:experiment_winner, name, winner_name.to_s)
153
+ @has_winner = true
152
154
  end
153
155
 
154
156
  def participant_count
@@ -161,6 +163,7 @@ module Split
161
163
 
162
164
  def reset_winner
163
165
  redis.hdel(:experiment_winner, name)
166
+ @has_winner = false
164
167
  end
165
168
 
166
169
  def start
@@ -262,10 +265,11 @@ module Split
262
265
  end
263
266
 
264
267
  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
268
+ # Cache the winning alternatives so we recalculate them once per the specified interval.
269
+ intervals_since_epoch =
270
+ Time.now.utc.to_i / Split.configuration.winning_alternative_recalculation_interval
267
271
 
268
- if self.calc_time != days_since_epoch
272
+ if self.calc_time != intervals_since_epoch
269
273
  if goals.empty?
270
274
  self.estimate_winning_alternative
271
275
  else
@@ -274,7 +278,7 @@ module Split
274
278
  end
275
279
  end
276
280
 
277
- self.calc_time = days_since_epoch
281
+ self.calc_time = intervals_since_epoch
278
282
 
279
283
  self.save
280
284
  end
@@ -419,14 +423,22 @@ module Split
419
423
  end
420
424
 
421
425
  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)
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
435
+ alternatives.map do |alt|
436
+ alt = begin
437
+ JSON.parse(alt)
438
+ rescue
439
+ alt
440
+ end
441
+ Split::Alternative.new(alt, @name)
430
442
  end
431
443
  end
432
444
 
@@ -442,7 +454,7 @@ module Split
442
454
 
443
455
  def persist_experiment_configuration
444
456
  redis_interface.add_to_set(:experiments, name)
445
- redis_interface.persist_list(name, @alternatives.map(&:name))
457
+ redis_interface.persist_list(name, @alternatives.map{|alt| {alt.name => alt.weight}.to_json})
446
458
  goals_collection.save
447
459
  redis.set(metadata_key, @metadata.to_json) unless @metadata.nil?
448
460
  end
@@ -458,7 +470,7 @@ module Split
458
470
  existing_alternatives = load_alternatives_from_redis
459
471
  existing_goals = Split::GoalsCollection.new(@name).load_from_redis
460
472
  existing_metadata = load_metadata_from_redis
461
- existing_alternatives != @alternatives.map(&:name) ||
473
+ existing_alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
462
474
  existing_goals != @goals ||
463
475
  existing_metadata != @metadata
464
476
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Split
2
3
  class GoalsCollection
3
4
 
data/lib/split/helper.rb CHANGED
@@ -8,8 +8,9 @@ module Split
8
8
  def ab_test(metric_descriptor, control = nil, *alternatives)
9
9
  begin
10
10
  experiment = ExperimentCatalog.find_or_initialize(metric_descriptor, control, *alternatives)
11
- alternative = if Split.configuration.enabled
11
+ alternative = if Split.configuration.enabled && !exclude_visitor?
12
12
  experiment.save
13
+ raise(Split::InvalidExperimentsFormatError) unless (Split.configuration.experiments || {}).fetch(experiment.name.to_sym, {})[:combined_experiments].nil?
13
14
  trial = Trial.new(:user => ab_user, :experiment => experiment,
14
15
  :override => override_alternative(experiment.name), :exclude => exclude_visitor?,
15
16
  :disabled => split_generically_disabled?)
@@ -43,6 +44,7 @@ module Split
43
44
  end
44
45
 
45
46
  def finish_experiment(experiment, options = {:reset => true})
47
+ return false if active_experiments[experiment.name].nil?
46
48
  return true if experiment.has_winner?
47
49
  should_reset = experiment.resettable? && options[:reset]
48
50
  if ab_user[experiment.finished_key] && !should_reset
@@ -78,7 +80,7 @@ module Split
78
80
 
79
81
  def ab_record_extra_info(metric_descriptor, key, value = 1)
80
82
  return if exclude_visitor? || Split.configuration.disabled?
81
- metric_descriptor, goals = normalize_metric(metric_descriptor)
83
+ metric_descriptor, _ = normalize_metric(metric_descriptor)
82
84
  experiments = Metric.possible_experiments(metric_descriptor)
83
85
 
84
86
  if experiments.any?
@@ -96,6 +98,14 @@ module Split
96
98
  Split.configuration.db_failover_on_db_error.call(e)
97
99
  end
98
100
 
101
+ def ab_active_experiments()
102
+ ab_user.active_experiments
103
+ rescue => e
104
+ raise unless Split.configuration.db_failover
105
+ Split.configuration.db_failover_on_db_error.call(e)
106
+ end
107
+
108
+
99
109
  def override_present?(experiment_name)
100
110
  override_alternative(experiment_name)
101
111
  end
@@ -113,13 +123,17 @@ module Split
113
123
  end
114
124
 
115
125
  def exclude_visitor?
116
- instance_eval(&Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot?
126
+ defined?(request) && (instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?)
117
127
  end
118
128
 
119
129
  def is_robot?
120
130
  defined?(request) && request.user_agent =~ Split.configuration.robot_regex
121
131
  end
122
132
 
133
+ def is_preview?
134
+ defined?(request) && defined?(request.headers) && request.headers['x-purpose'] == 'preview'
135
+ end
136
+
123
137
  def is_ignored_ip_address?
124
138
  return false if Split.configuration.ignore_ip_addresses.empty?
125
139
 
@@ -6,20 +6,22 @@ module Split
6
6
  class CookieAdapter
7
7
 
8
8
  def initialize(context)
9
- @cookies = context.send(:cookies)
9
+ @context = context
10
+ @request, @response = context.request, context.response
11
+ @cookies = @request.cookies
10
12
  @expires = Time.now + cookie_length_config
11
13
  end
12
14
 
13
15
  def [](key)
14
- hash[key]
16
+ hash[key.to_s]
15
17
  end
16
18
 
17
19
  def []=(key, value)
18
- set_cookie(hash.merge(key => value))
20
+ set_cookie(hash.merge!(key.to_s => value))
19
21
  end
20
22
 
21
23
  def delete(key)
22
- set_cookie(hash.tap { |h| h.delete(key) })
24
+ set_cookie(hash.tap { |h| h.delete(key.to_s) })
23
25
  end
24
26
 
25
27
  def keys
@@ -28,22 +30,55 @@ module Split
28
30
 
29
31
  private
30
32
 
31
- def set_cookie(value)
32
- @cookies[:split] = {
33
- :value => JSON.generate(value),
34
- :expires => @expires
35
- }
33
+ def set_cookie(value = {})
34
+ cookie_key = :split.to_s
35
+ cookie_value = default_options.merge(value: JSON.generate(value))
36
+ if action_dispatch?
37
+ # The "send" is necessary when we call ab_test from the controller
38
+ # and thus @context is a rails controller, because then "cookies" is
39
+ # a private method.
40
+ @context.send(:cookies)[cookie_key] = cookie_value
41
+ else
42
+ set_cookie_via_rack(cookie_key, cookie_value)
43
+ end
44
+ end
45
+
46
+ def default_options
47
+ { expires: @expires, path: '/' }
48
+ end
49
+
50
+ def set_cookie_via_rack(key, value)
51
+ delete_cookie_header!(@response.header, key, value)
52
+ Rack::Utils.set_cookie_header!(@response.header, key, value)
53
+ end
54
+
55
+ # Use Rack::Utils#make_delete_cookie_header after Rack 2.0.0
56
+ def delete_cookie_header!(header, key, value)
57
+ cookie_header = header['Set-Cookie']
58
+ case cookie_header
59
+ when nil, ''
60
+ cookies = []
61
+ when String
62
+ cookies = cookie_header.split("\n")
63
+ when Array
64
+ cookies = cookie_header
65
+ end
66
+
67
+ cookies.reject! { |cookie| cookie =~ /\A#{Rack::Utils.escape(key)}=/ }
68
+ header['Set-Cookie'] = cookies.join("\n")
36
69
  end
37
70
 
38
71
  def hash
39
- if @cookies[:split]
40
- begin
41
- JSON.parse(@cookies[:split])
42
- rescue JSON::ParserError
72
+ @hash ||= begin
73
+ if cookies = @cookies[:split.to_s]
74
+ begin
75
+ JSON.parse(cookies)
76
+ rescue JSON::ParserError
77
+ {}
78
+ end
79
+ else
43
80
  {}
44
81
  end
45
- else
46
- {}
47
82
  end
48
83
  end
49
84
 
@@ -51,6 +86,9 @@ module Split
51
86
  Split.configuration.persistence_cookie_length
52
87
  end
53
88
 
89
+ def action_dispatch?
90
+ defined?(Rails) && @response.is_a?(ActionDispatch::Response)
91
+ end
54
92
  end
55
93
  end
56
94
  end