vanity 0.3.1 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +34 -19
- data/README.rdoc +19 -15
- data/lib/vanity/commands/report.rb +11 -4
- data/lib/vanity/experiment/ab_test.rb +149 -108
- data/lib/vanity/experiment/base.rb +42 -53
- data/lib/vanity/playground.rb +48 -24
- data/lib/vanity/rails.rb +2 -1
- data/lib/vanity/rails/dashboard.rb +15 -0
- data/lib/vanity/rails/helpers.rb +19 -32
- data/lib/vanity/rails/testing.rb +3 -1
- data/lib/vanity/templates/_ab_test.erb +7 -5
- data/lib/vanity/templates/_experiment.erb +5 -0
- data/lib/vanity/templates/_experiments.erb +2 -7
- data/lib/vanity/templates/_report.erb +14 -14
- data/lib/vanity/templates/vanity.css +13 -0
- data/test/ab_test_test.rb +147 -110
- data/test/experiment_test.rb +15 -22
- data/test/experiments/age_and_zipcode.rb +17 -2
- data/test/experiments/null_abc.rb +1 -1
- data/test/playground_test.rb +53 -31
- data/test/rails_test.rb +1 -1
- data/test/test_helper.rb +2 -0
- data/vanity.gemspec +2 -2
- metadata +7 -6
- data/lib/vanity/rails/console.rb +0 -14
- data/lib/vanity/templates/_vanity.css +0 -13
@@ -14,65 +14,61 @@ module Vanity
|
|
14
14
|
|
15
15
|
end
|
16
16
|
|
17
|
-
def initialize(playground, id, name, &block)
|
17
|
+
def initialize(playground, id, name, options, &block)
|
18
18
|
@playground = playground
|
19
19
|
@id, @name = id.to_sym, name
|
20
|
+
@options = options || {}
|
20
21
|
@namespace = "#{@playground.namespace}:#{@id}"
|
21
22
|
@identify_block = ->(context){ context.vanity_identity }
|
22
23
|
end
|
23
24
|
|
24
|
-
# Human readable experiment name
|
25
|
+
# Human readable experiment name (first argument you pass when creating a
|
26
|
+
# new experiment).
|
25
27
|
attr_reader :name
|
26
28
|
|
27
|
-
# Unique identifier, derived from name, e.g. "Green
|
29
|
+
# Unique identifier, derived from name experiment name, e.g. "Green
|
30
|
+
# Button" becomes :green_button.
|
28
31
|
attr_reader :id
|
29
|
-
|
30
|
-
#
|
32
|
+
|
33
|
+
# Time stamp when experiment was created.
|
31
34
|
attr_reader :created_at
|
32
35
|
|
33
|
-
#
|
36
|
+
# Time stamp when experiment was completed.
|
34
37
|
attr_reader :completed_at
|
35
38
|
|
36
|
-
# Returns the type of this
|
39
|
+
# Returns the type of this experiment as a symbol (e.g. :ab_test).
|
37
40
|
def type
|
38
41
|
self.class.type
|
39
42
|
end
|
40
43
|
|
41
|
-
#
|
42
|
-
#
|
43
|
-
#
|
44
|
-
#
|
45
|
-
# For example, this experiment use the identity of the project associated
|
46
|
-
# with the controller:
|
47
|
-
#
|
48
|
-
# class ProjectController < ApplicationController
|
49
|
-
# before_filter :set_project
|
50
|
-
# attr_reader :project
|
44
|
+
# Defines how we obtain an identity for the current experiment. Usually
|
45
|
+
# Vanity gets the identity form a session object (see use_vanity), but
|
46
|
+
# there are cases where you want a particular experiment to use a
|
47
|
+
# different identity.
|
51
48
|
#
|
52
|
-
#
|
53
|
-
#
|
54
|
-
#
|
55
|
-
# experiment "Project widget" do
|
49
|
+
# For example, if all your experiments use current_user and you need one
|
50
|
+
# experiment to use the current project:
|
51
|
+
# ab_test "Project widget" do
|
56
52
|
# alternatives :small, :medium, :large
|
57
53
|
# identify do |controller|
|
58
54
|
# controller.project.id
|
59
55
|
# end
|
60
56
|
# end
|
61
57
|
def identify(&block)
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
end
|
58
|
+
@identify_block = block
|
59
|
+
end
|
60
|
+
|
61
|
+
def identity
|
62
|
+
@identify_block.call(Vanity.context) or fail "No identity found"
|
68
63
|
end
|
64
|
+
protected :identity
|
69
65
|
|
70
66
|
|
71
67
|
# -- Reporting --
|
72
68
|
|
73
69
|
# Sets or returns description. For example
|
74
|
-
#
|
75
|
-
# description "
|
70
|
+
# ab_test "Simple" do
|
71
|
+
# description "A simple A/B experiment"
|
76
72
|
# end
|
77
73
|
#
|
78
74
|
# puts "Just defined: " + experiment(:simple).description
|
@@ -90,8 +86,7 @@ module Vanity
|
|
90
86
|
|
91
87
|
# Define experiment completion condition. For example:
|
92
88
|
# complete_if do
|
93
|
-
#
|
94
|
-
# alternatives.any? { |alt| alt.confidence >= 0.95 }
|
89
|
+
# !score(95).chosen.nil?
|
95
90
|
# end
|
96
91
|
def complete_if(&block)
|
97
92
|
raise ArgumentError, "Missing block" unless block
|
@@ -129,41 +124,35 @@ module Vanity
|
|
129
124
|
redis[key(:completed_at)].nil?
|
130
125
|
end
|
131
126
|
|
132
|
-
|
133
127
|
# -- Store/validate --
|
134
128
|
|
135
|
-
#
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
def key(name = nil) #:nodoc:
|
140
|
-
name ? "#{@namespace}:#{name}" : @namespace
|
129
|
+
# Get rid of all experiment data.
|
130
|
+
def destroy
|
131
|
+
redis.del key(:created_at)
|
132
|
+
redis.del key(:completed_at)
|
141
133
|
end
|
142
134
|
|
143
|
-
# Shortcut for Vanity.playground.redis
|
144
|
-
def redis #:nodoc:
|
145
|
-
@playground.redis
|
146
|
-
end
|
147
|
-
|
148
135
|
# Called by Playground to save the experiment definition.
|
149
136
|
def save
|
150
137
|
redis.setnx key(:created_at), Time.now.to_i
|
151
138
|
@created_at = Time.at(redis[key(:created_at)].to_i)
|
152
139
|
end
|
153
140
|
|
154
|
-
|
155
|
-
def reset!
|
156
|
-
@created_at = Time.now
|
157
|
-
redis[key(:created_at)] = @created_at.to_i
|
158
|
-
redis.del key(:completed_at)
|
159
|
-
end
|
141
|
+
protected
|
160
142
|
|
161
|
-
#
|
162
|
-
|
163
|
-
|
164
|
-
|
143
|
+
# Returns key for this experiment, or with an argument, return a key
|
144
|
+
# using the experiment as the namespace. Examples:
|
145
|
+
# key => "vanity:experiments:green_button"
|
146
|
+
# key("participants") => "vanity:experiments:green_button:participants"
|
147
|
+
def key(name = nil)
|
148
|
+
name ? "#{@namespace}:#{name}" : @namespace
|
165
149
|
end
|
166
150
|
|
151
|
+
# Shortcut for Vanity.playground.redis
|
152
|
+
def redis
|
153
|
+
@playground.redis
|
154
|
+
end
|
155
|
+
|
167
156
|
end
|
168
157
|
end
|
169
158
|
end
|
data/lib/vanity/playground.rb
CHANGED
@@ -1,7 +1,21 @@
|
|
1
1
|
module Vanity
|
2
2
|
|
3
|
-
#
|
4
|
-
|
3
|
+
# These methods are available from experiment definitions (files located in
|
4
|
+
# the experiments directory, automatically loaded by Vanity). Use these
|
5
|
+
# methods to define you experiments, for example:
|
6
|
+
# ab_test "New Banner" do
|
7
|
+
# alternatives :red, :green, :blue
|
8
|
+
# end
|
9
|
+
module Definition
|
10
|
+
|
11
|
+
protected
|
12
|
+
# Defines a new experiment, given the experiment's name, type and
|
13
|
+
# definition block.
|
14
|
+
def define(name, type, options = nil, &block)
|
15
|
+
options ||= {}
|
16
|
+
Vanity.playground.define(name, type, options, &block)
|
17
|
+
end
|
18
|
+
|
5
19
|
end
|
6
20
|
|
7
21
|
# Playground catalogs all your experiments, holds the Vanity configuration.
|
@@ -40,14 +54,12 @@ module Vanity
|
|
40
54
|
attr_accessor :logger
|
41
55
|
|
42
56
|
# Defines a new experiment. Generally, do not call this directly,
|
43
|
-
# use
|
44
|
-
def define(name, options =
|
57
|
+
# use one of the definition methods (ab_test, measure, etc).
|
58
|
+
def define(name, type, options = {}, &block)
|
45
59
|
id = name.to_s.downcase.gsub(/\W/, "_")
|
46
60
|
raise "Experiment #{id} already defined once" if @experiments[id]
|
47
|
-
options ||= {}
|
48
|
-
type = options[:type] || :ab_test
|
49
61
|
klass = Experiment.const_get(type.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase })
|
50
|
-
experiment = klass.new(self, id, name)
|
62
|
+
experiment = klass.new(self, id, name, options)
|
51
63
|
experiment.instance_eval &block
|
52
64
|
experiment.save
|
53
65
|
@experiments[id] = experiment
|
@@ -62,7 +74,23 @@ module Vanity
|
|
62
74
|
def experiment(name)
|
63
75
|
id = name.to_s.downcase.gsub(/\W/, "_")
|
64
76
|
unless @experiments.has_key?(id)
|
65
|
-
|
77
|
+
@loading ||= []
|
78
|
+
fail "Circular dependency detected: #{@loading.join('=>')}=>#{id}" if @loading.include?(id)
|
79
|
+
begin
|
80
|
+
@loading.push id
|
81
|
+
source = File.read(File.expand_path("#{id}.rb", load_path))
|
82
|
+
context = Object.new
|
83
|
+
context.instance_eval do
|
84
|
+
extend Definition
|
85
|
+
eval source
|
86
|
+
end
|
87
|
+
rescue
|
88
|
+
error = LoadError.exception($!.message)
|
89
|
+
error.set_backtrace $!.backtrace
|
90
|
+
raise error
|
91
|
+
ensure
|
92
|
+
@loading.pop
|
93
|
+
end
|
66
94
|
end
|
67
95
|
@experiments[id] or fail LoadError, "Expected experiments/#{id}.rb to define experiment #{name}"
|
68
96
|
end
|
@@ -70,11 +98,17 @@ module Vanity
|
|
70
98
|
# Returns list of all loaded experiments.
|
71
99
|
def experiments
|
72
100
|
Dir[File.join(load_path, "*.rb")].each do |file|
|
73
|
-
|
101
|
+
id = File.basename(file).gsub(/.rb$/, "")
|
102
|
+
experiment id
|
74
103
|
end
|
75
104
|
@experiments.values
|
76
105
|
end
|
77
106
|
|
107
|
+
# Reloads all experiments.
|
108
|
+
def reload!
|
109
|
+
@experiments.clear
|
110
|
+
end
|
111
|
+
|
78
112
|
# Use this instance to access the Redis database.
|
79
113
|
def redis
|
80
114
|
redis = Redis.new(host: self.host, port: self.port, db: self.db,
|
@@ -100,7 +134,7 @@ module Vanity
|
|
100
134
|
end
|
101
135
|
|
102
136
|
# Sets the Vanity context. For example, when using Rails this would be
|
103
|
-
# set by the set_vanity_context before filter (via use_vanity).
|
137
|
+
# set by the set_vanity_context before filter (via Vanity::Rails#use_vanity).
|
104
138
|
def context=(context)
|
105
139
|
Thread.current[:vanity_context] = context
|
106
140
|
end
|
@@ -115,22 +149,12 @@ module Vanity
|
|
115
149
|
end
|
116
150
|
end
|
117
151
|
|
152
|
+
|
118
153
|
class Object
|
119
154
|
|
120
|
-
# Use this method to
|
121
|
-
#
|
122
|
-
# To define an experiment, call with a name, options and a block. For
|
123
|
-
# example:
|
124
|
-
# experiment "Text size" do
|
125
|
-
# alternatives :small, :medium, :large
|
126
|
-
# end
|
127
|
-
#
|
155
|
+
# Use this method to access an experiment by name. For example:
|
128
156
|
# puts experiment(:text_size).alternatives
|
129
|
-
def experiment(name
|
130
|
-
|
131
|
-
Vanity.playground.define(name, options, &block)
|
132
|
-
else
|
133
|
-
Vanity.playground.experiment(name)
|
134
|
-
end
|
157
|
+
def experiment(name)
|
158
|
+
Vanity.playground.experiment(name)
|
135
159
|
end
|
136
160
|
end
|
data/lib/vanity/rails.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
require "vanity"
|
2
2
|
require "vanity/rails/helpers"
|
3
3
|
require "vanity/rails/testing"
|
4
|
-
require "vanity/rails/
|
4
|
+
require "vanity/rails/dashboard"
|
5
5
|
|
6
6
|
# Use Rails logger by default.
|
7
7
|
Vanity.playground.logger ||= ActionController::Base.logger
|
@@ -9,6 +9,7 @@ Vanity.playground.load_path = "#{RAILS_ROOT}/experiments"
|
|
9
9
|
|
10
10
|
# Include in controller, add view helper methods.
|
11
11
|
ActionController::Base.class_eval do
|
12
|
+
extend Vanity::Rails::ClassMethods
|
12
13
|
include Vanity::Rails
|
13
14
|
helper Vanity::Rails
|
14
15
|
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Vanity
|
2
|
+
module Rails
|
3
|
+
module Dashboard
|
4
|
+
def index
|
5
|
+
render Vanity.template("_report"), content_type: Mime::HTML, layout: true
|
6
|
+
end
|
7
|
+
|
8
|
+
def chooses
|
9
|
+
exp = Vanity.playground.experiment(params[:e])
|
10
|
+
exp.chooses(exp.alternatives[params[:a].to_i].value)
|
11
|
+
render partial: Vanity.template("experiment"), locals: { experiment: exp }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
data/lib/vanity/rails/helpers.rb
CHANGED
@@ -14,43 +14,33 @@ module Vanity
|
|
14
14
|
# 3) Measure conversion:
|
15
15
|
#
|
16
16
|
# def signup
|
17
|
-
#
|
17
|
+
# track! :pricing
|
18
18
|
# . . .
|
19
19
|
# end
|
20
20
|
module Rails
|
21
21
|
module ClassMethods
|
22
22
|
|
23
|
-
# Defines the vanity_identity method
|
24
|
-
# filter.
|
23
|
+
# Defines the vanity_identity method and the set_identity_context filter.
|
25
24
|
#
|
26
|
-
#
|
27
|
-
# the
|
28
|
-
#
|
29
|
-
# identity, group, project. The object must provide its identity in
|
30
|
-
# response to the method +id+.
|
31
|
-
#
|
32
|
-
# For example, if +current_user+ returns a +User+ object, then to use the
|
33
|
-
# user's id:
|
25
|
+
# Call with the name of a method that returns an object whose identity
|
26
|
+
# will be used as the Vanity identity. Confusing? Let's try by example:
|
27
|
+
#
|
34
28
|
# class ApplicationController < ActionController::Base
|
35
29
|
# use_vanity :current_user
|
36
|
-
# end
|
37
|
-
#
|
38
|
-
# If that method returns nil (e.g. the user has not signed in), a random
|
39
|
-
# value will be used, instead. That random value is maintained using a
|
40
|
-
# cookie.
|
41
|
-
#
|
42
|
-
# Alternatively, if you call use_vanity with a block, it will yield to the
|
43
|
-
# block with controller.
|
44
|
-
#
|
45
|
-
# If there is no identity you can use, call use_vanity with the value +nil+.
|
46
30
|
#
|
47
|
-
#
|
48
|
-
#
|
49
|
-
#
|
31
|
+
# def current_user
|
32
|
+
# User.find(session[:user_id])
|
33
|
+
# end
|
50
34
|
# end
|
35
|
+
#
|
36
|
+
# If that method (current_user in this example) returns nil, Vanity will
|
37
|
+
# set the identity for you (using a cookie to remember it across
|
38
|
+
# requests). It also uses this mechanism if you don't provide an
|
39
|
+
# identity object, by calling use_vanity with no arguments.
|
51
40
|
#
|
41
|
+
# Of course you can also use a block:
|
52
42
|
# class ProjectController < ApplicationController
|
53
|
-
# use_vanity { |controller| controller.project_id }
|
43
|
+
# use_vanity { |controller| controller.params[:project_id] }
|
54
44
|
# end
|
55
45
|
def use_vanity(symbol = nil, &block)
|
56
46
|
define_method :vanity_identity do
|
@@ -71,13 +61,10 @@ module Vanity
|
|
71
61
|
Vanity.context = self
|
72
62
|
end
|
73
63
|
before_filter :set_vanity_context
|
64
|
+
before_filter { Vanity.playground.reload! } unless ::Rails.configuration.cache_classes
|
74
65
|
end
|
75
66
|
end
|
76
67
|
|
77
|
-
def self.included(base) #:nodoc:
|
78
|
-
base.extend ClassMethods
|
79
|
-
end
|
80
|
-
|
81
68
|
# This method returns one of the alternative values in the named A/B test.
|
82
69
|
#
|
83
70
|
# Examples using ab_test inside controller:
|
@@ -113,11 +100,11 @@ module Vanity
|
|
113
100
|
|
114
101
|
# This method records conversion on the named A/B test. For example:
|
115
102
|
# def create
|
116
|
-
#
|
103
|
+
# track! :call_to_action
|
117
104
|
# Acccount.create! params[:account]
|
118
105
|
# end
|
119
|
-
def
|
120
|
-
Vanity.playground.experiment(name).
|
106
|
+
def track!(name, *args)
|
107
|
+
Vanity.playground.experiment(name).track! *args
|
121
108
|
end
|
122
109
|
end
|
123
110
|
end
|
data/lib/vanity/rails/testing.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
|
-
module ActionController
|
1
|
+
module ActionController
|
2
2
|
class TestCase
|
3
3
|
alias :setup_controller_request_and_response_without_vanity :setup_controller_request_and_response
|
4
|
+
# Sets Vanity.context to the current controller, so you can do things like:
|
5
|
+
# experiment(:simple).chooses(:green)
|
4
6
|
def setup_controller_request_and_response
|
5
7
|
setup_controller_request_and_response_without_vanity
|
6
8
|
Vanity.context = @controller
|
@@ -1,21 +1,23 @@
|
|
1
1
|
<% score = experiment.score %>
|
2
2
|
<table>
|
3
|
-
<caption
|
3
|
+
<caption>
|
4
|
+
<%= experiment.conclusion(score).join(" ") %></caption>
|
4
5
|
<% score.alts.each do |alt| %>
|
5
6
|
<tr class="<%= "choice" if score.choice == alt %>">
|
6
7
|
<td class="option"><%= alt.name.gsub(/^o/, "O") %>:</td>
|
7
|
-
<td class="value"><code><%=
|
8
|
+
<td class="value"><code><%=h alt.value.to_s %></code></td>
|
8
9
|
<td>
|
9
10
|
<%= "%.1f%%" % [alt.conversion_rate * 100] %>
|
10
11
|
<%= "(%d%% better than %s)" % [alt.difference, score.least.name] if alt.difference && alt.difference >= 1 %>
|
11
12
|
</td>
|
12
13
|
<td class="action">
|
13
14
|
<% if experiment.active? && respond_to?(:chooses_experiments_url) %>
|
14
|
-
<% if experiment.
|
15
|
+
<% if experiment.showing?(alt) %>
|
15
16
|
showing
|
16
17
|
<% else %>
|
17
|
-
<%=
|
18
|
-
|
18
|
+
<%= link_to_remote "show", update: "experiment_#{experiment.id}",
|
19
|
+
url: chooses_experiments_url(e: experiment.id, a: alt.id), method: :post,
|
20
|
+
html: { class: "button", title: "Show me this alternative from now on" } %>
|
19
21
|
<% end %>
|
20
22
|
<% end %>
|
21
23
|
</td>
|
@@ -0,0 +1,5 @@
|
|
1
|
+
<h2><%=h experiment.name %> <span class="type">(<%= experiment.class.friendly_name %>)</span></h2>
|
2
|
+
<%= experiment.description.to_s.split(/\n\s*\n/).map { |para| %{<p class="description">#{h para}</p>} }.join %>
|
3
|
+
<%= render Vanity.template(experiment.type), experiment: experiment %>
|
4
|
+
<p class="meta">Started <%= experiment.created_at.strftime("%a, %b %-d") %>
|
5
|
+
<%= " | Completed #{experiment.completed_at.strftime("%a, %b %-d")}" unless experiment.active? %></p>
|