Skip to content

Commit

Permalink
Initial commit of working plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
jhubert committed Jan 23, 2011
0 parents commit 3719af3
Show file tree
Hide file tree
Showing 12 changed files with 305 additions and 0 deletions.
20 changes: 20 additions & 0 deletions MIT-LICENSE
@@ -0,0 +1,20 @@
Copyright (c) 2011 [name of plugin creator]

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
12 changes: 12 additions & 0 deletions README
@@ -0,0 +1,12 @@
SplitTester
===========

This is a plugin that I wrote for doing split testing in a rails application with Google Analytics as your dashboard. The idea is to create an easy to use bucket testing system that wouldn't require another dashboard just for looking at the data. The logic and system are a simplified version of bucket testing based on what I've learned while bucket testing experiments on Yahoo! Search, one of the highest traffic pages on the internet.

With this system, you don't need to muddy up your views, controllers or language files. The test elements are kept separate and can be turned on and off via percentage allocations of your traffic. It is a cookie based system, so users will have a consistent experience even if they end their session and return later. You can also run as many tests at the same time as you would like, only limited by the amount of traffic you have.

I have also built in support for action caching so that you can keep your application fast and awesome.

The code quality isn't as high as I would like and I have taken some shortcuts. I would love any help or input. :)

A new test is a made up of a locale.yml file and or a collection of new views. The local file can override any translations that are in use and the views are direct replacements for views in the core app (BASELINE).
23 changes: 23 additions & 0 deletions Rakefile
@@ -0,0 +1,23 @@
require 'rake'
require 'rake/testtask'
require 'rake/rdoctask'

desc 'Default: run unit tests.'
task :default => :test

desc 'Test the split_tester plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.libs << 'test'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end

desc 'Generate documentation for the split_tester plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'SplitTester'
rdoc.options << '--line-numbers' << '--inline-source'
rdoc.rdoc_files.include('README')
rdoc.rdoc_files.include('lib/**/*.rb')
end
9 changes: 9 additions & 0 deletions files/split_tests.yml
@@ -0,0 +1,9 @@
# Size represents the percent of traffic you want for each test
# The total of all sizes should equal 100% of traffic.
# It's easiest to think of it as being based out of 10 or 100.
BASELINE:
description: The baseline. This is a version running the default code.
size: 10
FirstTest:
description: This is a sample test. Replace this with your actual test.
size: 0
6 changes: 6 additions & 0 deletions init.rb
@@ -0,0 +1,6 @@
# Include hook code here
require 'action_controller'
require 'split_tester'
#require 'split_tester/class_methods'

#SplitTester::Base.init
7 changes: 7 additions & 0 deletions install.rb
@@ -0,0 +1,7 @@
# Install hook code here
require 'fileutils'

#Copy the Javascript files
FileUtils.copy(File.dirname(__FILE__) + '/files/split_tests.yml', File.dirname(__FILE__) + '/../../../config/')

FileUtils.mkdir_p(File.dirname(__FILE__) + '/../../../test/split/')
129 changes: 129 additions & 0 deletions lib/split_tester.rb
@@ -0,0 +1,129 @@
require 'action_controller'

module ActionController #:nodoc:
class Base

SPLIT_TESTS = YAML.load_file("#{RAILS_ROOT}/config/split_tests.yml")

class << self
def custom_view_path(name)
name == "views" ? "app/views" : "test/split/#{name}/views"
end

def self.random_test_key
split_test_map.sample
end
end

before_filter :setup_split_testing

# preprocess some pathsets on boot
# doing pathset generation during a request is very costly
@@preprocessed_pathsets = begin
SPLIT_TESTS.keys.reject { |k| k == 'BASELINE' }.inject({}) do |pathsets, slug|
path = ActionController::Base.custom_view_path(slug)
pathsets[path] = ActionView::Base.process_view_paths(path).first
pathsets
end
end

@@split_test_map = begin
tm = {} # test map
SPLIT_TESTS.each { |k, v| tm[k] = v['size'].to_i }
tm.keys.zip(tm.values).collect { |v,d| (0...d).collect { v }}.flatten
end

cattr_accessor :preprocessed_pathsets, :split_test_map

# If a split_test_key other than BASELINE exists, add the proper
# view path to the load paths used by ActionView
def setup_split_testing
return unless is_split_test?
split_test_path = preprocessed_pathsets[ActionController::Base.custom_view_path(@split_test_key)]
prepend_view_path(split_test_path) if split_test_path
end

# Get the existing split_test_key from the session or the cookie.
# If there isn't one, or if the one isn't a running test anymore
# assign the user a new key and store it.
# Don't assign a key if it is a crawler. (This doesn't feel right)
def get_split_test_key
return params[:force_test_key] if params[:force_test_key] # just for testing
return session[:split_test_key] if session[:split_test_key] && SPLIT_TESTS.has_key?(session[:split_test_key])
return session[:split_test_key] = cookies[:split_test_key] if cookies[:split_test_key] && SPLIT_TESTS.has_key?(cookies[:split_test_key])
if (request.user_agent =~ /\b(Baidu|Gigabot|Googlebot|libwww-perl|lwp-trivial|msnbot|SiteUptime|Slurp|WordPress|ZIBB|ZyBorg)\b/i)
session[:split_test_key] = nil
else
session[:split_test_key] = ActionController::Base.random_test_key
cookies[:split_test_key] = session[:split_test_key]
end
return session[:split_test_key]
end

def current_split_test_key
@split_test_key ||= get_split_test_key
end

def is_split_test?
current_split_test_key && current_split_test_key != 'BASELINE'
end

