mikeg-vanity 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 (66) hide show
  1. data/CHANGELOG +153 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.rdoc +83 -0
  4. data/bin/vanity +53 -0
  5. data/lib/vanity.rb +38 -0
  6. data/lib/vanity/backport.rb +43 -0
  7. data/lib/vanity/commands.rb +2 -0
  8. data/lib/vanity/commands/list.rb +21 -0
  9. data/lib/vanity/commands/report.rb +60 -0
  10. data/lib/vanity/experiment/ab_test.rb +477 -0
  11. data/lib/vanity/experiment/base.rb +212 -0
  12. data/lib/vanity/helpers.rb +59 -0
  13. data/lib/vanity/metric/active_record.rb +77 -0
  14. data/lib/vanity/metric/base.rb +221 -0
  15. data/lib/vanity/metric/google_analytics.rb +70 -0
  16. data/lib/vanity/mock_redis.rb +76 -0
  17. data/lib/vanity/playground.rb +197 -0
  18. data/lib/vanity/rails.rb +22 -0
  19. data/lib/vanity/rails/dashboard.rb +24 -0
  20. data/lib/vanity/rails/helpers.rb +158 -0
  21. data/lib/vanity/rails/testing.rb +11 -0
  22. data/lib/vanity/templates/_ab_test.erb +26 -0
  23. data/lib/vanity/templates/_experiment.erb +5 -0
  24. data/lib/vanity/templates/_experiments.erb +7 -0
  25. data/lib/vanity/templates/_metric.erb +14 -0
  26. data/lib/vanity/templates/_metrics.erb +13 -0
  27. data/lib/vanity/templates/_report.erb +27 -0
  28. data/lib/vanity/templates/flot.min.js +1 -0
  29. data/lib/vanity/templates/jquery.min.js +19 -0
  30. data/lib/vanity/templates/vanity.css +26 -0
  31. data/lib/vanity/templates/vanity.js +82 -0
  32. data/test/ab_test_test.rb +656 -0
  33. data/test/experiment_test.rb +136 -0
  34. data/test/experiments/age_and_zipcode.rb +19 -0
  35. data/test/experiments/metrics/cheers.rb +3 -0
  36. data/test/experiments/metrics/signups.rb +2 -0
  37. data/test/experiments/metrics/yawns.rb +3 -0
  38. data/test/experiments/null_abc.rb +5 -0
  39. data/test/metric_test.rb +518 -0
  40. data/test/playground_test.rb +10 -0
  41. data/test/rails_test.rb +104 -0
  42. data/test/test_helper.rb +135 -0
  43. data/vanity.gemspec +18 -0
  44. data/vendor/redis-rb/LICENSE +20 -0
  45. data/vendor/redis-rb/README.markdown +36 -0
  46. data/vendor/redis-rb/Rakefile +62 -0
  47. data/vendor/redis-rb/bench.rb +44 -0
  48. data/vendor/redis-rb/benchmarking/suite.rb +24 -0
  49. data/vendor/redis-rb/benchmarking/worker.rb +71 -0
  50. data/vendor/redis-rb/bin/distredis +33 -0
  51. data/vendor/redis-rb/examples/basic.rb +16 -0
  52. data/vendor/redis-rb/examples/incr-decr.rb +18 -0
  53. data/vendor/redis-rb/examples/list.rb +26 -0
  54. data/vendor/redis-rb/examples/sets.rb +36 -0
  55. data/vendor/redis-rb/lib/dist_redis.rb +124 -0
  56. data/vendor/redis-rb/lib/hash_ring.rb +128 -0
  57. data/vendor/redis-rb/lib/pipeline.rb +21 -0
  58. data/vendor/redis-rb/lib/redis.rb +370 -0
  59. data/vendor/redis-rb/lib/redis/raketasks.rb +1 -0
  60. data/vendor/redis-rb/profile.rb +22 -0
  61. data/vendor/redis-rb/redis-rb.gemspec +30 -0
  62. data/vendor/redis-rb/spec/redis_spec.rb +637 -0
  63. data/vendor/redis-rb/spec/spec_helper.rb +4 -0
  64. data/vendor/redis-rb/speed.rb +16 -0
  65. data/vendor/redis-rb/tasks/redis.tasks.rb +140 -0
  66. metadata +125 -0
