profitably-abingo 0.1.2

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.
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'