profitably-abingo 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Patrick McKenzie
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,149 @@
1
+ A/Bingo Version 1.0.3
2
+ =====================
3
+
4
+ Rails A/B testing. One minute to install. One line to set up a new A/B test.
5
+ One line to track conversion.
6
+
7
+ For usage notes, see: [http://www.bingocardcreator.com/abingo](http://www.bingocardcreator.com/abingo)
8
+
9
+ Forked from the original distribution for easier integration into rails at [Toptranslation GmbH Übersetzungsagentur](https://www.toptranslation.com/).
10
+
11
+ Installation instructions are below usage examples.
12
+
13
+ Key default features:
14
+
15
+ * Conversions only tracked once per individual.
16
+ * Conversions only tracked if individual saw test.
17
+ * Same individual ALWAYS sees same alternative for same test.
18
+ * Syntax sugar. Specify alternatives as a range, array, hash of alternatives with weights, or just let it default to true or false.
19
+ * A simple z-test of statistical significance, with output so clear anyone in your organization can understand it.
20
+
21
+ Example: View
22
+ -------------
23
+
24
+ <% ab_test("login_button", ["/images/button1.jpg", "/images/button2.jpg"]) do |button_file| %>
25
+ <%= img_tag(button_file, :alt => "Login!") %>
26
+ <% end %>
27
+
28
+ Example: Controller
29
+ -------------------
30
+
31
+ def register_new_user
32
+ #See what level of free points maximizes users' decision to buy replacement points.
33
+ @starter_points = ab_test("new_user_free_points", [100, 200, 300])
34
+ end
35
+
36
+ Example: Controller
37
+ -------------------
38
+
39
+ def registration
40
+ if (ab_test("send_welcome_email"), :conversion => "purchase")
41
+ #send the email, track to see if it later increases conversion to full version
42
+ end
43
+ end
44
+
45
+ Example: Conversion tracking (in a controller!)
46
+ -----------------------------------------------
47
+
48
+ def buy_new_points
49
+ #some business logic
50
+ bingo!("buy_new_points") #Either a conversion named with :conversion or a test name.
51
+ end
52
+
53
+ Example: Conversion tracking (in a view)
54
+ ----------------------------------------
55
+
56
+ Thanks for signing up, dude! <% bingo!("signup_page_redesign") >
57
+
58
+ Example: Statistical Significance Testing
59
+ -----------------------------------------
60
+
61
+ Abingo::Experiment.last.describe_result_in_words
62
+ => "The best alternative you have is: [0], which had 130 conversions from 5000 participants (2.60%).
63
+ The other alternative was [1], which had 1800 conversions from 100000 participants (1.80%).
64
+ This difference is 99.9% likely to be statistically significant, which means you can be extremely
65
+ confident that it is the result of your alternatives actually mattering, rather than being due to
66
+ random chance. However, this doesn't say anything about how much the first alternative is really
67
+ likely to be better by."
68
+
69
+ Installation
70
+ ============
71
+
72
+ Configure the Gem
73
+ -----------------
74
+
75
+ gem 'abingo', :git => "git://github.com/moeffju/abingo.git"
76
+ bundle install
77
+
78
+ Generate the database tables
79
+ ----------------------------
80
+ Creates tables "experiments" and "alternatives". If you use these names already you will need to do some hacking)
81
+
82
+ rails g abingo_migration
83
+ rake db:migrate
84
+
85
+ Configure a Cache
86
+ -----------------
87
+ A/Bingo makes HEAVY use of the cache to reduce load on thedatabase and share potentially long-lived "temporary" data, such as what alternative a given visitor should be shown for a particular test.
88
+
89
+ A/Bingo defaults to using the same cache store as Rails.These instructions are on how to use the memcache-addon in heroku.
90
+
91
+ heroku addons:add memcache:5mb
92
+
93
+ #Gemfile
94
+ group :production do
95
+ gem "memcache-client"
96
+ gem 'memcached-northscale', :require => 'memcached'
97
+ end
98
+
99
+ #config/environments/production.rb
100
+ # Use a different cache store in production
101
+ # config.cache_store = :mem_cache_store
102
+ config.cache_store = :mem_cache_store, Memcached::Rails.new
103
+
104
+ Tell A/Bingo a user's identity
105
+ ------------------------------
106
+ So abingo knows who a users is if they come back to a test. (The same identity will always see the same alternative for the same test.) How you do this is up to you -- I suggest integrating with your login/account infrastructure. The simplest thing that can possibly work is:
107
+
108
+ #Somewhere in application.rb
109
+ before_filter :set_abingo_identity
110
+
111
+ def set_abingo_identity
112
+ #treat all bots as one user to prevent skewing results
113
+ if request.user_agent =~ /\b(Baidu|Gigabot|Googlebot|libwww-perl|lwp-trivial|msnbot|SiteUptime|Slurp|WordPress|ZIBB|ZyBorg)\b/i
114
+ Abingo.identity = "robot"
115
+ elsif current_user
116
+ Abingo.identity = current_user.id
117
+ else
118
+ session[:abingo_identity] ||= rand(10 ** 10)
119
+ Abingo.identity = session[:abingo_identity]
120
+ end
121
+ end
122
+
123
+ Create the Dashboard
124
+ --------------------
125
+ You need to create a controller which includes the methods from the Abingo module, as well as generate the views. You can customise the view if you wish.
126
+ Don't forget to authenticate access to the controller.
127
+
128
+ rails g controller admin/abingo_dashboard
129
+
130
+ #app/controllers/admin/abingo_dashboard_controller.rb
131
+ class Admin::AbingoDashboardController < ApplicationController
132
+ include Abingo::Controller::Dashboard
133
+ end
134
+
135
+ #routes.rb
136
+ namespace :admin do
137
+ get "ab_dashboard" => "abingo_dashboard#index"
138
+ post "ab_end_experiment/:id" => "abingo_dashboard#end_experiment"
139
+ end
140
+
141
+ rails g abingo_views
142
+
143
+ Run your first test!
144
+ ====================
145
+
146
+ Copyright
147
+ =========
148
+
149
+ Copyright (c) 2009-2010 Patrick McKenzie, released under the MIT license
data/Rakefile ADDED
@@ -0,0 +1,51 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "profitably-abingo"
8
+ gem.summary = %Q{A/B Testing for Rails}
9
+ gem.description = %Q{Rails A/B testing. One minute to install. One line to set up a new A/B test. One line to track conversion.}
10
+ gem.email = "admin@profitably.com"
11
+ gem.homepage = "https://github.com/profitably/abingo"
12
+ gem.authors = ["Patrick McKenzie", 'Wildfalcon', 'Matthias Bauer']
13
+ end
14
+ Jeweler::GemcutterTasks.new
15
+ rescue LoadError
16
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
17
+ end
18
+
19
+ require 'rake/testtask'
20
+ Rake::TestTask.new(:test) do |test|
21
+ test.libs << 'lib' << 'test'
22
+ test.pattern = 'test/**/test_*.rb'
23
+ test.verbose = true
24
+ end
25
+
26
+ begin
27
+ require 'rcov/rcovtask'
28
+ Rcov::RcovTask.new do |test|
29
+ test.libs << 'test'
30
+ test.pattern = 'test/**/test_*.rb'
31
+ test.verbose = true
32
+ end
33
+ rescue LoadError
34
+ task :rcov do
35
+ abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
36
+ end
37
+ end
38
+
39
+ task :test => :check_dependencies
40
+
41
+ task :default => :test
42
+
43
+ require 'rake/rdoctask'
44
+ Rake::RDocTask.new do |rdoc|
45
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
46
+
47
+ rdoc.rdoc_dir = 'rdoc'
48
+ rdoc.title = "baby_railtie #{version}"
49
+ rdoc.rdoc_files.include('README*')
50
+ rdoc.rdoc_files.include('lib/**/*.rb')
51
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.2
data/init.rb ADDED
@@ -0,0 +1,5 @@
1
+ require File.dirname(__FILE__) + '/lib/abingo'
2
+
3
+ ActionController::Base.send :include, AbingoSugar
4
+
5
+ ActionView::Base.send :include, AbingoViewHelper
data/install.rb ADDED
@@ -0,0 +1 @@
1
+ # Install hook code here
data/lib/abingo.rb ADDED
@@ -0,0 +1,313 @@
1
+ #This class is outside code's main interface into the ABingo A/B testing framework.
2
+ #Unless you're fiddling with implementation details, it is the only one you need worry about.
3
+
4
+ #Usage of ABingo, including practical hints, is covered at http://www.bingocardcreator.com/abingo
5
+ require 'abingo/railtie'
6
+ require 'abingo_sugar'
7
+ require 'abingo_view_helper'
8
+
9
+ class Abingo
10
+ @@VERSION = "1.0.3"
11
+ @@MAJOR_VERSION = "1.0"
12
+ cattr_reader :VERSION
13
+ cattr_reader :MAJOR_VERSION
14
+
15
+ #Not strictly necessary, but eh, as long as I'm here.
16
+ cattr_accessor :salt
17
+ @@salt = "Not really necessary."
18
+
19
+ @@options ||= {}
20
+ cattr_accessor :options
21
+
22
+ #Defined options:
23
+ # :enable_specification => if true, allow params[test_name] to override the calculated value for a test.
24
+ # :enable_override_in_session => if true, allows session[test_name] to override the calculated value for a test.
25
+ # :expires_in => if not nil, passes expire_in to creation of per-user cache keys. Useful for Redis, to prevent expired sessions
26
+ # from running wild and consuming all of your memory.
27
+ # :count_humans_only => Count only participation and conversions from humans. Humans can be identified by calling Abingo.mark_human!
28
+ # This can be done in e.g. Javascript code, which bots will typically not execute. See FAQ for details.
29
+ # :expires_in_for_bots => if not nil, passes expire_in to creation of per-user cache keys, but only for bots.
30
+ # Only matters if :count_humans_only is on.
31
+
32
+ #ABingo stores whether a particular user has participated in a particular
33
+ #experiment yet, and if so whether they converted, in the cache.
34
+ #
35
+ #It is STRONGLY recommended that you use a MemcacheStore for this.
36
+ #If you'd like to persist this through a system restart or the like, you can
37
+ #look into memcachedb, which speaks the memcached protocol. From the perspective
38
+ #of Rails it is just another MemcachedStore.
39
+ #
40
+ #You can overwrite Abingo's cache instance, if you would like it to not share
41
+ #your generic Rails cache.
42
+ cattr_writer :cache
43
+
44
+ def self.cache
45
+ @@cache || Rails.cache
46
+ end
47
+
48
+ #This method gives a unique identity to a user. It can be absolutely anything
49
+ #you want, as long as it is consistent.
50
+ #
51
+ #We use the identity to determine, deterministically, which alternative a user sees.
52
+ #This means that if you use Abingo.identify_user on someone at login, they will
53
+ #always see the same alternative for a particular test which is past the login
54
+ #screen. For details and usage notes, see the docs.
55
+ def self.identity=(new_identity)
56
+ @@identity = new_identity.to_s
57
+ end
58
+
59
+ def self.identity
60
+ @@identity ||= rand(10 ** 10).to_i.to_s
61
+ end
62
+
63
+ #A simple convenience method for doing an A/B test. Returns true or false.
64
+ #If you pass it a block, it will bind the choice to the variable given to the block.
65
+ def self.flip(test_name)
66
+ if block_given?
67
+ yield(self.test(test_name, [true, false]))
68
+ else
69
+ self.test(test_name, [true, false])
70
+ end
71
+ end
72
+
73
+ #This is the meat of A/Bingo.
74
+ #options accepts
75
+ # :multiple_participation (true or false)
76
+ # :conversion name of conversion to listen for (alias: conversion_name)
77
+ def self.test(test_name, alternatives, options = {})
78
+
79
+ short_circuit = Abingo.cache.read("Abingo::Experiment::short_circuit(#{test_name})".gsub(" ", "_"))
80
+ unless short_circuit.nil?
81
+ return short_circuit #Test has been stopped, pick canonical alternative.
82
+ end
83
+
84
+ unless Abingo::Experiment.exists?(test_name)
85
+ lock_key = "Abingo::lock_for_creation(#{test_name.gsub(" ", "_")})"
86
+ creation_required = true
87
+
88
+ #this prevents (most) repeated creations of experiments in high concurrency environments.
89
+ if Abingo.cache.exist?(lock_key)
90
+ creation_required = false
91
+ while Abingo.cache.exist?(lock_key)
92
+ sleep(0.1)
93
+ end
94
+ creation_required = Abingo::Experiment.exists?(test_name)
95
+ end
96
+
97
+ if creation_required
98
+ Abingo.cache.write(lock_key, 1, :expires_in => 5.seconds)
99
+ conversion_name = options[:conversion] || options[:conversion_name]
100
+ Abingo::Experiment.start_experiment!(test_name, self.parse_alternatives(alternatives), conversion_name)
101
+ Abingo.cache.delete(lock_key)
102
+ end
103
+ end
104
+
105
+ choice = self.find_alternative_for_user(test_name, alternatives)
106
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
107
+
108
+ #Set this user to participate in this experiment, and increment participants count.
109
+ if options[:multiple_participation] || !(participating_tests.include?(test_name))
110
+ unless participating_tests.include?(test_name)
111
+ participating_tests = participating_tests + [test_name]
112
+ expires_in = Abingo.expires_in
113
+ if expires_in
114
+ Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests, {:expires_in => expires_in})
115
+ else
116
+ Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests)
117
+ end
118
+ end
119
+ #If we're only counting known humans, then postpone scoring participation until after we know the user is human.
120
+ if (!@@options[:count_humans_only] || Abingo.is_human?)
121
+ Abingo::Alternative.score_participation(test_name)
122
+ end
123
+ end
124
+
125
+ if block_given?
126
+ yield(choice)
127
+ else
128
+ choice
129
+ end
130
+ end
131
+
132
+
133
+ #Scores conversions for tests.
134
+ #test_name_or_array supports three types of input:
135
+ #
136
+ #A conversion name: scores a conversion for any test the user is participating in which
137
+ # is listening to the specified conversion.
138
+ #
139
+ #A test name: scores a conversion for the named test if the user is participating in it.
140
+ #
141
+ #An array of either of the above: for each element of the array, process as above.
142
+ #
143
+ #nil: score a conversion for every test the user is participating in.
144
+ def Abingo.bingo!(name = nil, options = {})
145
+ if name.kind_of? Array
146
+ name.map do |single_test|
147
+ self.bingo!(single_test, options)
148
+ end
149
+ else
150
+ if name.nil?
151
+ #Score all participating tests
152
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
153
+ participating_tests.each do |participating_test|
154
+ self.bingo!(participating_test, options)
155
+ end
156
+ else #Could be a test name or conversion name.
157
+ conversion_name = name.gsub(" ", "_")
158
+ tests_listening_to_conversion = Abingo.cache.read("Abingo::tests_listening_to_conversion#{conversion_name}")
159
+ if tests_listening_to_conversion
160
+ if tests_listening_to_conversion.size > 1
161
+ tests_listening_to_conversion.map do |individual_test|
162
+ self.score_conversion!(individual_test.to_s)
163
+ end
164
+ elsif tests_listening_to_conversion.size == 1
165
+ test_name_str = tests_listening_to_conversion.first.to_s
166
+ self.score_conversion!(test_name_str)
167
+ end
168
+ else
169
+ #No tests listening for this conversion. Assume it is just a test name.
170
+ test_name_str = name.to_s
171
+ self.score_conversion!(test_name_str)
172
+ end
173
+ end
174
+ end
175
+ end
176
+
177
+ def self.participating_tests(only_current = true)
178
+ identity = Abingo.identity
179
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{identity}") || []
180
+ tests_and_alternatives = participating_tests.inject({}) do |acc, test_name|
181
+ alternatives_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")
182
+ alternatives = Abingo.cache.read(alternatives_key)
183
+ acc[test_name] = Abingo.find_alternative_for_user(test_name, alternatives)
184
+ acc
185
+ end
186
+ if (only_current)
187
+ tests_and_alternatives.reject! do |key, value|
188
+ self.cache.read("Abingo::Experiment::short_circuit(#{key})")
189
+ end
190
+ end
191
+ tests_and_alternatives
192
+ end
193
+
194
+ #Marks that this user is human.
195
+ def self.human!
196
+ Abingo.cache.fetch("Abingo::is_human(#{Abingo.identity})", {:expires_in => Abingo.expires_in(true)}) do
197
+ #Now that we know the user is human, score participation for all their tests. (Further participation will *not* be lazy evaluated.)
198
+
199
+ #Score all tests which have been deferred.
200
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
201
+
202
+ #Refresh cache expiry for this user to match that of known humans.
203
+ if (@@options[:expires_in_for_bots] && !participating_tests.blank?)
204
+ Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests, {:expires_in => Abingo.expires_in(true)})
205
+ end
206
+
207
+ participating_tests.each do |test_name|
208
+ Alternative.score_participation(test_name)
209
+ if conversions = Abingo.cache.read("Abingo::conversions(#{Abingo.identity},#{test_name}")
210
+ conversions.times { Alternative.score_conversion(test_name) }
211
+ end
212
+ end
213
+ true #Marks this user as human in the cache.
214
+ end
215
+ end
216
+
217
+ protected
218
+
219
+ def self.is_human?
220
+ !!Abingo.cache.read("Abingo::is_human(#{Abingo.identity})")
221
+ end
222
+
223
+ #For programmer convenience, we allow you to specify what the alternatives for
224
+ #an experiment are in a few ways. Thus, we need to actually be able to handle
225
+ #all of them. We fire this parser very infrequently (once per test, typically)
226
+ #so it can be as complicated as we want.
227
+ # Integer => a number 1 through N
228
+ # Range => a number within the range
229
+ # Array => an element of the array.
230
+ # Hash => assumes a hash of something to int. We pick one of the
231
+ # somethings, weighted accorded to the ints provided. e.g.
232
+ # {:a => 2, :b => 3} produces :a 40% of the time, :b 60%.
233
+ #
234
+ #Alternatives are always represented internally as an array.
235
+ def self.parse_alternatives(alternatives)
236
+ if alternatives.kind_of? Array
237
+ return alternatives
238
+ elsif alternatives.kind_of? Integer
239
+ return (1..alternatives).to_a
240
+ elsif alternatives.kind_of? Range
241
+ return alternatives.to_a
242
+ elsif alternatives.kind_of? Hash
243
+ alternatives_array = []
244
+ alternatives.each do |key, value|
245
+ if value.kind_of? Integer
246
+ alternatives_array += [key] * value
247
+ else
248
+ raise "You gave a hash with #{key} => #{value} as an element. The value must be an integral weight."
249
+ end
250
+ end
251
+ return alternatives_array
252
+ else
253
+ raise "I don't know how to turn [#{alternatives}] into an array of alternatives."
254
+ end
255
+ end
256
+
257
+ def self.retrieve_alternatives(test_name, alternatives)
258
+ cache_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")
259
+ alternative_array = self.cache.fetch(cache_key) do
260
+ self.parse_alternatives(alternatives)
261
+ end
262
+ alternative_array
263
+ end
264
+
265
+ def self.find_alternative_for_user(test_name, alternatives)
266
+ alternatives_array = retrieve_alternatives(test_name, alternatives)
267
+ alternatives_array[self.modulo_choice(test_name, alternatives_array.size)]
268
+ end
269
+
270
+ #Quickly determines what alternative to show a given user. Given a test name
271
+ #and their identity, we hash them together (which, for MD5, provably introduces
272
+ #enough entropy that we don't care) otherwise
273
+ def self.modulo_choice(test_name, choices_count)
274
+ Digest::MD5.hexdigest(Abingo.salt.to_s + test_name + self.identity.to_s).to_i(16) % choices_count
275
+ end
276
+
277
+ def self.score_conversion!(test_name)
278
+ test_name.gsub!(" ", "_")
279
+ participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
280
+ if options[:assume_participation] || participating_tests.include?(test_name)
281
+ cache_key = "Abingo::conversions(#{Abingo.identity},#{test_name}"
282
+ if options[:multiple_conversions] || !Abingo.cache.read(cache_key)
283
+ if !options[:count_humans_only] || Abingo.is_human?
284
+ Abingo::Alternative.score_conversion(test_name)
285
+ end
286
+
287
+ if Abingo.cache.exist?(cache_key)
288
+ Abingo.cache.increment(cache_key)
289
+ else
290
+ Abingo.cache.write(cache_key, 1)
291
+ end
292
+ end
293
+ end
294
+ end
295
+
296
+ def self.expires_in(known_human = false)
297
+ expires_in = nil
298
+ if (@@options[:expires_in])
299
+ expires_in = @@options[:expires_in]
300
+ end
301
+ if (@@options[:count_humans_only] && @@options[:expires_in_for_bots] && !(known_human || Abingo.is_human?))
302
+ expires_in = @@options[:expires_in_for_bots]
303
+ end
304
+ expires_in
305
+ end
306
+
307
+ end
308
+
309
+ require 'abingo/statistics'
310
+ require 'abingo/conversion_rate'
311
+ require 'abingo/alternative'
312
+ require 'abingo/experiment'
313
+ require 'abingo/controller/dashboard'