ab-split 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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