@@ -0,0 +1,212 @@
1
+ module Vanity
2
+ module Experiment
3
+
4
+ # These methods are available from experiment definitions (files located in
5
+ # the experiments directory, automatically loaded by Vanity). Use these
6
+ # methods to define you experiments, for example:
7
+ # ab_test "New Banner" do
8
+ # alternatives :red, :green, :blue
9
+ # metrics :signup
10
+ # end
11
+ module Definition
12
+
13
+ attr_reader :playground
14
+
15
+ # Defines a new experiment, given the experiment's name, type and
16
+ # definition block.
17
+ def define(name, type, options = nil, &block)
18
+ fail "Experiment #{@experiment_id} already defined in playground" if playground.experiments[@experiment_id]
19
+ klass = Experiment.const_get(type.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase })
20
+ experiment = klass.new(playground, @experiment_id, name, options)
21
+ experiment.instance_eval &block
22
+ experiment.save
23
+ playground.experiments[@experiment_id] = experiment
24
+ end
25
+
26
+ def new_binding(playground, id)
27
+ @playground, @experiment_id = playground, id
28
+ binding
29
+ end
30
+
31
+ end
32
+
33
+ # Base class that all experiment types are derived from.
34
+ class Base
35
+
36
+ class << self
37
+
38
+ # Returns the type of this class as a symbol (e.g. AbTest becomes
39
+ # ab_test).
40
+ def type
41
+ name.split("::").last.gsub(/([a-z])([A-Z])/) { "#{$1}_#{$2}" }.gsub(/([A-Z])([A-Z][a-z])/) { "#{$1}_#{$2}" }.downcase
42
+ end
43
+
44
+ # Playground uses this to load experiment definitions.
45
+ def load(playground, stack, file)
46
+ fail "Circular dependency detected: #{stack.join('=>')}=>#{file}" if stack.include?(file)
47
+ source = File.read(file)
48
+ stack.push file
49
+ id = File.basename(file, ".rb").downcase.gsub(/\W/, "_").to_sym
50
+ context = Object.new
51
+ context.instance_eval do
52
+ extend Definition
53
+ experiment = eval(source, context.new_binding(playground, id), file)
54
+ fail NameError.new("Expected #{file} to define experiment #{id}", id) unless playground.experiments[id]
55
+ experiment
56
+ end
57
+ rescue
58
+ error = NameError.exception($!.message, id)
59
+ error.set_backtrace $!.backtrace
60
+ raise error
61
+ ensure
62
+ stack.pop
63
+ end
64
+
65
+ end
66
+
67
+ def initialize(playground, id, name, options = nil)
68
+ @playground = playground
69
+ @id, @name = id.to_sym, name
70
+ @options = options || {}
71
+ @namespace = "#{@playground.namespace}:#{@id}"
72
+ @identify_block = method(:default_identify)
73
+ end
74
+
75
+ # Human readable experiment name (first argument you pass when creating a
76
+ # new experiment).
77
+ attr_reader :name
78
+ alias :to_s :name
79
+
80
+ # Unique identifier, derived from name experiment name, e.g. "Green
81
+ # Button" becomes :green_button.
82
+ attr_reader :id
83
+
84
+ # Time stamp when experiment was created.
85
+ attr_reader :created_at
86
+
87
+ # Time stamp when experiment was completed.
88
+ attr_reader :completed_at
89
+
90
+ # Returns the type of this experiment as a symbol (e.g. :ab_test).
91
+ def type
92
+ self.class.type
93
+ end
94
+
95
+ # Defines how we obtain an identity for the current experiment. Usually
96
+ # Vanity gets the identity form a session object (see use_vanity), but
97
+ # there are cases where you want a particular experiment to use a
98
+ # different identity.
99
+ #
100
+ # For example, if all your experiments use current_user and you need one
101
+ # experiment to use the current project:
102
+ # ab_test "Project widget" do
103
+ # alternatives :small, :medium, :large
104
+ # identify do |controller|
105
+ # controller.project.id
106
+ # end
107
+ # end
108
+ def identify(&block)
109
+ @identify_block = block
110
+ end
111
+
112
+
113
+ # -- Reporting --
114
+
115
+ # Sets or returns description. For example
116
+ # ab_test "Simple" do
117
+ # description "A simple A/B experiment"
118
+ # end
119
+ #
120
+ # puts "Just defined: " + experiment(:simple).description
121
+ def description(text = nil)
122
+ @description = text if text
123
+ @description
124
+ end
125
+
126
+
127
+ # -- Experiment completion --
128
+
129
+ # Define experiment completion condition. For example:
130
+ # complete_if do
131
+ # !score(95).chosen.nil?
132
+ # end
133
+ def complete_if(&block)
134
+ raise ArgumentError, "Missing block" unless block
135
+ raise "complete_if already called on this experiment" if @complete_block
136
+ @complete_block = block
137
+ end
138
+
139
+ # Force experiment to complete.
140
+ def complete!
141
+ redis.setnx key(:completed_at), Time.now.to_i
142
+ @completed_at = redis[key(:completed_at)]
143
+ @playground.logger.info "vanity: completed experiment #{id}"
144
+ end
145
+
146
+ # Time stamp when experiment was completed.
147
+ def completed_at
148
+ @completed_at ||= redis[key(:completed_at)]
149
+ @completed_at && Time.at(@completed_at.to_i)
150
+ end
151
+
152
+ # Returns true if experiment active, false if completed.
153
+ def active?
154
+ !redis.exists(key(:completed_at))
155
+ end
156
+
157
+ # -- Store/validate --
158
+
159
+ # Get rid of all experiment data.
160
+ def destroy
161
+ redis.del key(:created_at)
162
+ redis.del key(:completed_at)
163
+ @created_at = @completed_at = nil
164
+ end
165
+
166
+ # Called by Playground to save the experiment definition.
167
+ def save
168
+ redis.setnx key(:created_at), Time.now.to_i
169
+ @created_at = Time.at(redis[key(:created_at)].to_i)
170
+ end
171
+
172
+ protected
173
+
174
+ def identity
175
+ @identify_block.call(Vanity.context)
176
+ end
177
+
178
+ def default_identify(context)
179
+ raise "No Vanity.context" unless context
180
+ raise "Vanity.context does not respond to vanity_identity" unless context.respond_to?(:vanity_identity)
181
+ context.vanity_identity or raise "Vanity.context.vanity_identity - no identity"
182
+ end
183
+
184
+ # Derived classes call this after state changes that may lead to
185
+ # experiment completing.
186
+ def check_completion!
187
+ if @complete_block
188
+ begin
189
+ complete! if @complete_block.call
190
+ rescue
191
+ # TODO: logging
192
+ end
193
+ end
194
+ end
195
+
196
+ # Returns key for this experiment, or with an argument, return a key
197
+ # using the experiment as the namespace. Examples:
198
+ # key => "vanity:experiments:green_button"
199
+ # key("participants") => "vanity:experiments:green_button:participants"
200
+ def key(name = nil)
201
+ name ? "#{@namespace}:#{name}" : @namespace
202
+ end
203
+
204
+ # Shortcut for Vanity.playground.redis
205
+ def redis
206
+ @playground.redis
207
+ end
208
+
209
+ end
210
+ end
211
+ end
212
+
@@ -0,0 +1,59 @@
1
+ module Vanity
2
+ # Helper methods available on Object.
3
+ #
4
+ # @example From ERB template
5
+ # <%= ab_test(:greeting) %> <%= current_user.name %>
6
+ # @example From Rails controller
7
+ # class AccountController < ApplicationController
8
+ # def create
9
+ # track! :signup
10
+ # Acccount.create! params[:account]
11
+ # end
12
+ # end
13
+ # @example From ActiveRecord
14
+ # class Posts < ActiveRecord::Base
15
+ # after_create do |post|
16
+ # track! :images if post.type == :image
17
+ # end
18
+ # end
19
+ module Helpers
20
+
21
+ # This method returns one of the alternative values in the named A/B test.
22
+ #
23
+ # @example A/B two alternatives for a page
24
+ # def index
25
+ # if ab_test(:new_page) # true/false test
26
+ # render action: "new_page"
27
+ # else
28
+ # render action: "index"
29
+ # end
30
+ # end
31
+ # @example Similar, alternative value is page name
32
+ # def index
33
+ # render action: ab_test(:new_page)
34
+ # end
35
+ # @since 1.2.0
36
+ def ab_test(name, &block)
37
+ value = Vanity.playground.experiment(name).choose
38
+ if block
39
+ content = capture(value, &block)
40
+ block_called_from_erb?(block) ? concat(content) : content
41
+ else
42
+ value
43
+ end
44
+ end
45
+
46
+ # Tracks an action associated with a metric.
47
+ #
48
+ # @example
49
+ # track! :invitation
50
+ # @since 1.2.0
51
+ def track!(name, count = 1)
52
+ Vanity.playground.track! name, count
53
+ end
54
+ end
55
+ end
56
+
57
+ Object.class_eval do
58
+ include Vanity::Helpers
59
+ 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
@@ -0,0 +1,221 @@
1
+ module Vanity
2
+
3
+ # A metric is an object that implements two methods: +name+ and +values+. It
4
+ # can also respond to addition methods (+track!+, +bounds+, etc), these are
5
+ # optional.
6
+ #
7
+ # This class implements a basic metric that tracks data and stores it in
8
+ # Redis. You can use this as the basis for your metric, or as reference for
9
+ # the methods your metric must and can implement.
10
+ #
11
+ # @since 1.1.0
12
+ class Metric
13
+
14
+ # These methods are available when defining a metric in a file loaded
15
+ # from the +experiments/metrics+ directory.
16
+ #
17
+ # For example:
18
+ # $ cat experiments/metrics/yawn_sec
19
+ # metric "Yawns/sec" do
20
+ # description "Most boring metric ever"
21
+ # end
22
+ module Definition
23
+
24
+ attr_reader :playground
25
+
26
+ # Defines a new metric, using the class Vanity::Metric.
27
+ def metric(name, &block)
28
+ fail "Metric #{@metric_id} already defined in playground" if playground.metrics[@metric_id]
29
+ metric = Metric.new(playground, name.to_s, @metric_id)
30
+ metric.instance_eval &block
31
+ playground.metrics[@metric_id] = metric
32
+ end
33
+
34
+ def new_binding(playground, id)
35
+ @playground, @metric_id = playground, id
36
+ binding
37
+ end
38
+
39
+ end
40
+
41
+ # Startup metrics for pirates. AARRR stands for:
42
+ # * Acquisition
43
+ # * Activation
44
+ # * Retention
45
+ # * Referral
46
+ # * Revenue
47
+ # Read more: http://500hats.typepad.com/500blogs/2007/09/startup-metrics.html
48
+
49
+ class << self
50
+
51
+ # Helper method to return description for a metric.
52
+ #
53
+ # A metric object may have a +description+ method that returns a detailed
54
+ # description. It may also have no description, or no +description+
55
+ # method, in which case return +nil+.
56
+ #
57
+ # @example
58
+ # puts Vanity::Metric.description(metric)
59
+ def description(metric)
60
+ metric.description if metric.respond_to?(:description)
61
+ end
62
+
63
+ # Helper method to return bounds for a metric.
64
+ #
65
+ # A metric object may have a +bounds+ method that returns lower and upper
66
+ # bounds. It may also have no bounds, or no +bounds+ # method, in which
67
+ # case we return +[nil, nil]+.
68
+ #
69
+ # @example
70
+ # upper = Vanity::Metric.bounds(metric).last
71
+ def bounds(metric)
72
+ metric.respond_to?(:bounds) && metric.bounds || [nil, nil]
73
+ end
74
+
75
+ # Returns data set for a given date range. The data set is an array of
76
+ # date, value pairs.
77
+ #
78
+ # First argument is the metric. Second argument is the start date, or
79
+ # number of days to go back in history, defaults to 90 days. Third
80
+ # argument is end date, defaults to today.
81
+ #
82
+ # @example These are all equivalent:
83
+ # Vanity::Metric.data(my_metric)
84
+ # Vanity::Metric.data(my_metric, 90)
85
+ # Vanity::Metric.data(my_metric, Date.today - 89)
86
+ # Vanity::Metric.data(my_metric, Date.today - 89, Date.today)
87
+ def data(metric, *args)
88
+ first = args.shift || 90
89
+ to = args.shift || Date.today
90
+ from = first.respond_to?(:to_date) ? first.to_date : to - (first - 1)
91
+ (from..to).zip(metric.values(from, to))
92
+ end
93
+
94
+ # Playground uses this to load metric definitions.
95
+ def load(playground, stack, file)
96
+ fail "Circular dependency detected: #{stack.join('=>')}=>#{file}" if stack.include?(file)
97
+ source = File.read(file)
98
+ stack.push file
99
+ id = File.basename(file, ".rb").downcase.gsub(/\W/, "_").to_sym
100
+ context = Object.new
101
+ context.instance_eval do
102
+ extend Definition
103
+ metric = eval(source, context.new_binding(playground, id), file)
104
+ fail NameError.new("Expected #{file} to define metric #{id}", id) unless playground.metrics[id]
105
+ metric
106
+ end
107
+ rescue
108
+ error = NameError.exception($!.message, id)
109
+ error.set_backtrace $!.backtrace
110
+ raise error
111
+ ensure
112
+ stack.pop
113
+ end
114
+
115
+ end
116
+
117
+
118
+ # Takes playground (need this to access Redis), friendly name and optional
119
+ # id (can infer from name).
120
+ def initialize(playground, name, id = nil)
121
+ @playground, @name = playground, name.to_s
122
+ @id = (id || name.to_s.downcase.gsub(/\W+/, '_')).to_sym
123
+ @hooks = []
124
+ redis.setnx key(:created_at), Time.now.to_i
125
+ @created_at = Time.at(redis[key(:created_at)].to_i)
126
+ end
127
+
128
+
129
+ # -- Tracking --
130
+
131
+ # Called to track an action associated with this metric.
132
+ def track!(count = 1)
133
+ count ||= 1
134
+ if count > 0
135
+ timestamp = Time.now
136
+ redis.incrby key(timestamp.to_date, "count"), count
137
+ @playground.logger.info "vanity: #{@id} with count #{count}"
138
+ call_hooks timestamp, count
139
+ end
140
+ end
141
+
142
+ # Metric definitions use this to introduce tracking hook. The hook is
143
+ # called with metric identifier, timestamp, count and possibly additional
144
+ # arguments.
145
+ #
146
+ # For example:
147
+ # hook do |metric_id, timestamp, count|
148
+ # syslog.info metric_id
149
+ # end
150
+ def hook(&block)
151
+ @hooks << block
152
+ end
153
+
154
+ # This method returns the acceptable bounds of a metric as an array with
155
+ # two values: low and high. Use nil for unbounded.
156
+ #
157
+ # Alerts are created when metric values exceed their bounds. For example,
158
+ # a metric of user registration can use historical data to calculate
159
+ # expected range of new registration for the next day. If actual metric
160
+ # falls below the expected range, it could indicate registration process is
161
+ # broken. Going above higher bound could trigger opening a Champagne
162
+ # bottle.
163
+ #
164
+ # The default implementation returns +nil+.
165
+ def bounds
166
+ end
167
+
168
+
169
+ # -- Reporting --
170
+
171
+ # Human readable metric name. All metrics must implement this method.
172
+ attr_reader :name
173
+ alias :to_s :name
174
+
175
+ # Time stamp when metric was created.
176
+ attr_reader :created_at
177
+
178
+ # Human readable description. Use two newlines to break paragraphs.
179
+ attr_accessor :description
180
+
181
+ # Sets or returns description. For example
182
+ # metric "Yawns/sec" do
183
+ # description "Most boring metric ever"
184
+ # end
185
+ #
186
+ # puts "Just defined: " + metric(:boring).description
187
+ def description(text = nil)
188
+ @description = text if text
189
+ @description
190
+ end
191
+
192
+ # Given two arguments, a start date and an end date (inclusive), returns an
193
+ # array of measurements. All metrics must implement this method.
194
+ def values(from, to)
195
+ redis.mget((from.to_date..to.to_date).map { |date| key(date, "count") }).map(&:to_i)
196
+ end
197
+
198
+
199
+ # -- Storage --
200
+
201
+ def destroy!
202
+ redis.del redis.keys(key("*"))
203
+ end
204
+
205
+ def redis
206
+ @playground.redis
207
+ end
208
+
209
+ def key(*args)
210
+ "metrics:#{@id}:#{args.join(':')}"
211
+ end
212
+
213
+ def call_hooks(timestamp, count)
214
+ count ||= 1
215
+ @hooks.each do |hook|
216
+ hook.call @id, timestamp, count
217
+ end
218
+ end
219
+
220
+ end
221
+ end