vanity 1.8.1 → 1.8.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
- index = connection.ab_showing(@id, identity)
202
- unless index
203
- index = alternative_for(identity)
204
- if !@playground.using_js?
205
- connection.ab_add_participant @id, index, identity
206
- check_completion!
207
- end
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
- alternative_for(identity) != index
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
- @_vanity_experiments ||= {}
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
- return experiments
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
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  module Vanity
2
- VERSION = "1.8.1"
2
+ VERSION = "1.8.2"
3
3
 
4
4
  module Version
5
5
  version = VERSION.to_s.split(".").map { |i| i.to_i }
@@ -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
@@ -1,10 +1,8 @@
1
1
  require 'rubygems'
2
- gemfile = File.expand_path('../../../../Gemfile', __FILE__)
3
2
 
4
- if File.exist?(gemfile)
5
- ENV['BUNDLE_GEMFILE'] = gemfile
6
- require 'bundler'
7
- Bundler.setup
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
@@ -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] }