vanity 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. data/CHANGELOG +34 -0
  2. data/Gemfile +16 -0
  3. data/MIT-LICENSE +1 -1
  4. data/README.rdoc +10 -5
  5. data/Rakefile +119 -0
  6. data/bin/vanity +23 -18
  7. data/lib/vanity.rb +12 -4
  8. data/lib/vanity/commands.rb +1 -0
  9. data/lib/vanity/commands/list.rb +21 -0
  10. data/lib/vanity/experiment/ab_test.rb +8 -1
  11. data/lib/vanity/experiment/base.rb +40 -30
  12. data/lib/vanity/frameworks/rails.rb +222 -0
  13. data/lib/vanity/metric/active_record.rb +77 -0
  14. data/lib/vanity/{metric.rb → metric/base.rb} +6 -71
  15. data/lib/vanity/metric/google_analytics.rb +76 -0
  16. data/lib/vanity/playground.rb +93 -44
  17. data/lib/vanity/templates/_metric.erb +12 -7
  18. data/lib/vanity/templates/vanity.css +1 -0
  19. data/test/ab_test_test.rb +69 -48
  20. data/test/experiment_test.rb +29 -15
  21. data/test/metric_test.rb +104 -0
  22. data/test/myapp/app/controllers/application_controller.rb +2 -0
  23. data/test/myapp/app/controllers/main_controller.rb +7 -0
  24. data/test/myapp/config/boot.rb +110 -0
  25. data/test/myapp/config/environment.rb +10 -0
  26. data/test/myapp/config/environments/production.rb +0 -0
  27. data/test/myapp/config/routes.rb +3 -0
  28. data/test/myapp/log/production.log +80 -0
  29. data/test/passenger_test.rb +34 -0
  30. data/test/rails_test.rb +129 -1
  31. data/test/test_helper.rb +12 -4
  32. data/vanity.gemspec +2 -2
  33. data/vendor/cache/RedCloth-4.2.2.gem +0 -0
  34. data/vendor/cache/actionmailer-2.3.5.gem +0 -0
  35. data/vendor/cache/actionpack-2.3.5.gem +0 -0
  36. data/vendor/cache/activerecord-2.3.5.gem +0 -0
  37. data/vendor/cache/activeresource-2.3.5.gem +0 -0
  38. data/vendor/cache/activesupport-2.3.5.gem +0 -0
  39. data/vendor/cache/autotest-4.2.7.gem +0 -0
  40. data/vendor/cache/autotest-fsevent-0.2.1.gem +0 -0
  41. data/vendor/cache/autotest-growl-0.2.0.gem +0 -0
  42. data/vendor/cache/bundler-0.9.7.gem +0 -0
  43. data/vendor/cache/classifier-1.3.1.gem +0 -0
  44. data/vendor/cache/directory_watcher-1.3.1.gem +0 -0
  45. data/vendor/cache/fastthread-1.0.7.gem +0 -0
  46. data/vendor/cache/garb-0.7.0.gem +0 -0
  47. data/vendor/cache/happymapper-0.3.0.gem +0 -0
  48. data/vendor/cache/jekyll-0.5.7.gem +0 -0
  49. data/vendor/cache/libxml-ruby-1.1.3.gem +0 -0
  50. data/vendor/cache/liquid-2.0.0.gem +0 -0
  51. data/vendor/cache/maruku-0.6.0.gem +0 -0
  52. data/vendor/cache/mocha-0.9.8.gem +0 -0
  53. data/vendor/cache/open4-1.0.1.gem +0 -0
  54. data/vendor/cache/passenger-2.2.9.gem +0 -0
  55. data/vendor/cache/rack-1.0.1.gem +0 -0
  56. data/vendor/cache/rails-2.3.5.gem +0 -0
  57. data/vendor/cache/rake-0.8.7.gem +0 -0
  58. data/vendor/cache/rubygems-update-1.3.5.gem +0 -0
  59. data/vendor/cache/shoulda-2.10.3.gem +0 -0
  60. data/vendor/cache/sqlite3-ruby-1.2.5.gem +0 -0
  61. data/vendor/cache/stemmer-1.0.1.gem +0 -0
  62. data/vendor/cache/syntax-1.0.0.gem +0 -0
  63. data/vendor/cache/sys-uname-0.8.4.gem +0 -0
  64. data/vendor/cache/timecop-0.3.4.gem +0 -0
  65. metadata +60 -11
  66. data/lib/vanity/rails.rb +0 -22
  67. data/lib/vanity/rails/dashboard.rb +0 -24
  68. data/lib/vanity/rails/helpers.rb +0 -101
  69. data/lib/vanity/rails/testing.rb +0 -11