helper_method :is_split_test?, :current_split_test_key
end
end

# Change the namespace for caching if the current request
# is a split test so that caches don't get mixed together
module ActionController #:nodoc:
module Caching
module Fragments
def fragment_cache_key(key)
namespace = is_split_test? ? "views-split-#{current_split_test_key}" : :views
ActiveSupport::Cache.expand_cache_key(key.is_a?(Hash) ? url_for(key).split("://").last : key, namespace)
end
end
end
end

# Overwrite the translate method so that it tries the bucket translation first
# TODO: There is probably a better way to write this
module ActionView
module Helpers
module TranslationHelper
def translate(key, options = {})
key = scope_key_by_partial(key)
if is_split_test?
# normalize the parameters so that we can add in
# the current_split_test_key properly
scope = options.delete(:scope)
keys = I18n.normalize_keys(I18n.locale, key, scope)
keys.shift
key = keys.join('.')

# Set the standard key as a default to fall back on automatically
if options[:default]
options[:default] = [options[:default]] unless options[:default].is_a?(Array)
options[:default].unshift(key.to_sym)
else
options[:default] = [key.to_sym]
end

key = "#{current_split_test_key}.#{key}"
end
translation = I18n.translate(key, options.merge!(:raise => true))
if html_safe_translation_key?(key) && translation.respond_to?(:html_safe)
translation.html_safe
else
translation
end
rescue I18n::MissingTranslationData => e
keys = I18n.normalize_keys(e.locale, e.key, e.options[:scope])
content_tag('span', keys.join(', '), :class => 'translation_missing')
end
alias t translate
end
end
end

# Add the split test language files to the load path
I18n.load_path += Dir[Rails.root.join('test', 'split', '*', 'locale.{rb,yml}')]
17 changes: 17 additions & 0 deletions lib/split_tester/class_methods.rb
@@ -0,0 +1,17 @@
module SplitTester
module ClassMethods
def self.random_test_key
split_test_map.sample
end

private

def self.split_test_map
@@split_test_map
end

def self.preprocessed_pathsets
@@preprocessed_pathsets
end
end
end
69 changes: 69 additions & 0 deletions lib/split_tester/controller.rb
@@ -0,0 +1,69 @@
module SplitTester
module Controller

def self.included(controller)
controller.extend ClassMethods
end

# ---------------------------------------------------------------
# Before Filters
# ---------------------------------------------------------------

# If a split_test_key other than BASELINE exists, add the proper
# view path to the load paths used by ActionView
def setup_split_testing
@split_test_key = get_split_test_key
return if @split_test_key == 'BASELINE' || @split_test_key.nil?
split_test_path = preprocessed_pathsets[SplitTester::Base.custom_view_path(@split_test_key)]
prepend_view_path(split_test_path) if split_test_path
end

# Get the existing split_test_key from the session or the cookie.
# If there isn't one, or if the one isn't a running test anymore
# assign the user a new key and store it.
# Don't assign a key if it is a crawler. (This doesn't feel right)
def get_split_test_key
return params[:force_test_key] if params[:force_test_key] # just for testing
return session[:split_test_key] if session[:split_test_key] && SPLIT_TESTS.has_key?(session[:split_test_key])
return session[:split_test_key] = cookies[:split_test_key] if cookies[:split_test_key] && SPLIT_TESTS.has_key?(cookies[:split_test_key])
if (request.user_agent =~ /\b(Baidu|Gigabot|Googlebot|libwww-perl|lwp-trivial|msnbot|SiteUptime|Slurp|WordPress|ZIBB|ZyBorg)\b/i)
session[:split_test_key] = nil
else
session[:split_test_key] = ApplicationController.random_test_key
cookies[:split_test_key] = session[:split_test_key]
end
return session[:split_test_key]
end

# For caching, we need to add something to the cache_path
# so that it caches each version of the page seperately.
# Ideally, this would be added into the ActionCaching
# module directly so that you don't need to specify anything
# in the caches_action command
def custom_cache_path
path = ActionCachePath.new(self).path
path += ":#{@split_test_key}" if @split_test_key && @split_test_key != 'BASELINE'
path
end

# ---------------------------------------------------------------
# Class Variables
# ---------------------------------------------------------------

# preprocess some pathsets on boot
# doing pathset generation during a request is very costly
@@preprocessed_pathsets = begin
SPLIT_TESTS.keys.reject { |k| k == 'BASELINE' }.inject({}) do |pathsets, slug|
path = SplitTester::Base.custom_view_path(slug)
pathsets[path] = ActionView::Base.process_view_paths(path).first
pathsets
end
end

@@split_test_map = begin
tm = {} # test map
SPLIT_TESTS.each { |k, v| tm[k] = v['size'].to_i }
tm.keys.zip(tm.values).collect { |v,d| (0...d).collect { v }}.flatten
end
end
end
8 changes: 8 additions & 0 deletions test/split_tester_test.rb
@@ -0,0 +1,8 @@
require 'test_helper'

class SplitTesterTest < ActiveSupport::TestCase
# Replace this with your real tests.
test "the truth" do
assert true
end
end
3 changes: 3 additions & 0 deletions test/test_helper.rb
@@ -0,0 +1,3 @@
require 'rubygems'
require 'test/unit'
require 'active_support'
2 changes: 2 additions & 0 deletions uninstall.rb
@@ -0,0 +1,2 @@
# Uninstall hook code here
puts "To clean up, just remove config/split_tests.yml and test/split/"

0 comments on commit 3719af3

Please sign in to comment.