vanity 1.8.1 → 1.8.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.travis.yml +47 -5
- data/Appraisals +3 -1
- data/CHANGELOG +7 -1
- data/Gemfile +21 -10
- data/Gemfile.lock +1 -1
- data/README.rdoc +9 -6
- data/gemfiles/rails3.gemfile +9 -8
- data/gemfiles/rails3.gemfile.lock +8 -2
- data/gemfiles/rails31.gemfile +9 -8
- data/gemfiles/rails31.gemfile.lock +8 -2
- data/gemfiles/rails32.gemfile +9 -8
- data/gemfiles/rails32.gemfile.lock +8 -2
- data/lib/vanity.rb +1 -0
- data/lib/vanity/adapters/abstract_adapter.rb +5 -0
- data/lib/vanity/adapters/active_record_adapter.rb +12 -5
- data/lib/vanity/adapters/mongodb_adapter.rb +6 -0
- data/lib/vanity/adapters/redis_adapter.rb +8 -0
- data/lib/vanity/autoconnect.rb +61 -0
- data/lib/vanity/experiment/ab_test.rb +29 -23
- data/lib/vanity/experiment/base.rb +14 -0
- data/lib/vanity/frameworks/rails.rb +11 -4
- data/lib/vanity/playground.rb +24 -15
- data/lib/vanity/version.rb +1 -1
- data/test/autoconnect_test.rb +25 -0
- data/test/dummy/config/boot.rb +4 -6
- data/test/dummy/config/environments/development.rb +1 -1
- data/test/experiment/ab_test.rb +40 -4
- data/test/experiment/base_test.rb +3 -1
- data/test/metric/base_test.rb +10 -10
- data/test/passenger_test.rb +7 -1
- data/test/playground_test.rb +11 -0
- data/test/rails_test.rb +149 -68
- data/test/test_helper.rb +1 -0
- metadata +58 -43
- checksums.yaml +0 -7
@@ -130,6 +130,14 @@ module Vanity
|
|
130
130
|
@experiments.sadd "#{experiment}:alts:#{alternative}:participants", identity
|
131
131
|
end
|
132
132
|
|
133
|
+
def ab_seen(experiment, identity, alternative)
|
134
|
+
if @experiments.sismember "#{experiment}:alts:#{alternative.id}:participants", identity
|
135
|
+
return alternative
|
136
|
+
else
|
137
|
+
return nil
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
133
141
|
def ab_add_conversion(experiment, alternative, identity, count = 1, implicit = false)
|
134
142
|
if implicit
|
135
143
|
@experiments.sadd "#{experiment}:alts:#{alternative}:participants", identity
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Vanity
|
2
|
+
# A singleton responsible for determining if the playground should connect
|
3
|
+
# to the datastore.
|
4
|
+
module Autoconnect
|
5
|
+
|
6
|
+
BLACKLISTED_RAILS_RAKE_TASKS = [
|
7
|
+
'about',
|
8
|
+
'assets:clean',
|
9
|
+
'assets:clobber',
|
10
|
+
'assets:environment',
|
11
|
+
'assets:precompile',
|
12
|
+
'assets:precompile:all',
|
13
|
+
'db:create',
|
14
|
+
'db:drop',
|
15
|
+
'db:fixtures:load',
|
16
|
+
'db:migrate',
|
17
|
+
'db:migrate:status',
|
18
|
+
'db:rollback',
|
19
|
+
'db:schema:cache:clear',
|
20
|
+
'db:schema:cache:dump',
|
21
|
+
'db:schema:dump',
|
22
|
+
'db:schema:load',
|
23
|
+
'db:seed',
|
24
|
+
'db:setup',
|
25
|
+
'db:structure:dump',
|
26
|
+
'db:version',
|
27
|
+
'doc:app',
|
28
|
+
'log:clear',
|
29
|
+
'middleware',
|
30
|
+
'notes',
|
31
|
+
'notes:custom',
|
32
|
+
'rails:template',
|
33
|
+
'rails:update',
|
34
|
+
'routes',
|
35
|
+
'secret',
|
36
|
+
'stats',
|
37
|
+
'time:zones:all',
|
38
|
+
'tmp:clear',
|
39
|
+
'tmp:create'
|
40
|
+
]
|
41
|
+
ENVIRONMENT_VANITY_DISABLED_FLAG = "VANITY_DISABLED"
|
42
|
+
|
43
|
+
def self.playground_should_autoconnect?
|
44
|
+
!environment_disabled? && !in_blacklisted_rake_task?
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.environment_disabled?
|
48
|
+
!!ENV[ENVIRONMENT_VANITY_DISABLED_FLAG]
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.in_blacklisted_rake_task?
|
52
|
+
!(current_rake_tasks & BLACKLISTED_RAILS_RAKE_TASKS).empty?
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.current_rake_tasks
|
56
|
+
::Rake.application.top_level_tasks
|
57
|
+
rescue => e
|
58
|
+
[]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -15,7 +15,7 @@ module Vanity
|
|
15
15
|
|
16
16
|
# Alternative id, only unique for this experiment.
|
17
17
|
attr_reader :id
|
18
|
-
|
18
|
+
|
19
19
|
# Alternative name (option A, option B, etc).
|
20
20
|
attr_reader :name
|
21
21
|
|
@@ -48,7 +48,7 @@ module Vanity
|
|
48
48
|
|
49
49
|
# Probability derived from z-score. Populated by AbTest#score.
|
50
50
|
attr_accessor :probability
|
51
|
-
|
51
|
+
|
52
52
|
# Difference from least performing alternative. Populated by AbTest#score.
|
53
53
|
attr_accessor :difference
|
54
54
|
|
@@ -64,7 +64,7 @@ module Vanity
|
|
64
64
|
end
|
65
65
|
|
66
66
|
def <=>(other)
|
67
|
-
measure <=> other.measure
|
67
|
+
measure <=> other.measure
|
68
68
|
end
|
69
69
|
|
70
70
|
def ==(other)
|
@@ -101,7 +101,7 @@ module Vanity
|
|
101
101
|
end
|
102
102
|
|
103
103
|
def friendly_name
|
104
|
-
"A/B Test"
|
104
|
+
"A/B Test"
|
105
105
|
end
|
106
106
|
|
107
107
|
end
|
@@ -112,8 +112,8 @@ module Vanity
|
|
112
112
|
|
113
113
|
|
114
114
|
# -- Metric --
|
115
|
-
|
116
|
-
# Tells A/B test which metric we're measuring, or returns metric in use.
|
115
|
+
|
116
|
+
# Tells A/B test which metric we're measuring, or returns metric in use.
|
117
117
|
#
|
118
118
|
# @example Define A/B test against coolness metric
|
119
119
|
# ab_test "Background color" do
|
@@ -127,7 +127,6 @@ module Vanity
|
|
127
127
|
@metrics
|
128
128
|
end
|
129
129
|
|
130
|
-
|
131
130
|
# -- Alternatives --
|
132
131
|
|
133
132
|
# Call this method once to set alternative values for this experiment
|
@@ -139,7 +138,7 @@ module Vanity
|
|
139
138
|
# metrics :coolness
|
140
139
|
# alternatives "red", "blue", "orange"
|
141
140
|
# end
|
142
|
-
#
|
141
|
+
#
|
143
142
|
# @example Find out which alternatives this test uses
|
144
143
|
# alts = experiment(:background_color).alternatives
|
145
144
|
# puts "#{alts.count} alternatives, with the colors: #{alts.map(&:value).join(", ")}"
|
@@ -175,7 +174,7 @@ module Vanity
|
|
175
174
|
#
|
176
175
|
# @example
|
177
176
|
# ab_test "More bacon" do
|
178
|
-
# metrics :yummyness
|
177
|
+
# metrics :yummyness
|
179
178
|
# false_true
|
180
179
|
# end
|
181
180
|
#
|
@@ -198,13 +197,20 @@ module Vanity
|
|
198
197
|
if @playground.collecting?
|
199
198
|
if active?
|
200
199
|
identity = identity()
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
200
|
+
index = connection.ab_showing(@id, identity)
|
201
|
+
unless index
|
202
|
+
index = alternative_for(identity)
|
203
|
+
if !@playground.using_js?
|
204
|
+
# if we have an on_assignment block, call it on new assignments
|
205
|
+
if @on_assignment_block
|
206
|
+
assignment = alternatives[index.to_i]
|
207
|
+
if !connection.ab_seen @id, identity, assignment
|
208
|
+
@on_assignment_block.call(Vanity.context, identity, assignment, self)
|
209
|
+
end
|
210
|
+
end
|
211
|
+
connection.ab_add_participant @id, index, identity
|
212
|
+
check_completion!
|
213
|
+
end
|
208
214
|
end
|
209
215
|
else
|
210
216
|
index = connection.ab_get_outcome(@id) || alternative_for(identity)
|
@@ -225,9 +231,9 @@ module Vanity
|
|
225
231
|
Digest::MD5.hexdigest("#{id}:#{alternative.id}")[-10,10]
|
226
232
|
end
|
227
233
|
|
228
|
-
|
234
|
+
|
229
235
|
# -- Testing --
|
230
|
-
|
236
|
+
|
231
237
|
# Forces this experiment to use a particular alternative. You'll want to
|
232
238
|
# use this from your test cases to test for the different alternatives.
|
233
239
|
#
|
@@ -256,8 +262,8 @@ module Vanity
|
|
256
262
|
check_completion!
|
257
263
|
end
|
258
264
|
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
259
|
-
if (connection.ab_showing(@id, identity) && connection.ab_showing(@id, identity) != index) ||
|
260
|
-
|
265
|
+
if (connection.ab_showing(@id, identity) && connection.ab_showing(@id, identity) != index) ||
|
266
|
+
alternative_for(identity) != index
|
261
267
|
connection.ab_show @id, identity, index
|
262
268
|
end
|
263
269
|
end
|
@@ -294,7 +300,7 @@ module Vanity
|
|
294
300
|
# [:z_score] Z-score (relative to the base alternative).
|
295
301
|
# [:probability] Probability (z-score mapped to 0, 90, 95, 99 or 99.9%).
|
296
302
|
# [:difference] Difference from the least performant altenative.
|
297
|
-
#
|
303
|
+
#
|
298
304
|
# The choice alternative is set only if its probability is higher or
|
299
305
|
# equal to the specified probability (default is 90%).
|
300
306
|
def score(probability = 90)
|
@@ -405,7 +411,7 @@ module Vanity
|
|
405
411
|
begin
|
406
412
|
result = @outcome_is.call
|
407
413
|
outcome = result.id if Alternative === result && result.experiment == self
|
408
|
-
rescue
|
414
|
+
rescue
|
409
415
|
warn "Error in AbTest#complete!: #{$!}"
|
410
416
|
end
|
411
417
|
else
|
@@ -416,7 +422,7 @@ module Vanity
|
|
416
422
|
connection.ab_set_outcome @id, outcome || 0
|
417
423
|
end
|
418
424
|
|
419
|
-
|
425
|
+
|
420
426
|
# -- Store/validate --
|
421
427
|
|
422
428
|
def destroy
|
@@ -69,6 +69,7 @@ module Vanity
|
|
69
69
|
@id, @name = id.to_sym, name
|
70
70
|
@options = options || {}
|
71
71
|
@identify_block = method(:default_identify)
|
72
|
+
@on_assignment_block = nil
|
72
73
|
end
|
73
74
|
|
74
75
|
# Human readable experiment name (first argument you pass when creating a
|
@@ -113,6 +114,19 @@ module Vanity
|
|
113
114
|
@identify_block = block
|
114
115
|
end
|
115
116
|
|
117
|
+
# Defines any additional actions to take when a new assignment is made for the current experiment
|
118
|
+
#
|
119
|
+
# For example, if you want to use the rails default logger to log whenever an assignment is made:
|
120
|
+
# ab_test "Project widget" do
|
121
|
+
# alternatives :small, :medium, :large
|
122
|
+
# on_assignment do |controller, identity, assignment|
|
123
|
+
# controller.logger.info "made a split test assignment for #{experiment.name}: #{identity} => #{assignment}"
|
124
|
+
# end
|
125
|
+
# end
|
126
|
+
def on_assignment(&block)
|
127
|
+
fail "Missing block" unless block
|
128
|
+
@on_assignment_block = block
|
129
|
+
end
|
116
130
|
|
117
131
|
# -- Reporting --
|
118
132
|
|
@@ -182,9 +182,7 @@ module Vanity
|
|
182
182
|
# <%= count %> features to choose from!
|
183
183
|
# <% end %>
|
184
184
|
def ab_test(name, &block)
|
185
|
-
|
186
|
-
@_vanity_experiments[name] ||= Vanity.playground.experiment(name.to_sym).choose
|
187
|
-
value = @_vanity_experiments[name].value
|
185
|
+
value = setup_experiment(name)
|
188
186
|
|
189
187
|
if block
|
190
188
|
content = capture(value, &block)
|
@@ -258,8 +256,17 @@ module Vanity
|
|
258
256
|
experiments[name] = alternative.clone
|
259
257
|
end
|
260
258
|
|
261
|
-
|
259
|
+
experiments
|
260
|
+
end
|
261
|
+
|
262
|
+
protected
|
263
|
+
|
264
|
+
def setup_experiment(name)
|
265
|
+
@_vanity_experiments ||= {}
|
266
|
+
@_vanity_experiments[name] ||= Vanity.playground.experiment(name.to_sym).choose
|
267
|
+
@_vanity_experiments[name].value
|
262
268
|
end
|
269
|
+
|
263
270
|
end
|
264
271
|
|
265
272
|
|
data/lib/vanity/playground.rb
CHANGED
@@ -21,6 +21,7 @@ module Vanity
|
|
21
21
|
# - namespace -- Namespace to use
|
22
22
|
# - load_path -- Path to load experiments/metrics from
|
23
23
|
# - logger -- Logger to use
|
24
|
+
# - redis -- A Redis object that will be used for the connection
|
24
25
|
def initialize(*args)
|
25
26
|
options = Hash === args.last ? args.pop : {}
|
26
27
|
# In the case of Rails, use the Rails logger and collect only for
|
@@ -39,18 +40,6 @@ module Vanity
|
|
39
40
|
end
|
40
41
|
|
41
42
|
@options = defaults.merge(config).merge(options)
|
42
|
-
if @options[:host] == 'redis' && @options.values_at(:host, :port, :db).any?
|
43
|
-
warn "Deprecated: please specify Redis connection as URL (\"redis://host:port/db\")"
|
44
|
-
establish_connection :adapter=>"redis", :host=>@options[:host], :port=>@options[:port], :database=>@options[:db] || @options[:database]
|
45
|
-
elsif @options[:redis]
|
46
|
-
@adapter = RedisAdapter.new(:redis=>@options[:redis])
|
47
|
-
else
|
48
|
-
connection_spec = args.shift || @options[:connection]
|
49
|
-
if connection_spec
|
50
|
-
connection_spec = "redis://" + connection_spec unless connection_spec[/^\w+:/]
|
51
|
-
establish_connection connection_spec
|
52
|
-
end
|
53
|
-
end
|
54
43
|
|
55
44
|
warn "Deprecated: namespace option no longer supported directly" if @options[:namespace]
|
56
45
|
@load_path = @options[:load_path] || DEFAULTS[:load_path]
|
@@ -58,12 +47,15 @@ module Vanity
|
|
58
47
|
@logger = Logger.new(STDOUT)
|
59
48
|
@logger.level = Logger::ERROR
|
60
49
|
end
|
50
|
+
|
51
|
+
autoconnect(@options, args) if Vanity::Autoconnect.playground_should_autoconnect?
|
52
|
+
|
61
53
|
@loading = []
|
62
54
|
@use_js = false
|
63
55
|
self.add_participant_path = DEFAULT_ADD_PARTICIPANT_PATH
|
64
56
|
@collecting = !!@options[:collecting]
|
65
57
|
end
|
66
|
-
|
58
|
+
|
67
59
|
# Deprecated. Use redis.server instead.
|
68
60
|
attr_accessor :host, :port, :db, :password, :namespace
|
69
61
|
|
@@ -219,7 +211,7 @@ module Vanity
|
|
219
211
|
|
220
212
|
|
221
213
|
# -- Connection management --
|
222
|
-
|
214
|
+
|
223
215
|
# This is the preferred way to programmatically create a new connection (or
|
224
216
|
# switch to a new connection). If no connection was established, the
|
225
217
|
# playground will create a new one by calling this method with no arguments.
|
@@ -242,7 +234,7 @@ module Vanity
|
|
242
234
|
# Vanity.playground.establish_connection :adapter=>:redis,
|
243
235
|
# :host=>"redis.local"
|
244
236
|
#
|
245
|
-
# @since 1.4.0
|
237
|
+
# @since 1.4.0
|
246
238
|
def establish_connection(spec = nil)
|
247
239
|
@spec = spec
|
248
240
|
disconnect! if @adapter
|
@@ -343,6 +335,23 @@ module Vanity
|
|
343
335
|
connection
|
344
336
|
end
|
345
337
|
|
338
|
+
protected
|
339
|
+
|
340
|
+
def autoconnect(options, arguments)
|
341
|
+
if options[:host] == 'redis' && options.values_at(:host, :port, :db).any?
|
342
|
+
warn "Deprecated: please specify Redis connection as URL (\"redis://host:port/db\")"
|
343
|
+
establish_connection :adapter=>"redis", :host=>options[:host], :port=>options[:port], :database=>options[:db] || options[:database]
|
344
|
+
elsif options[:redis]
|
345
|
+
@adapter = RedisAdapter.new(:redis=>options[:redis])
|
346
|
+
else
|
347
|
+
connection_spec = arguments.shift || options[:connection]
|
348
|
+
if connection_spec
|
349
|
+
connection_spec = "redis://" + connection_spec unless connection_spec[/^\w+:/]
|
350
|
+
establish_connection connection_spec
|
351
|
+
end
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
346
355
|
end
|
347
356
|
|
348
357
|
# In the case of Rails, use the Rails logger and collect only for
|
data/lib/vanity/version.rb
CHANGED
@@ -0,0 +1,25 @@
|
|
1
|
+
require "test/test_helper"
|
2
|
+
|
3
|
+
context ".playground_should_autoconnect?" do
|
4
|
+
|
5
|
+
setup do
|
6
|
+
end
|
7
|
+
|
8
|
+
test "returns true by default" do
|
9
|
+
autoconnect = Vanity::Autoconnect.playground_should_autoconnect?
|
10
|
+
assert autoconnect == true
|
11
|
+
end
|
12
|
+
|
13
|
+
test "returns false if environment flag is set" do
|
14
|
+
ENV['VANITY_DISABLED'] = '1'
|
15
|
+
autoconnect = Vanity::Autoconnect.playground_should_autoconnect?
|
16
|
+
assert autoconnect == false
|
17
|
+
ENV['VANITY_DISABLED'] = nil
|
18
|
+
end
|
19
|
+
|
20
|
+
test "returns false if in assets:precompile rake task" do
|
21
|
+
Rake.expects(:application).returns(stub(:top_level_tasks => ['assets:precompile']))
|
22
|
+
autoconnect = Vanity::Autoconnect.playground_should_autoconnect?
|
23
|
+
assert autoconnect == false
|
24
|
+
end
|
25
|
+
end
|
data/test/dummy/config/boot.rb
CHANGED
@@ -1,10 +1,8 @@
|
|
1
1
|
require 'rubygems'
|
2
|
-
gemfile = File.expand_path('../../../../Gemfile', __FILE__)
|
3
2
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
end
|
3
|
+
# Set up gems listed in the Gemfile.
|
4
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
|
5
|
+
|
6
|
+
require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE'])
|
9
7
|
|
10
8
|
$:.unshift File.expand_path('../../../../lib', __FILE__)
|
@@ -11,7 +11,7 @@ Dummy::Application.configure do
|
|
11
11
|
|
12
12
|
# Show full error reports and disable caching
|
13
13
|
config.consider_all_requests_local = true
|
14
|
-
config.action_view.debug_rjs = true
|
14
|
+
config.action_view.debug_rjs = true if ActionView::Base.respond_to?(:debug_rjs=)
|
15
15
|
config.action_controller.perform_caching = false
|
16
16
|
|
17
17
|
# Don't care if the mailer can't send
|
data/test/experiment/ab_test.rb
CHANGED
@@ -35,6 +35,15 @@ class AbTestTest < ActionController::TestCase
|
|
35
35
|
metric "Coolness"
|
36
36
|
end
|
37
37
|
|
38
|
+
def teardown
|
39
|
+
super
|
40
|
+
if RUBY_VERSION == '1.8.7'
|
41
|
+
GC.enable
|
42
|
+
GC.start
|
43
|
+
GC.disable
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
38
47
|
# -- Experiment definition --
|
39
48
|
|
40
49
|
def test_requires_at_least_two_alternatives_per_experiment
|
@@ -53,7 +62,7 @@ class AbTestTest < ActionController::TestCase
|
|
53
62
|
metrics :coolness
|
54
63
|
end
|
55
64
|
end
|
56
|
-
|
65
|
+
|
57
66
|
def test_returning_alternative_by_value
|
58
67
|
new_ab_test :abcd do
|
59
68
|
alternatives :a, :b, :c, :d
|
@@ -137,6 +146,33 @@ class AbTestTest < ActionController::TestCase
|
|
137
146
|
assert_equal 0, alts.map(&:participants).sum
|
138
147
|
end
|
139
148
|
|
149
|
+
# -- on_assignment --
|
150
|
+
|
151
|
+
def test_calls_on_assignment_on_new_assignment
|
152
|
+
on_assignment_called_times = 0
|
153
|
+
new_ab_test :foobar do
|
154
|
+
alternatives "foo", "bar"
|
155
|
+
identify { "6e98ec" }
|
156
|
+
metrics :coolness
|
157
|
+
on_assignment { on_assignment_called_times = on_assignment_called_times+1 }
|
158
|
+
end
|
159
|
+
2.times { experiment(:foobar).choose }
|
160
|
+
assert_equal 1, on_assignment_called_times
|
161
|
+
end
|
162
|
+
|
163
|
+
def test_returns_the_same_alternative_consistently_when_on_assignment_is_set
|
164
|
+
new_ab_test :foobar do
|
165
|
+
alternatives "foo", "bar"
|
166
|
+
identify { "6e98ec" }
|
167
|
+
on_assignment {}
|
168
|
+
metrics :coolness
|
169
|
+
end
|
170
|
+
assert value = experiment(:foobar).choose.value
|
171
|
+
assert_match /foo|bar/, value
|
172
|
+
1000.times do
|
173
|
+
assert_equal value, experiment(:foobar).choose.value
|
174
|
+
end
|
175
|
+
end
|
140
176
|
|
141
177
|
# -- Running experiment --
|
142
178
|
|
@@ -288,7 +324,7 @@ class AbTestTest < ActionController::TestCase
|
|
288
324
|
|
289
325
|
|
290
326
|
# -- Testing with tests --
|
291
|
-
|
327
|
+
|
292
328
|
def test_with_given_choice
|
293
329
|
new_ab_test :simple do
|
294
330
|
alternatives :a, :b, :c
|
@@ -329,7 +365,7 @@ class AbTestTest < ActionController::TestCase
|
|
329
365
|
|
330
366
|
|
331
367
|
# -- Scoring --
|
332
|
-
|
368
|
+
|
333
369
|
def test_scoring
|
334
370
|
new_ab_test :abcd do
|
335
371
|
alternatives :a, :b, :c, :d
|
@@ -601,7 +637,7 @@ This experiment did not run long enough to find a clear winner.
|
|
601
637
|
|
602
638
|
|
603
639
|
# -- Outcome --
|
604
|
-
|
640
|
+
|
605
641
|
def test_completion_outcome
|
606
642
|
new_ab_test :quick do
|
607
643
|
outcome_is { alternatives[1] }
|