@@ -0,0 +1,222 @@
1
+ module Vanity
2
+ module Rails #:nodoc:
3
+ # The use_vanity method will setup the controller to allow testing and
4
+ # tracking of the current user.
5
+ module UseVanity
6
+ # Defines the vanity_identity method and the set_identity_context filter.
7
+ #
8
+ # Call with the name of a method that returns an object whose identity
9
+ # will be used as the Vanity identity. Confusing? Let's try by example:
10
+ #
11
+ # class ApplicationController < ActionController::Base
12
+ # use_vanity :current_user
13
+ #
14
+ # def current_user
15
+ # User.find(session[:user_id])
16
+ # end
17
+ # end
18
+ #
19
+ # If that method (current_user in this example) returns nil, Vanity will
20
+ # set the identity for you (using a cookie to remember it across
21
+ # requests). It also uses this mechanism if you don't provide an
22
+ # identity object, by calling use_vanity with no arguments.
23
+ #
24
+ # Of course you can also use a block:
25
+ # class ProjectController < ApplicationController
26
+ # use_vanity { |controller| controller.params[:project_id] }
27
+ # end
28
+ def use_vanity(symbol = nil, &block)
29
+ if block
30
+ define_method(:vanity_identity) { block.call(self) }
31
+ else
32
+ define_method :vanity_identity do
33
+ return @vanity_identity if @vanity_identity
34
+ if symbol && object = send(symbol)
35
+ @vanity_identity = object.id
36
+ elsif response # everyday use
37
+ @vanity_identity = cookies["vanity_id"] || ActiveSupport::SecureRandom.hex(16)
38
+ cookies["vanity_id"] = { :value=>@vanity_identity, :expires=>1.month.from_now }
39
+ @vanity_identity
40
+ else # during functional testing
41
+ @vanity_identity = "test"
42
+ end
43
+ end
44
+ end
45
+ around_filter :vanity_context_filter
46
+ before_filter :vanity_reload_filter unless ::Rails.configuration.cache_classes
47
+ before_filter :vanity_query_parameter_filter
48
+ end
49
+ protected :use_vanity
50
+ end
51
+
52
+
53
+ # Vanity needs these filters. They are includes in ActionController and
54
+ # automatically added when you use #use_vanity in your controller.
55
+ module Filters
56
+ # Around filter that sets Vanity.context to controller.
57
+ def vanity_context_filter
58
+ previous, Vanity.context = Vanity.context, self
59
+ yield
60
+ ensure
61
+ Vanity.context = previous
62
+ end
63
+
64
+ # This filter allows user to choose alternative in experiment using query
65
+ # parameter.
66
+ #
67
+ # Each alternative has a unique fingerprint (run vanity list command to
68
+ # see them all). A request with the _vanity query parameter is
69
+ # intercepted, the alternative is chosen, and the user redirected to the
70
+ # same request URL sans _vanity parameter. This only works for GET
71
+ # requests.
72
+ #
73
+ # For example, if the user requests the page
74
+ # http://example.com/?_vanity=2907dac4de, the first alternative of the
75
+ # :null_abc experiment is chosen and the user redirected to
76
+ # http://example.com/.
77
+ def vanity_query_parameter_filter
78
+ if request.get? && params[:_vanity]
79
+ hashes = Array(params.delete(:_vanity))
80
+ Vanity.playground.experiments.each do |id, experiment|
81
+ if experiment.respond_to?(:alternatives)
82
+ experiment.alternatives.each do |alt|
83
+ if hash = hashes.delete(experiment.fingerprint(alt))
84
+ experiment.chooses alt.value
85
+ break
86
+ end
87
+ end
88
+ end
89
+ break if hashes.empty?
90
+ end
91
+ redirect_to url_for(params)
92
+ end
93
+ end
94
+
95
+ # Before filter to reload Vanity experiments/metrics. Enabled when
96
+ # cache_classes is false (typically, testing environment).
97
+ def vanity_reload_filter
98
+ Vanity.playground.reload!
99
+ end
100
+
101
+ protected :vanity_context_filter, :vanity_query_parameter_filter, :vanity_reload_filter
102
+ end
103
+
104
+
105
+ # Introduces ab_test helper (controllers and views). Similar to the generic
106
+ # ab_test method, with the ability to capture content (applicable to views,
107
+ # see examples).
108
+ module Helpers
109
+ # This method returns one of the alternative values in the named A/B test.
110
+ #
111
+ # @example A/B two alternatives for a page
112
+ # def index
113
+ # if ab_test(:new_page) # true/false test
114
+ # render action: "new_page"
115
+ # else
116
+ # render action: "index"
117
+ # end
118
+ # end
119
+ # @example Similar, alternative value is page name
120
+ # def index
121
+ # render action: ab_test(:new_page)
122
+ # end
123
+ # @example A/B test inside ERB template (condition)
124
+ # <%= if ab_test(:banner) %>100% less complexity!<% end %>
125
+ # @example A/B test inside ERB template (value)
126
+ # <%= ab_test(:greeting) %> <%= current_user.name %>
127
+ # @example A/B test inside ERB template (capture)
128
+ # <% ab_test :features do |count| %>
129
+ # <%= count %> features to choose from!
130
+ # <% end %>
131
+ def ab_test(name, &block)
132
+ value = Vanity.playground.experiment(name).choose
133
+ if block
134
+ content = capture(value, &block)
135
+ block_called_from_erb?(block) ? concat(content) : content
136
+ else
137
+ value
138
+ end
139
+ end
140
+ end
141
+
142
+
143
+ # Step 1: Add a new resource in config/routes.rb:
144
+ # map.vanity "/vanity/:action/:id", :controller=>:vanity
145
+ #
146
+ # Step 2: Create a new experiments controller:
147
+ # class VanityController < ApplicationController
148
+ # include Vanity::Rails::Dashboard
149
+ # end
150
+ #
151
+ # Step 3: Open your browser to http://localhost:3000/vanity
152
+ module Dashboard
153
+ def index
154
+ render :template=>Vanity.template("_report"), :content_type=>Mime::HTML, :layout=>true
155
+ end
156
+
157
+ def chooses
158
+ exp = Vanity.playground.experiment(params[:e])
159
+ exp.chooses(exp.alternatives[params[:a].to_i].value)
160
+ render :partial=>Vanity.template("experiment"), :locals=>{ :experiment=>exp }
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+
167
+ # Enhance ActionController with use_vanity, filters and helper methods.
168
+ if defined?(ActionController)
169
+ # Include in controller, add view helper methods.
170
+ ActionController::Base.class_eval do
171
+ extend Vanity::Rails::UseVanity
172
+ include Vanity::Rails::Filters
173
+ helper Vanity::Rails::Helpers
174
+ end
175
+
176
+ module ActionController
177
+ class TestCase
178
+ alias :setup_controller_request_and_response_without_vanity :setup_controller_request_and_response
179
+ # Sets Vanity.context to the current controller, so you can do things like:
180
+ # experiment(:simple).chooses(:green)
181
+ def setup_controller_request_and_response
182
+ setup_controller_request_and_response_without_vanity
183
+ Vanity.context = @controller
184
+ end
185
+ end
186
+ end
187
+ end
188
+
189
+
190
+ # Automatically configure Vanity. Uses the
191
+ if defined?(Rails)
192
+ Rails.configuration.after_initialize do
193
+ # Use Rails logger by default.
194
+ Vanity.playground.logger ||= Rails.logger
195
+ Vanity.playground.load_path = Rails.root + Vanity.playground.load_path
196
+ config_file = Rails.root + "config/redis.yml"
197
+ if !Vanity.playground.connected? && config_file.exist?
198
+ config = YAML.load_file(config_file)[Rails.env.to_s]
199
+ Vanity.playground.redis = config if config
200
+ end
201
+
202
+ # Do this at the very end of initialization, allowing test environment to do
203
+ # Vanity.playground.mock! before any database access takes place.
204
+ Rails.configuration.after_initialize do
205
+ Vanity.playground.load!
206
+ end
207
+ end
208
+ end
209
+
210
+
211
+ # Reconnect whenever we fork under Passenger.
212
+ if defined?(PhusionPassenger)
213
+ PhusionPassenger.on_event(:starting_worker_process) do |forked|
214
+ if forked
215
+ begin
216
+ Vanity.playground.reconnect!
217
+ rescue Exception=>ex
218
+ Rails.logger.error "Error reconnecting Redis: #{ex.to_s}"
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,77 @@
1
+ module Vanity
2
+ class Metric
3
+
4
+ AGGREGATES = [:average, :minimum, :maximum, :sum]
5
+
6
+ # Use an ActiveRecord model to get metric data from database table. Also
7
+ # forwards +after_create+ callbacks to hooks (updating experiments).
8
+ #
9
+ # Supported options:
10
+ # :conditions -- Only select records that match this condition
11
+ # :average -- Metric value is average of this column
12
+ # :minimum -- Metric value is minimum of this column
13
+ # :maximum -- Metric value is maximum of this column
14
+ # :sum -- Metric value is sum of this column
15
+ # :timestamp -- Use this column to filter/group records (defaults to
16
+ # +created_at+)
17
+ #
18
+ # @example Track sign ups using User model
19
+ # metric "Signups" do
20
+ # model Account
21
+ # end
22
+ # @example Track satisfaction using Survey model
23
+ # metric "Satisfaction" do
24
+ # model Survey, :average=>:rating
25
+ # end
26
+ # @example Track only high ratings
27
+ # metric "High ratings" do
28
+ # model Rating, :conditions=>["stars >= 4"]
29
+ # end
30
+ # @example Track only high ratings (using scope)
31
+ # metric "High ratings" do
32
+ # model Rating.high
33
+ # end
34
+ #
35
+ # @since 1.2.0
36
+ # @see Vanity::Metric::ActiveRecord
37
+ def model(class_or_scope, options = nil)
38
+ options = options || {}
39
+ conditions = options.delete(:conditions)
40
+ @ar_scoped = conditions ? class_or_scope.scoped(:conditions=>conditions) : class_or_scope
41
+ @ar_aggregate = AGGREGATES.find { |key| options.has_key?(key) }
42
+ @ar_column = options.delete(@ar_aggregate)
43
+ fail "Cannot use multiple aggregates in a single metric" if AGGREGATES.find { |key| options.has_key?(key) }
44
+ @ar_timestamp = options.delete(:timestamp) || :created_at
45
+ fail "Unrecognized options: #{options.keys * ", "}" unless options.empty?
46
+ @ar_scoped.after_create self
47
+ extend ActiveRecord
48
+ end
49
+
50
+ # Calling model method on a metric extends it with these modules, redefining
51
+ # the values and track! methods.
52
+ #
53
+ # @since 1.3.0
54
+ module ActiveRecord
55
+
56
+ # This values method queries the database.
57
+ def values(sdate, edate)
58
+ query = { :conditions=>{ @ar_timestamp=>(sdate.to_time...(edate + 1).to_time) },
59
+ :group=>"date(#{@ar_scoped.connection.quote_column_name @ar_timestamp})" }
60
+ grouped = @ar_column ? @ar_scoped.calculate(@ar_aggregate, @ar_column, query) : @ar_scoped.count(query)
61
+ (sdate..edate).inject([]) { |ordered, date| ordered << (grouped[date.to_s] || 0) }
62
+ end
63
+
64
+ # This track! method stores nothing, but calls the hooks.
65
+ def track!(*args)
66
+ count = args.first || 1
67
+ call_hooks Time.now, count if count > 0
68
+ end
69
+
70
+ # AR model after_create callback notifies all the hooks.
71
+ def after_create(record)
72
+ count = @ar_column ? (record.send(@ar_column) || 0) : 1
73
+ call_hooks record.send(@ar_timestamp), count if count > 0 && @ar_scoped.exists?(record)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -25,15 +25,14 @@ module Vanity
25
25
 
