ab-split 1.0.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 (93) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +30 -0
  3. data/.csslintrc +2 -0
  4. data/.eslintignore +1 -0
  5. data/.eslintrc +213 -0
  6. data/.github/FUNDING.yml +1 -0
  7. data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
  8. data/.rspec +1 -0
  9. data/.rubocop.yml +7 -0
  10. data/.rubocop_todo.yml +679 -0
  11. data/.travis.yml +60 -0
  12. data/Appraisals +19 -0
  13. data/CHANGELOG.md +696 -0
  14. data/CODE_OF_CONDUCT.md +74 -0
  15. data/CONTRIBUTING.md +62 -0
  16. data/Gemfile +7 -0
  17. data/LICENSE +22 -0
  18. data/README.md +955 -0
  19. data/Rakefile +9 -0
  20. data/ab-split.gemspec +44 -0
  21. data/gemfiles/4.2.gemfile +9 -0
  22. data/gemfiles/5.0.gemfile +9 -0
  23. data/gemfiles/5.1.gemfile +9 -0
  24. data/gemfiles/5.2.gemfile +9 -0
  25. data/gemfiles/6.0.gemfile +9 -0
  26. data/lib/split.rb +76 -0
  27. data/lib/split/algorithms/block_randomization.rb +23 -0
  28. data/lib/split/algorithms/weighted_sample.rb +18 -0
  29. data/lib/split/algorithms/whiplash.rb +38 -0
  30. data/lib/split/alternative.rb +191 -0
  31. data/lib/split/combined_experiments_helper.rb +37 -0
  32. data/lib/split/configuration.rb +255 -0
  33. data/lib/split/dashboard.rb +74 -0
  34. data/lib/split/dashboard/helpers.rb +45 -0
  35. data/lib/split/dashboard/pagination_helpers.rb +86 -0
  36. data/lib/split/dashboard/paginator.rb +16 -0
  37. data/lib/split/dashboard/public/dashboard-filtering.js +43 -0
  38. data/lib/split/dashboard/public/dashboard.js +24 -0
  39. data/lib/split/dashboard/public/jquery-1.11.1.min.js +4 -0
  40. data/lib/split/dashboard/public/reset.css +48 -0
  41. data/lib/split/dashboard/public/style.css +328 -0
  42. data/lib/split/dashboard/views/_controls.erb +18 -0
  43. data/lib/split/dashboard/views/_experiment.erb +155 -0
  44. data/lib/split/dashboard/views/_experiment_with_goal_header.erb +8 -0
  45. data/lib/split/dashboard/views/index.erb +26 -0
  46. data/lib/split/dashboard/views/layout.erb +27 -0
  47. data/lib/split/encapsulated_helper.rb +42 -0
  48. data/lib/split/engine.rb +15 -0
  49. data/lib/split/exceptions.rb +6 -0
  50. data/lib/split/experiment.rb +486 -0
  51. data/lib/split/experiment_catalog.rb +51 -0
  52. data/lib/split/extensions/string.rb +16 -0
  53. data/lib/split/goals_collection.rb +45 -0
  54. data/lib/split/helper.rb +165 -0
  55. data/lib/split/metric.rb +101 -0
  56. data/lib/split/persistence.rb +28 -0
  57. data/lib/split/persistence/cookie_adapter.rb +94 -0
  58. data/lib/split/persistence/dual_adapter.rb +85 -0
  59. data/lib/split/persistence/redis_adapter.rb +57 -0
  60. data/lib/split/persistence/session_adapter.rb +29 -0
  61. data/lib/split/redis_interface.rb +50 -0
  62. data/lib/split/trial.rb +117 -0
  63. data/lib/split/user.rb +69 -0
  64. data/lib/split/version.rb +7 -0
  65. data/lib/split/zscore.rb +57 -0
  66. data/spec/algorithms/block_randomization_spec.rb +32 -0
  67. data/spec/algorithms/weighted_sample_spec.rb +19 -0
  68. data/spec/algorithms/whiplash_spec.rb +24 -0
  69. data/spec/alternative_spec.rb +320 -0
  70. data/spec/combined_experiments_helper_spec.rb +57 -0
  71. data/spec/configuration_spec.rb +258 -0
  72. data/spec/dashboard/pagination_helpers_spec.rb +200 -0
  73. data/spec/dashboard/paginator_spec.rb +37 -0
  74. data/spec/dashboard_helpers_spec.rb +42 -0
  75. data/spec/dashboard_spec.rb +210 -0
  76. data/spec/encapsulated_helper_spec.rb +52 -0
  77. data/spec/experiment_catalog_spec.rb +53 -0
  78. data/spec/experiment_spec.rb +533 -0
  79. data/spec/goals_collection_spec.rb +80 -0
  80. data/spec/helper_spec.rb +1111 -0
  81. data/spec/metric_spec.rb +31 -0
  82. data/spec/persistence/cookie_adapter_spec.rb +106 -0
  83. data/spec/persistence/dual_adapter_spec.rb +194 -0
  84. data/spec/persistence/redis_adapter_spec.rb +90 -0
  85. data/spec/persistence/session_adapter_spec.rb +32 -0
  86. data/spec/persistence_spec.rb +34 -0
  87. data/spec/redis_interface_spec.rb +111 -0
  88. data/spec/spec_helper.rb +52 -0
  89. data/spec/split_spec.rb +43 -0
  90. data/spec/support/cookies_mock.rb +20 -0
  91. data/spec/trial_spec.rb +299 -0
  92. data/spec/user_spec.rb +87 -0
  93. metadata +322 -0
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ class ExperimentCatalog
4
+ # Return all experiments
5
+ def self.all
6
+ # Call compact to prevent nil experiments from being returned -- seems to happen during gem upgrades
7
+ Split.redis.smembers(:experiments).map {|e| find(e)}.compact
8
+ end
9
+
10
+ # Return experiments without a winner (considered "active") first
11
+ def self.all_active_first
12
+ all.partition{|e| not e.winner}.map{|es| es.sort_by(&:name)}.flatten
13
+ end
14
+
15
+ def self.find(name)
16
+ return unless Split.redis.exists(name)
17
+ Experiment.new(name).tap { |exp| exp.load_from_redis }
18
+ end
19
+
20
+ def self.find_or_initialize(metric_descriptor, control = nil, *alternatives)
21
+ # Check if array is passed to ab_test
22
+ # e.g. ab_test('name', ['Alt 1', 'Alt 2', 'Alt 3'])
23
+ if control.is_a? Array and alternatives.length.zero?
24
+ control, alternatives = control.first, control[1..-1]
25
+ end
26
+
27
+ experiment_name_with_version, goals = normalize_experiment(metric_descriptor)
28
+ experiment_name = experiment_name_with_version.to_s.split(':')[0]
29
+ Split::Experiment.new(experiment_name,
30
+ :alternatives => [control].compact + alternatives, :goals => goals)
31
+ end
32
+
33
+ def self.find_or_create(metric_descriptor, control = nil, *alternatives)
34
+ experiment = find_or_initialize(metric_descriptor, control, *alternatives)
35
+ experiment.save
36
+ end
37
+
38
+ def self.normalize_experiment(metric_descriptor)
39
+ if Hash === metric_descriptor
40
+ experiment_name = metric_descriptor.keys.first
41
+ goals = Array(metric_descriptor.values.first)
42
+ else
43
+ experiment_name = metric_descriptor
44
+ goals = []
45
+ end
46
+ return experiment_name, goals
47
+ end
48
+ private_class_method :normalize_experiment
49
+
50
+ end
51
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ class String
3
+ # Constatntize is often provided by ActiveSupport, but ActiveSupport is not a dependency of Split.
4
+ unless method_defined?(:constantize)
5
+ def constantize
6
+ names = self.split('::')
7
+ names.shift if names.empty? || names.first.empty?
8
+
9
+ constant = Object
10
+ names.each do |name|
11
+ constant = constant.const_defined?(name) ? constant.const_get(name) : constant.const_missing(name)
12
+ end
13
+ constant
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ class GoalsCollection
4
+
5
+ def initialize(experiment_name, goals=nil)
6
+ @experiment_name = experiment_name
7
+ @goals = goals
8
+ end
9
+
10
+ def load_from_redis
11
+ Split.redis.lrange(goals_key, 0, -1)
12
+ end
13
+
14
+ def load_from_configuration
15
+ goals = Split.configuration.experiment_for(@experiment_name)[:goals]
16
+
17
+ if goals.nil?
18
+ goals = []
19
+ else
20
+ goals.flatten
21
+ end
22
+ end
23
+
24
+ def save
25
+ return false if @goals.nil?
26
+ RedisInterface.new.persist_list(goals_key, @goals)
27
+ end
28
+
29
+ def validate!
30
+ unless @goals.nil? || @goals.kind_of?(Array)
31
+ raise ArgumentError, 'Goals must be an array'
32
+ end
33
+ end
34
+
35
+ def delete
36
+ Split.redis.del(goals_key)
37
+ end
38
+
39
+ private
40
+
41
+ def goals_key
42
+ "#{@experiment_name}:goals"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ module Helper
4
+ OVERRIDE_PARAM_NAME = "ab_test"
5
+
6
+ module_function
7
+
8
+ def ab_test(metric_descriptor, control = nil, *alternatives)
9
+ begin
10
+ experiment = ExperimentCatalog.find_or_initialize(metric_descriptor, control, *alternatives)
11
+ alternative = if Split.configuration.enabled && !exclude_visitor?
12
+ experiment.save
13
+ raise(Split::InvalidExperimentsFormatError) unless (Split.configuration.experiments || {}).fetch(experiment.name.to_sym, {})[:combined_experiments].nil?
14
+ trial = Trial.new(:user => ab_user, :experiment => experiment,
15
+ :override => override_alternative(experiment.name), :exclude => exclude_visitor?,
16
+ :disabled => split_generically_disabled?)
17
+ alt = trial.choose!(self)
18
+ alt ? alt.name : nil
19
+ else
20
+ control_variable(experiment.control)
21
+ end
22
+ rescue Errno::ECONNREFUSED, Redis::BaseError, SocketError => e
23
+ raise(e) unless Split.configuration.db_failover
24
+ Split.configuration.db_failover_on_db_error.call(e)
25
+
26
+ if Split.configuration.db_failover_allow_parameter_override
27
+ alternative = override_alternative(experiment.name) if override_present?(experiment.name)
28
+ alternative = control_variable(experiment.control) if split_generically_disabled?
29
+ end
30
+ ensure
31
+ alternative ||= control_variable(experiment.control)
32
+ end
33
+
34
+ if block_given?
35
+ metadata = trial ? trial.metadata : {}
36
+ yield(alternative, metadata)
37
+ else
38
+ alternative
39
+ end
40
+ end
41
+
42
+ def reset!(experiment)
43
+ ab_user.delete(experiment.key)
44
+ end
45
+
46
+ def finish_experiment(experiment, options = {:reset => true})
47
+ return false if active_experiments[experiment.name].nil?
48
+ return true if experiment.has_winner?
49
+ should_reset = experiment.resettable? && options[:reset]
50
+ if ab_user[experiment.finished_key] && !should_reset
51
+ return true
52
+ else
53
+ 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)
57
+
58
+ if should_reset
59
+ reset!(experiment)
60
+ else
61
+ ab_user[experiment.finished_key] = true
62
+ end
63
+ end
64
+ end
65
+
66
+ def ab_finished(metric_descriptor, options = {:reset => true})
67
+ return if exclude_visitor? || Split.configuration.disabled?
68
+ metric_descriptor, goals = normalize_metric(metric_descriptor)
69
+ experiments = Metric.possible_experiments(metric_descriptor)
70
+
71
+ if experiments.any?
72
+ experiments.each do |experiment|
73
+ finish_experiment(experiment, options.merge(:goals => goals))
74
+ end
75
+ end
76
+ rescue => e
77
+ raise unless Split.configuration.db_failover
78
+ Split.configuration.db_failover_on_db_error.call(e)
79
+ end
80
+
81
+ def ab_record_extra_info(metric_descriptor, key, value = 1)
82
+ return if exclude_visitor? || Split.configuration.disabled?
83
+ metric_descriptor, _ = normalize_metric(metric_descriptor)
84
+ experiments = Metric.possible_experiments(metric_descriptor)
85
+
86
+ if experiments.any?
87
+ experiments.each do |experiment|
88
+ alternative_name = ab_user[experiment.key]
89
+
90
+ if alternative_name
91
+ alternative = experiment.alternatives.find{|alt| alt.name == alternative_name}
92
+ alternative.record_extra_info(key, value) if alternative
93
+ end
94
+ end
95
+ end
96
+ rescue => e
97
+ raise unless Split.configuration.db_failover
98
+ Split.configuration.db_failover_on_db_error.call(e)
99
+ end
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
+
109
+ def override_present?(experiment_name)
110
+ override_alternative(experiment_name)
111
+ end
112
+
113
+ def override_alternative(experiment_name)
114
+ defined?(params) && params[OVERRIDE_PARAM_NAME] && params[OVERRIDE_PARAM_NAME][experiment_name]
115
+ end
116
+
117
+ def split_generically_disabled?
118
+ defined?(params) && params['SPLIT_DISABLE']
119
+ end
120
+
121
+ def ab_user
122
+ @ab_user ||= User.new(self)
123
+ end
124
+
125
+ def exclude_visitor?
126
+ defined?(request) && (instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?)
127
+ end
128
+
129
+ def is_robot?
130
+ defined?(request) && request.user_agent =~ Split.configuration.robot_regex
131
+ end
132
+
133
+ def is_preview?
134
+ defined?(request) && defined?(request.headers) && request.headers['x-purpose'] == 'preview'
135
+ end
136
+
137
+ def is_ignored_ip_address?
138
+ return false if Split.configuration.ignore_ip_addresses.empty?
139
+
140
+ Split.configuration.ignore_ip_addresses.each do |ip|
141
+ return true if defined?(request) && (request.ip == ip || (ip.class == Regexp && request.ip =~ ip))
142
+ end
143
+ false
144
+ end
145
+
146
+ def active_experiments
147
+ ab_user.active_experiments
148
+ end
149
+
150
+ def normalize_metric(metric_descriptor)
151
+ if Hash === metric_descriptor
152
+ experiment_name = metric_descriptor.keys.first
153
+ goals = Array(metric_descriptor.values.first)
154
+ else
155
+ experiment_name = metric_descriptor
156
+ goals = []
157
+ end
158
+ return experiment_name, goals
159
+ end
160
+
161
+ def control_variable(control)
162
+ Hash === control ? control.keys.first.to_s : control.to_s
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ class Metric
4
+ attr_accessor :name
5
+ attr_accessor :experiments
6
+
7
+ def initialize(attrs = {})
8
+ attrs.each do |key,value|
9
+ if self.respond_to?("#{key}=")
10
+ self.send("#{key}=", value)
11
+ end
12
+ end
13
+ end
14
+
15
+ def self.load_from_redis(name)
16
+ metric = Split.redis.hget(:metrics, name)
17
+ if metric
18
+ experiment_names = metric.split(',')
19
+
20
+ experiments = experiment_names.collect do |experiment_name|
21
+ Split::ExperimentCatalog.find(experiment_name)
22
+ end
23
+
24
+ Split::Metric.new(:name => name, :experiments => experiments)
25
+ else
26
+ nil
27
+ end
28
+ end
29
+
30
+ def self.load_from_configuration(name)
31
+ metrics = Split.configuration.metrics
32
+ if metrics && metrics[name]
33
+ Split::Metric.new(:experiments => metrics[name], :name => name)
34
+ else
35
+ nil
36
+ end
37
+ end
38
+
39
+ def self.find(name)
40
+ name = name.intern if name.is_a?(String)
41
+ metric = load_from_configuration(name)
42
+ metric = load_from_redis(name) if metric.nil?
43
+ metric
44
+ end
45
+
46
+ def self.find_or_create(attrs)
47
+ metric = find(attrs[:name])
48
+ unless metric
49
+ metric = new(attrs)
50
+ metric.save
51
+ end
52
+ metric
53
+ end
54
+
55
+ def self.all
56
+ redis_metrics = Split.redis.hgetall(:metrics).collect do |key, value|
57
+ find(key)
58
+ end
59
+ configuration_metrics = Split.configuration.metrics.collect do |key, value|
60
+ new(name: key, experiments: value)
61
+ end
62
+ redis_metrics | configuration_metrics
63
+ end
64
+
65
+ def self.possible_experiments(metric_name)
66
+ experiments = []
67
+ metric = Split::Metric.find(metric_name)
68
+ if metric
69
+ experiments << metric.experiments
70
+ end
71
+ experiment = Split::ExperimentCatalog.find(metric_name)
72
+ if experiment
73
+ experiments << experiment
74
+ end
75
+ experiments.flatten
76
+ end
77
+
78
+ def save
79
+ Split.redis.hset(:metrics, name, experiments.map(&:name).join(','))
80
+ end
81
+
82
+ def complete!
83
+ experiments.each do |experiment|
84
+ experiment.complete!
85
+ end
86
+ end
87
+
88
+ def self.normalize_metric(label)
89
+ if Hash === label
90
+ metric_name = label.keys.first
91
+ goals = label.values.first
92
+ else
93
+ metric_name = label
94
+ goals = []
95
+ end
96
+ return metric_name, goals
97
+ end
98
+ private_class_method :normalize_metric
99
+
100
+ end
101
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Split
4
+ module Persistence
5
+ require 'split/persistence/cookie_adapter'
6
+ require 'split/persistence/dual_adapter'
7
+ require 'split/persistence/redis_adapter'
8
+ require 'split/persistence/session_adapter'
9
+
10
+ ADAPTERS = {
11
+ :cookie => Split::Persistence::CookieAdapter,
12
+ :session => Split::Persistence::SessionAdapter
13
+ }.freeze
14
+
15
+ def self.adapter
16
+ if persistence_config.is_a?(Symbol)
17
+ ADAPTERS.fetch(persistence_config) { raise Split::InvalidPersistenceAdapterError }
18
+ else
19
+ persistence_config
20
+ end
21
+ end
22
+
23
+ def self.persistence_config
24
+ Split.configuration.persistence
25
+ end
26
+ private_class_method :persistence_config
27
+ end
28
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Split
5
+ module Persistence
6
+ class CookieAdapter
7
+
8
+ def initialize(context)
9
+ @context = context
10
+ @request, @response = context.request, context.response
11
+ @cookies = @request.cookies
12
+ @expires = Time.now + cookie_length_config
13
+ end
14
+
15
+ def [](key)
16
+ hash[key.to_s]
17
+ end
18
+
19
+ def []=(key, value)
20
+ set_cookie(hash.merge!(key.to_s => value))
21
+ end
22
+
23
+ def delete(key)
24
+ set_cookie(hash.tap { |h| h.delete(key.to_s) })
25
+ end
26
+
27
+ def keys
28
+ hash.keys
29
+ end
30
+
31
+ private
32
+
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")
69
+ end
70
+
71
+ def hash
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
80
+ {}
81
+ end
82
+ end
83
+ end
84
+
85
+ def cookie_length_config
86
+ Split.configuration.persistence_cookie_length
87
+ end
88
+
89
+ def action_dispatch?
90
+ defined?(Rails) && @response.is_a?(ActionDispatch::Response)
91
+ end
92
+ end
93
+ end
94
+ end