Permalink
Fetching contributors…
Cannot retrieve contributors at this time
446 lines (400 sloc) 15.7 KB
module Vanity
module Rails
def self.load!
::Rails.configuration.before_initialize do
Vanity.configuration.logger ||= ::Rails.logger
Vanity.configuration.setup_locales
end
# Do this at the very end of initialization, allowing you to change
# connection adapter, turn collection on/off, etc.
::Rails.configuration.after_initialize do
Vanity.load! if Vanity.connection.connected?
end
end
# The use_vanity method will setup the controller to allow testing and
# tracking of the current user.
module UseVanity
# Defines the vanity_identity method and the vanity_context_filter filter.
#
# Call with the name of a method that returns an object whose identity
# will be used as the Vanity identity if the user is not already
# cookied. Confusing? Let's try by example:
#
# class ApplicationController < ActionController::Base
# use_vanity :current_user
#
# def current_user
# User.find(session[:user_id])
# end
# end
#
# If that method (current_user in this example) returns nil, Vanity will
# look for a vanity cookie. If there is none, it will create an identity
# (using a cookie to remember it across requests). It also uses this
# mechanism if you don't provide an identity object, by calling
# use_vanity with no arguments.
#
# You can also use a block:
# class ProjectController < ApplicationController
# use_vanity { |controller| controller.params[:project_id] }
# end
def use_vanity(method_name = nil, &block)
define_method(:vanity_identity_block) { block }
define_method(:vanity_identity_method) { method_name }
callback_method_name = respond_to?(:before_action) ? :action : :filter
send(:"around_#{callback_method_name}", :vanity_context_filter)
send(:"before_#{callback_method_name}", :vanity_reload_filter) unless ::Rails.configuration.cache_classes
send(:"before_#{callback_method_name}", :vanity_query_parameter_filter)
send(:"after_#{callback_method_name}", :vanity_track_filter)
end
protected :use_vanity
end
module Identity
def vanity_identity # :nodoc:
return vanity_identity_block.call(self) if vanity_identity_block
return @vanity_identity if defined?(@vanity_identity) && @vanity_identity
# With user sign in, it's possible to visit not-logged in, get
# cookied and shown alternative A, then sign in and based on
# user.id, get shown alternative B.
# This implementation prefers an initial vanity cookie id over a
# new user.id to avoid the flash of alternative B (FOAB).
if request.get? && params[:_identity]
@vanity_identity = params[:_identity]
cookies[Vanity.configuration.cookie_name] = build_vanity_cookie(@vanity_identity)
@vanity_identity
elsif cookies[Vanity.configuration.cookie_name]
@vanity_identity = cookies[Vanity.configuration.cookie_name]
elsif identity = vanity_identity_from_method(vanity_identity_method)
@vanity_identity = identity
elsif response # everyday use
@vanity_identity = cookies[Vanity.configuration.cookie_name] || SecureRandom.hex(16)
cookies[Vanity.configuration.cookie_name] = build_vanity_cookie(@vanity_identity)
@vanity_identity
else # during functional testing
@vanity_identity = "test"
end
end
protected :vanity_identity
def vanity_identity_from_method(method_name) # :nodoc:
return unless method_name
object = send(method_name)
object.try(:id)
end
private :vanity_identity_from_method
def build_vanity_cookie(identity) # :nodoc:
result = {
value: identity,
expires: Time.now + Vanity.configuration.cookie_expires,
path: Vanity.configuration.cookie_path,
domain: Vanity.configuration.cookie_domain,
secure: Vanity.configuration.cookie_secure,
httponly: Vanity.configuration.cookie_httponly
}
result[:domain] ||= ::Rails.application.config.session_options[:domain]
result
end
private :build_vanity_cookie
end
module UseVanityMailer
# Should be called from within the mailer function. For example:
#
# def invite_email(user)
# use_vanity_mailer user
# mail to: user.email, subject: ab_test(:invite_subject)
# end
#
def use_vanity_mailer(symbol = nil)
# Context is the instance of ActionMailer::Base
Vanity.context = self
if symbol && (@object = symbol)
class << self
define_method :vanity_identity do
@vanity_identity = (String === @object ? @object : @object.id)
end
end
else
class << self
define_method :vanity_identity do
@vanity_identity = @vanity_identity || SecureRandom.hex(16)
end
end
end
end
protected :use_vanity_mailer
end
# Vanity needs these filters. They are includes in ActionController and
# automatically added when you use #use_vanity in your controller.
module Filters
# Around filter that sets Vanity.context to controller.
def vanity_context_filter
previous, Vanity.context = Vanity.context, self
yield
ensure
Vanity.context = previous
end
# This filter allows user to choose alternative in experiment using query
# parameter.
#
# Each alternative has a unique fingerprint (run vanity list command to
# see them all). A request with the _vanity query parameter is
# intercepted, the alternative is chosen, and the user redirected to the
# same request URL sans _vanity parameter. This only works for GET
# requests.
#
# For example, if the user requests the page
# http://example.com/?_vanity=2907dac4de, the first alternative of the
# :null_abc experiment is chosen and the user redirected to
# http://example.com/.
def vanity_query_parameter_filter
query_params = request.query_parameters
if request.get? && query_params[:_vanity]
hashes = Array(query_params.delete(:_vanity))
Vanity.playground.experiments.each do |id, experiment|
if experiment.respond_to?(:alternatives)
experiment.alternatives.each do |alt|
if hashes.delete(experiment.fingerprint(alt))
experiment.chooses(alt.value)
break
end
end
end
break if hashes.empty?
end
path_parts = [url_for, query_params.to_query]
redirect_to(path_parts.join('?'))
end
end
# Before filter to reload Vanity experiments/metrics. Enabled when
# cache_classes is false (typically, testing environment).
def vanity_reload_filter
Vanity.playground.reload!
end
# Filter to track metrics. Pass _track param along to call track! on that
# alternative.
def vanity_track_filter
if request.get? && params[:_track]
Vanity.track! params[:_track]
end
end
protected :vanity_context_filter, :vanity_query_parameter_filter, :vanity_reload_filter
end
# Introduces ab_test helper (controllers and views). Similar to the generic
# ab_test method, with the ability to capture content (applicable to views,
# see examples).
module Helpers
# This method returns one of the alternative values in the named A/B test.
#
# @example A/B two alternatives for a page
# def index
# if ab_test(:new_page) # true/false test
# render action: "new_page"
# else
# render action: "index"
# end
# end
# @example Similar, alternative value is page name
# def index
# render action: ab_test(:new_page)
# end
# @example A/B test inside ERB template (condition)
# <%= if ab_test(:banner) %>100% less complexity!<% end %>
# @example A/B test inside ERB template (value)
# <%= ab_test(:greeting) %> <%= current_user.name %>
# @example A/B test inside ERB template (capture)
# <% ab_test :features do |count| %>
# <%= count %> features to choose from!
# <% end %>
def ab_test(name, &block)
current_request = respond_to?(:request) ? self.request : nil
value = Vanity.ab_test(name, current_request)
if block
content = capture(value, &block)
if defined?(block_called_from_erb?) && block_called_from_erb?(block)
concat(content)
else
content
end
else
value
end
end
# Generate url with the identity of the current user and the metric to track on click
def vanity_track_url_for(identity, metric, options = {})
options = options.merge(:_identity => identity, :_track => metric)
url_for(options)
end
# Generate url with the fingerprint for the current Vanity experiment
def vanity_tracking_image(identity, metric, options = {})
options = options.merge(:controller => :vanity, :action => :image, :_identity => identity, :_track => metric)
image_tag(url_for(options), :width => "1px", :height => "1px", :alt => "")
end
def vanity_js
return if Vanity.context.vanity_active_experiments.nil? || Vanity.context.vanity_active_experiments.empty?
javascript_tag do
render :file => Vanity.template("_vanity"), :formats => [:js]
end
end
def vanity_h(text)
h(text)
end
def vanity_html_safe(text)
if text.respond_to?(:html_safe)
text.html_safe
else
text
end
end
def vanity_simple_format(text, html_options={})
vanity_html_safe(simple_format(text, html_options))
end
# Return a copy of the active experiments on a page
#
# @example Render some info about each active experiment in development mode
# <% if Rails.env.development? %>
# <% vanity_experiments.each do |name, alternative| %>
# <span>Participating in <%= name %>, seeing <%= alternative %>:<%= alternative.value %> </span>
# <% end %>
# <% end %>
# @example Push experiment values into javascript for use there
# <% experiments = vanity_experiments %>
# <% unless experiments.empty? %>
# <script>
# <% experiments.each do |name, alternative| %>
# myAbTests.<%= name.to_s.camelize(:lower) %> = '<%= alternative.value %>';
# <% end %>
# </script>
# <% end %>
def vanity_experiments
edit_safe_experiments = {}
Vanity.context.vanity_active_experiments.each do |name, alternative|
edit_safe_experiments[name] = alternative.clone
end
edit_safe_experiments
end
end
# When configuring use_js to true, you must set up a route to
# add_participant_route.
#
# Step 1: Add a new resource in config/routes.rb:
# post "/vanity/add_participant" => "vanity#add_participant"
#
# Step 2: Include Vanity::Rails::AddParticipant (or Vanity::Rails::Dashboard) in VanityController
# class VanityController < ApplicationController
# include Vanity::Rails::AddParticipant
# end
#
# Step 3: Open your browser to http://localhost:3000/vanity
module AddParticipant
# JS callback action used by vanity_js
def add_participant
if params[:v].nil?
head 404
return
end
h = {}
params[:v].split(',').each do |pair|
exp_id, answer = pair.split('=')
exp = Vanity.playground.experiment(exp_id.to_s.to_sym) rescue nil
answer = answer.to_i
if !exp || !exp.alternatives[answer]
head 404
return
end
h[exp] = exp.alternatives[answer].value
end
h.each{ |e,a| e.chooses(a, request) }
head 200
end
end
# Step 1: Add a new resource in config/routes.rb:
# map.vanity "/vanity/:action/:id", :controller=>:vanity
#
# Step 2: Create a new experiments controller:
# class VanityController < ApplicationController
# include Vanity::Rails::Dashboard
# end
#
# Step 3: Open your browser to http://localhost:3000/vanity
module Dashboard
def index
render :file=>Vanity.template("_report"),:content_type=>Mime[:html], :locals=>{
:experiments=>Vanity.playground.experiments,
:experiments_persisted=>Vanity.playground.experiments_persisted?,
:metrics=>Vanity.playground.metrics
}
end
def participant
render :file=>Vanity.template("_participant"), :locals=>{:participant_id => params[:id], :participant_info => Vanity.playground.participant_info(params[:id])}, :content_type=>Mime[:html]
end
def complete
exp = Vanity.playground.experiment(params[:e].to_sym)
alt = exp.alternatives[params[:a].to_i]
confirmed = params[:confirmed]
# make the user confirm before completing an experiment
if confirmed && confirmed.to_i==alt.id && exp.active?
exp.complete!(alt.id)
render :file=>Vanity.template("_experiment"), :locals=>{:experiment=>exp}
else
@to_confirm = alt.id
render :file=>Vanity.template("_experiment"), :locals=>{:experiment=>exp}
end
end
def disable
exp = Vanity.playground.experiment(params[:e].to_sym)
exp.enabled = false
render :file=>Vanity.template("_experiment"), :locals=>{:experiment=>exp}
end
def enable
exp = Vanity.playground.experiment(params[:e].to_sym)
exp.enabled = true
render :file=>Vanity.template("_experiment"), :locals=>{:experiment=>exp}
end
def chooses
exp = Vanity.playground.experiment(params[:e].to_sym)
exp.chooses(exp.alternatives[params[:a].to_i].value)
render :file=>Vanity.template("_experiment"), :locals=>{:experiment=>exp}
end
def reset
exp = Vanity.playground.experiment(params[:e].to_sym)
exp.reset
flash[:notice] = I18n.t 'vanity.experiment_has_been_reset', name: exp.name
render :file=>Vanity.template("_experiment"), :locals=>{:experiment=>exp}
end
include AddParticipant
end
module TrackingImage
def image
# send image
send_file(File.expand_path("../images/x.gif", File.dirname(__FILE__)), :type => 'image/gif', :stream => false, :disposition => 'inline')
end
end
end
end
# Enhance ActionController with use_vanity, filters and helper methods.
ActiveSupport.on_load(:action_controller) do
# Include in controller, add view helper methods.
ActionController::Base.class_eval do
extend Vanity::Rails::UseVanity
include Vanity::Rails::Filters
include Vanity::Rails::Identity
helper Vanity::Rails::Helpers
end
end
# Include in mailer, add view helper methods.
ActiveSupport.on_load(:action_mailer) do
include Vanity::Rails::UseVanityMailer
include Vanity::Rails::Filters
helper Vanity::Rails::Helpers
end
# Reconnect whenever we fork under Passenger.
if defined?(PhusionPassenger)
PhusionPassenger.on_event(:starting_worker_process) do |forked|
if forked
begin
Vanity.playground.reconnect! if Vanity.playground.collecting?
rescue Exception=>ex
Rails.logger.error "Error reconnecting: #{ex.to_s}"
end
end
end
end