26
26
  # Defines a new metric, using the class Vanity::Metric.
27
27
  def metric(name, &block)
28
- id = File.basename(caller.first.split(":").first, ".rb").downcase.gsub(/\W/, "_").to_sym
29
- fail "Metric #{id} already defined in playground" if playground.metrics[id]
30
- metric = Metric.new(playground, name.to_s, id)
28
+ fail "Metric #{@metric_id} already defined in playground" if playground.metrics[@metric_id]
29
+ metric = Metric.new(playground, name.to_s, @metric_id)
31
30
  metric.instance_eval &block
32
- playground.metrics[id] = metric
31
+ playground.metrics[@metric_id] = metric
33
32
  end
34
33
 
35
- def binding_with(playground)
36
- @playground = playground
34
+ def new_binding(playground, id)
35
+ @playground, @metric_id = playground, id
37
36
  binding
38
37
  end
39
38
 
@@ -101,7 +100,7 @@ module Vanity
101
100
  context = Object.new
102
101
  context.instance_eval do
103
102
  extend Definition
104
- metric = eval(source, context.binding_with(playground), file)
103
+ metric = eval(source, context.new_binding(playground, id), file)
105
104
  fail NameError.new("Expected #{file} to define metric #{id}", id) unless playground.metrics[id]
106
105
  metric
