├── Rakefile ├── lib ├── simple_abs │ └── version.rb ├── generators │ └── simple_abs │ │ ├── templates │ │ └── migration.rb │ │ └── simple_abs_generator.rb └── simple_abs.rb ├── Gemfile ├── .gitignore ├── simple_abs.gemspec ├── LICENSE.txt └── README.md /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /lib/simple_abs/version.rb: -------------------------------------------------------------------------------- 1 | module SimpleAbs 2 | VERSION = "0.0.2" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in simple_abs.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /lib/generators/simple_abs/templates/migration.rb: -------------------------------------------------------------------------------- 1 | class CreateAlternativesTable < ActiveRecord::Migration 2 | def self.up 3 | create_table :alternatives, :force => true do |t| 4 | t.string "which" 5 | t.integer "participants", :default => 0 6 | t.integer "conversions", :default => 0 7 | t.text "experiment" 8 | 9 | t.timestamps 10 | end 11 | 12 | add_index :alternatives, :which 13 | 14 | end 15 | 16 | def self.down 17 | drop_table :alternatives 18 | end 19 | end -------------------------------------------------------------------------------- /lib/generators/simple_abs/simple_abs_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/migration' 3 | 4 | class SimpleAbsGenerator < Rails::Generators::Base 5 | include Rails::Generators::Migration 6 | 7 | def self.source_root 8 | @source_root ||= File.join(File.dirname(__FILE__), 'templates') 9 | end 10 | 11 | def self.next_migration_number(dirname) 12 | if ActiveRecord::Base.timestamped_migrations 13 | Time.new.utc.strftime("%Y%m%d%H%M%S") 14 | else 15 | "%.3d" % (current_migration_number(dirname) + 1) 16 | end 17 | end 18 | 19 | def create_migration_file 20 | migration_template 'migration.rb', 'db/migrate/create_alternatives_table.rb' 21 | end 22 | 23 | end -------------------------------------------------------------------------------- /simple_abs.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'simple_abs/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "simple_abs" 8 | spec.version = SimpleAbs::VERSION 9 | spec.authors = ["nate"] 10 | spec.email = ["nate@cityposh.com"] 11 | spec.description = %q{Really simple way to do AB tests in Rails} 12 | spec.summary = %q{Really simple way to do AB tests in Rails} 13 | spec.homepage = "" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler", "~> 1.3" 22 | spec.add_development_dependency "rake" 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 nate 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/simple_abs.rb: -------------------------------------------------------------------------------- 1 | require "simple_abs/version" 2 | 3 | module SimpleAbs 4 | 5 | def is_bot? 6 | agent = request.env["HTTP_USER_AGENT"] 7 | matches = nil 8 | matches = agent.match(/(facebook|postrank|voyager|twitterbot|googlebot|slurp|butterfly|pycurl|tweetmemebot|metauri|evrinid|reddit|digg)/mi) if agent 9 | if (agent.nil? or matches) 10 | return true 11 | else 12 | return false 13 | end 14 | end 15 | 16 | def ab_test(name, tests) 17 | 18 | if is_bot? 19 | test_value = tests[rand(tests.size)] 20 | return test_value 21 | end 22 | 23 | if params[:test_value] 24 | return params[:test_value] 25 | end 26 | 27 | test_value = cookies[name] 28 | 29 | if test_value.blank? || !tests.include?(test_value) 30 | test_value = tests[rand(tests.size)] 31 | cookies.permanent[name] = test_value 32 | 33 | find_or_create_by_experiment_and_which_method(name, test_value).increment!(:participants) 34 | end 35 | 36 | return test_value 37 | end 38 | 39 | def converted!(name) 40 | 41 | if !is_bot? 42 | test_value = cookies[name] 43 | if test_value && cookies[name.to_s + "_converted"].blank? 44 | find_or_create_by_experiment_and_which_method(name, test_value).increment!(:conversions) 45 | cookies.permanent[name.to_s + "_converted"] = true 46 | end 47 | end 48 | end 49 | 50 | def find_or_create_by_experiment_and_which_method(experiment, which) 51 | alternative = Alternative.where(experiment: experiment, which: which).first 52 | 53 | if alternative.nil? 54 | alternative = Alternative.new 55 | alternative.experiment = experiment 56 | alternative.which = which 57 | alternative.save 58 | end 59 | 60 | return alternative 61 | 62 | end 63 | 64 | 65 | class Railtie < Rails::Railtie 66 | initializer "simple_abs.initialize" do 67 | ActionView::Base.send :include, SimpleAbs 68 | ActionController::Base.send :include, SimpleAbs 69 | end 70 | end 71 | 72 | class Alternative < ActiveRecord::Base 73 | 74 | def conversion 75 | if participants.present? && conversions.present? 76 | (participants.to_f/conversions.to_f).round(2) 77 | end 78 | end 79 | 80 | # 90 percent error 81 | def error 82 | 83 | end 84 | end 85 | 86 | 87 | end 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rails AB Testing - Simple Abs 2 | 3 | I recently turned on paid subscriptions to [Draft, the writing software](https://draftin.com) I've created. And I wanted a really simple way to test a few alternatives of the payment page in Rails without needing to use a separate service. 4 | 5 | But the solutions out there get too complicated. Even the "simplest" ones require things like Redis. They do that because somewhere the AB testing library needs to remember what variation of a test a user has already seen, so it knows what to show them on subsequent visits. 6 | 7 | But I don't want to install Redis just to have my AB tests be performant. That's still an extra network call to Redis for this simple operation, not to mention the added complexity of adding Redis to my software stack when I don't need it right now. 8 | 9 | Why can't the AB testing library just store what variation a user has already seen in the user's cookies? 10 | 11 | That's what SimpleAbs does. 12 | 13 | ## Installation 14 | 15 | Add this line to your application's Gemfile: 16 | 17 | gem 'simple_abs' 18 | 19 | And then execute: 20 | 21 | $ bundle 22 | 23 | Or install it yourself as: 24 | 25 | $ gem install simple_abs 26 | 27 | Create the migration to install the Alternatives table: 28 | 29 | rails g simple_abs 30 | 31 | Run the migrations: 32 | 33 | rake db:migrate 34 | 35 | 36 | ## Usage 37 | 38 | Use simple_abs to figure out an "alternative" to show your users. You can get ahold of an alternative from either a Rails View or a Controller. 39 | 40 | ```ruby 41 | ab_test(experiment name, [variation name 1, variation name 2, etc.]) 42 | ``` 43 | 44 | Here's an example where I might have three different versions of a buy page. simple_abs will randomly pick which version this user should see. 45 | 46 | ```ruby 47 | def buy 48 | @buy_page = ab_test("buy_page", ["short", "medium", "long"]) 49 | 50 | render action: 'buy' 51 | end 52 | ``` 53 | 54 | Then, in your template, you can use an if statement to show them that version of the @buy_page: 55 | 56 | ```erb 57 | <% if @buy_page == "long" %> 58 | Lots of extra information 59 | <% end %> 60 | ``` 61 | 62 | If they've already seen one of the alternatives, simple_abs figures that out from the permanent cookies of the user. In other words, if on the first visit, this method: 63 | 64 | ```ruby 65 | ab_test("buy_page", ["short", "long"]) 66 | ``` 67 | 68 | Returns "short". On subsequent visits to the page from this same user, you will also get the value "short" from the ab_test method. 69 | 70 | Once your user converts you can call "converted!(experiment name)" from a View or Controller: 71 | 72 | ```ruby 73 | converted!("buy_page") 74 | ``` 75 | 76 | You can also force someone to a specific alternative of your page by using the query paramater "test_value" in your url: 77 | 78 | http://draftin.com/buy_things?test_value=long 79 | 80 | When you get some data you can look at it from a Rails console. 81 | 82 | ```ruby 83 | irb(main):011:0> pp SimpleAbs::Alternative.where("experiment = 'buy_page'").all 84 | SimpleAbs::Alternative Load (55.1ms) SELECT "alternatives".* FROM "alternatives" WHERE (experiment = 'buy_page') 85 | [#, 86 | #] 87 | ``` 88 | 89 | And then you can plug the participants and conversions into a calculator like that found here: 90 | 91 | [http://tools.seobook.com/ppc-tools/calculators/split-test.html](http://tools.seobook.com/ppc-tools/calculators/split-test.html) 92 | 93 | Trials = simple_abs' participants 94 | Successes = simple_abs' conversions 95 | 96 | 97 | 98 | Feedback 99 | -------- 100 | [Source code available on Github](https://github.com/n8/simple_abs). Feedback and pull requests are greatly appreciated. 101 | 102 | Credit 103 | -------- 104 | This library is very much inspired by the other AB testing libraries out there. Not the least of which is A/Bingo from [Patrick McKenzie](https://twitter.com/patio11). 105 | 106 | 107 | 108 |
109 | 110 | **P.S. [It would be awesome to meet you on Twitter](http://twitter.com/natekontny).** 111 |
112 | --------------------------------------------------------------------------------