107
106
  end
@@ -197,70 +196,6 @@ module Vanity
197
196
  end
198
197
 
199
198
 
200
- # -- ActiveRecord support --
201
-
202
- AGGREGATES = [:average, :minimum, :maximum, :sum]
203
-
204
- # Use an ActiveRecord model to get metric data from database table. Also
205
- # forwards @after_create@ callbacks to hooks (updating experiments).
206
- #
207
- # Supported options:
208
- # :conditions -- Only select records that match this condition
209
- # :average -- Metric value is average of this column
210
- # :minimum -- Metric value is minimum of this column
211
- # :maximum -- Metric value is maximum of this column
212
- # :sum -- Metric value is sum of this column
213
- # :timestamp -- Use this column to filter/group records (defaults to
214
- # +created_at+)
215
- #
216
- # @example Track sign ups using User model
217
- # metric "Signups" do
218
- # model Account
219
- # end
220
- # @example Track satisfaction using Survey model
221
- # metric "Satisfaction" do
222
- # model Survey, :average=>:rating
223
- # end
224
- # @example Track only high ratings
225
- # metric "High ratings" do
226
- # model Rating, :conditions=>["stars >= 4"]
227
- # end
228
- # @example Track only high ratings (using scope)
229
- # metric "High ratings" do
230
- # model Rating.high
231
- # end
232
- #
233
- # @since 1.2.0
234
- def model(class_or_scope, options = nil)
235
- options = (options || {}).clone
236
- conditions = options.delete(:conditions)
237
- scoped = conditions ? class_or_scope.scoped(:conditions=>conditions) : class_or_scope
238
- aggregate = AGGREGATES.find { |key| options.has_key?(key) }
239
- column = options.delete(aggregate)
240
- fail "Cannot use multiple aggregates in a single metric" if AGGREGATES.find { |key| options.has_key?(key) }
241
- timestamp = options.delete(:timestamp) || :created_at
242
- fail "Unrecognized options: #{options.keys * ", "}" unless options.empty?
243
-
244
- # Hook into model's after_create
245
- scoped.after_create do |record|
246
- count = column ? (record.send(column) || 0) : 1
247
- call_hooks record.send(timestamp), count if count > 0 && scoped.exists?(record)
248
- end
249
- # Redefine values method to perform query
250
- eigenclass = class << self ; self ; end
251
- eigenclass.send :define_method, :values do |sdate, edate|
252
- query = { :conditions=>{ timestamp=>(sdate.to_time...(edate + 1).to_time) }, :group=>"date(#{scoped.connection.quote_column_name timestamp})" }
253
- grouped = column ? scoped.calculate(aggregate, column, query) : scoped.count(query)
254
- (sdate..edate).inject([]) { |ordered, date| ordered << (grouped[date.to_s] || 0) }
255
- end
256
- # Redefine track! method to call on hooks
257
- eigenclass.send :define_method, :track! do |*args|
258
- count = args.first || 1
259
- call_hooks Time.now, count if count > 0
260
- end
261
- end
262
-
263
-
264
199
  # -- Storage --
265
200
 
266
201
  def destroy!
@@ -0,0 +1,76 @@
1
+ module Vanity
2
+ class Metric
3
+
4
+ # Use Google Analytics metric. Note: you must +require "garb"+ before
5
+ # vanity.
6
+ #
7
+ # @example Page views
8
+ # metric "Page views" do
9
+ # google_analytics "UA-1828623-6"
10
+ # end
11
+ # @example Visits
12
+ # metric "Visits" do
13
+ # google_analytics "UA-1828623-6", :visits
14
+ # end
15
+ #
16
+ # @since 1.3.0
17
+ # @see Vanity::Metric::GoogleAnalytics
18
+ def google_analytics(web_property_id, *args)
19
+ require "garb"
20
+ options = Hash === args.last ? args.pop : {}
21
+ metric = args.shift || :pageviews
22
+ @ga_resource = Vanity::Metric::GoogleAnalytics::Resource.new(web_property_id, metric)
23
+ @ga_mapper = options[:mapper] ||= lambda { |entry| entry.send(@ga_resource.metrics.elements.first).to_i }
24
+ extend GoogleAnalytics
25
+ rescue LoadError
26
+ fail LoadError, "Google Analytics metrics require Garb, please gem install garb first"
27
+ end
28
+
29
+ # Calling google_analytics method on a metric extends it with these modules,
30
+ # redefining the values and hook methods.
31
+ #
32
+ # @since 1.3.0
33
+ module GoogleAnalytics
34
+
35
+ # Returns values from GA using parameters specified by prior call to
36
+ # google_analytics.
37
+ def values(from, to)
38
+ data = @ga_resource.results(from, to).inject({}) do |hash,entry|
39
+ hash.merge(entry.date=>@ga_mapper.call(entry))
40
+ end
41
+ (from..to).map { |day| data[day.strftime('%Y%m%d')] || 0 }
42
+ end
43
+
44
+ # Hooks not supported for GA metrics.
45
+ def hook
46
+ fail "Cannot use hooks with Google Analytics methods"
47
+ end
48
+
49
+ # Garb report.
50
+ def report
51
+ @ga_resource
52
+ end
53
+
54
+ class Resource
55
+ # GA profile used for this report. Populated after calling results.
56
+ attr_reader :profile
57
+
58
+ def initialize(web_property_id, metric)
59
+ self.class.send :include, Garb::Resource
60
+ @web_property_id = web_property_id
61
+ metrics metric
62
+ dimensions :date
63
+ sort :date
64
+ end
65
+
66
+ def results(start_date, end_date)
67
+ @profile = Garb::Profile.all.find { |p| p.web_property_id == @web_property_id }
68
+ @start_date = start_date
69
+ @end_date = end_date
70
+ Garb::ReportResponse.new(send_request_for_body).results
71
+ end
72
+ end
73
+
74
+ end
75
+ end
76
+ end