mikeg-vanity 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
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,70 @@
1
+ module Vanity
2
+ class Metric
3
+
4
+ # Use Google Analytics metric.
5
+ #
6
+ # @example Page views
7
+ # metric "Page views" do
8
+ # google_analytics "UA-1828623-6"
9
+ # end
10
+ # @example Visits
11
+ # metric "Visits" do
12
+ # google_analytics "UA-1828623-6", :visits
13
+ # end
14
+ #
15
+ # @since 1.3.0
16
+ # @see Vanity::Metric::GoogleAnalytics
17
+ def google_analytics(web_property_id, *args)
18
+ gem "garb"
19
+ require "garb"
20
+ options = Hash === args.last ? args.pop : {}
21
+ metric = options.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 Gem::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
+ class Resource
50
+ include Garb::Resource
51
+
52
+ def initialize(web_property_id, metric)
53
+ @web_property_id = web_property_id
54
+ metrics metric
55
+ dimensions :date
56
+ sort :date
57
+ end
58
+
59
+ def results(start_date, end_date)
60
+ @profile = Garb::Profile.all.find { |p| p.web_property_id == @web_property_id }
61
+ @start_date = start_date
62
+ @end_date = end_date
63
+ Garb::ReportResponse.new(send_request_for_body).results
64
+ end
65
+
66
+ end
67
+
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,76 @@
1
+ module Vanity
2
+ # The Redis you should never use in production.
3
+ class MockRedis
4
+ @@hash = {}
5
+
6
+ def initialize(options = {})
7
+ end
8
+
9
+ def [](key)
10
+ @@hash[key]
11
+ end
12
+
13
+ def []=(key, value)
14
+ @@hash[key] = value.to_s
15
+ end
16
+
17
+ def del(*keys)
18
+ keys.flatten.each do |key|
19
+ @@hash.delete key
20
+ end
21
+ end
22
+
23
+ def setnx(key, value)
24
+ @@hash[key] = value.to_s unless @@hash.has_key?(key)
25
+ end
26
+
27
+ def incr(key)
28
+ @@hash[key] = (@@hash[key].to_i + 1).to_s
29
+ end
30
+
31
+ def incrby(key, value)
32
+ @@hash[key] = (@@hash[key].to_i + value).to_s
33
+ end
34
+
35
+ def mget(keys)
36
+ @@hash.values_at(*keys)
37
+ end
38
+
39
+ def exists(key)
40
+ @@hash.has_key?(key)
41
+ end
42
+
43
+ def keys(pattern)
44
+ regexp = Regexp.new(pattern.split("*").map { |r| Regexp.escape(r) }.join(".*"))
45
+ @@hash.keys.select { |key| key =~ regexp }
46
+ end
47
+
48
+ def flushdb
49
+ @@hash.clear
50
+ end
51
+
52
+ def sismember(key, value)
53
+ case set = @@hash[key]
54
+ when nil ; false
55
+ when Set ; set.member?(value)
56
+ else fail "Not a set"
57
+ end
58
+ end
59
+
60
+ def sadd(key, value)
61
+ case set = @@hash[key]
62
+ when nil ; @@hash[key] = Set.new([value])
63
+ when Set ; set.add value
64
+ else fail "Not a set"
65
+ end
66
+ end
67
+
68
+ def scard(key)
69
+ case set = @@hash[key]
70
+ when nil ; 0
71
+ when Set ; set.size
72
+ else fail "Not a set"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,197 @@
1
+ module Vanity
2
+
3
+ # Playground catalogs all your experiments, holds the Vanity configuration.
4
+ #
5
+ # @example
6
+ # Vanity.playground.logger = my_logger
7
+ # puts Vanity.playground.map(&:name)
8
+ class Playground
9
+
10
+ DEFAULTS = { :host=>"127.0.0.1", :port=>6379, :db=>0, :load_path=>"experiments" }
11
+
12
+ # Created new Playground. Unless you need to, use the global Vanity.playground.
13
+ def initialize(options = {})
14
+ @host, @port, @db, @load_path = DEFAULTS.merge(options).values_at(:host, :port, :db, :load_path)
15
+ @namespace = "vanity:#{Vanity::Version::MAJOR}"
16
+ @logger = options[:logger]
17
+ unless @logger
18
+ @logger = Logger.new(STDOUT)
19
+ @logger.level = Logger::ERROR
20
+ end
21
+ @redis = options[:redis]
22
+ @loading = []
23
+ end
24
+
25
+ # Redis host name. Default is 127.0.0.1
26
+ attr_accessor :host
27
+
28
+ # Redis port number. Default is 6379.
29
+ attr_accessor :port
30
+
31
+ # Redis database number. Default is 0.
32
+ attr_accessor :db
33
+
34
+ # Redis database password.
35
+ attr_accessor :password
36
+
37
+ # Namespace for database keys. Default is vanity:n, where n is the major release number, e.g. vanity:1 for 1.0.3.
38
+ attr_accessor :namespace
39
+
40
+ # Path to load experiment files from.
41
+ attr_accessor :load_path
42
+
43
+ # Logger.
44
+ attr_accessor :logger
45
+
46
+ # Defines a new experiment. Generally, do not call this directly,
47
+ # use one of the definition methods (ab_test, measure, etc).
48
+ #
49
+ # @see Vanity::Experiment
50
+ def define(name, type, options = {}, &block)
51
+ warn "Deprecated: if you need this functionality let's make a better API"
52
+ id = name.to_s.downcase.gsub(/\W/, "_").to_sym
53
+ raise "Experiment #{id} already defined once" if experiments[id]
54
+ klass = Experiment.const_get(type.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase })
55
+ experiment = klass.new(self, id, name, options)
56
+ experiment.instance_eval &block
57
+ experiment.save
58
+ experiments[id] = experiment
59
+ end
60
+
61
+ # Returns the experiment. You may not have guessed, but this method raises
62
+ # an exception if it cannot load the experiment's definition.
63
+ #
64
+ # @see Vanity::Experiment
65
+ def experiment(name)
66
+ id = name.to_s.downcase.gsub(/\W/, "_").to_sym
67
+ warn "Deprecated: pleae call experiment method with experiment identifier (a Ruby symbol)" unless id == name
68
+ experiments[id.to_sym] or raise NameError, "No experiment #{id}"
69
+ end
70
+
71
+ # Returns hash of experiments (key is experiment id).
72
+ #
73
+ # @see Vanity::Experiment
74
+ def experiments
75
+ unless @experiments
76
+ @experiments = {}
77
+ @logger.info "Vanity: loading experiments from #{load_path}"
78
+ Dir[File.join(load_path, "*.rb")].each do |file|
79
+ Experiment::Base.load self, @loading, file
80
+ end
81
+ end
82
+ @experiments
83
+ end
84
+
85
+ # Reloads all metrics and experiments. Rails calls this for each request in
86
+ # development mode.
87
+ def reload!
88
+ @experiments = nil
89
+ @metrics = nil
90
+ load!
91
+ end
92
+
93
+ # Loads all metrics and experiments. Rails calls this during
94
+ # initialization.
95
+ def load!
96
+ experiments
97
+ metrics
98
+ end
99
+
100
+ # Use this instance to access the Redis database.
101
+ def redis
102
+ @redis ||= Redis.new(:host=>self.host, :port=>self.port, :db=>self.db,
103
+ :password=>self.password, :logger=>self.logger)
104
+ class << self ; self ; end.send(:define_method, :redis) { @redis }
105
+ @redis
106
+ end
107
+
108
+ # Switches playground to use MockRedis instead of a live server.
109
+ # Particularly useful for testing, e.g. if you can't access Redis on your CI
110
+ # server. This method has no affect after playground accesses live Redis
111
+ # server.
112
+ #
113
+ # @example Put this in config/environments/test.rb
114
+ # config.after_initialize { Vanity.playground.mock! }
115
+ def mock!
116
+ @redis ||= MockRedis.new
117
+ end
118
+
119
+ # Returns a metric (raises NameError if no metric with that identifier).
120
+ #
121
+ # @see Vanity::Metric
122
+ # @since 1.1.0
123
+ def metric(id)
124
+ metrics[id.to_sym] or raise NameError, "No metric #{id}"
125
+ end
126
+
127
+ # Returns hash of metrics (key is metric id).
128
+ #
129
+ # @see Vanity::Metric
130
+ # @since 1.1.0
131
+ def metrics
132
+ unless @metrics
133
+ @metrics = {}
134
+ @logger.info "Vanity: loading metrics from #{load_path}/metrics"
135
+ Dir[File.join(load_path, "metrics/*.rb")].each do |file|
136
+ Metric.load self, @loading, file
137
+ end
138
+ end
139
+ @metrics
140
+ end
141
+
142
+ # Tracks an action associated with a metric.
143
+ #
144
+ # @example
145
+ # Vanity.playground.track! :uploaded_video
146
+ #
147
+ # @since 1.1.0
148
+ def track!(id, count = 1)
149
+ metric(id).track! count
150
+ end
151
+ end
152
+
153
+ @playground = Playground.new
154
+ class << self
155
+
156
+ # The playground instance.
157
+ #
158
+ # @see Vanity::Playground
159
+ attr_accessor :playground
160
+
161
+ # Returns the Vanity context. For example, when using Rails this would be
162
+ # the current controller, which can be used to get/set the vanity identity.
163
+ def context
164
+ Thread.current[:vanity_context]
165
+ end
166
+
167
+ # Sets the Vanity context. For example, when using Rails this would be
168
+ # set by the set_vanity_context before filter (via Vanity::Rails#use_vanity).
169
+ def context=(context)
170
+ Thread.current[:vanity_context] = context
171
+ end
172
+
173
+ # Path to template.
174
+ def template(name)
175
+ path = File.join(File.dirname(__FILE__), "templates/#{name}")
176
+ path << ".erb" unless name["."]
177
+ path
178
+ end
179
+
180
+ end
181
+ end
182
+
183
+
184
+ class Object
185
+
186
+ # Use this method to access an experiment by name.
187
+ #
188
+ # @example
189
+ # puts experiment(:text_size).alternatives
190
+ #
191
+ # @see Vanity::Playground#experiment
192
+ # @deprecated
193
+ def experiment(name)
194
+ warn "Deprecated. Please call Vanity.playground.experiment directly."
195
+ Vanity.playground.experiment(name)
196
+ end
197
+ end
@@ -0,0 +1,22 @@
1
+ require "vanity/rails/helpers"
2
+ require "vanity/rails/testing"
3
+ require "vanity/rails/dashboard"
4
+
5
+ # Include in controller, add view helper methods.
6
+ ActionController::Base.class_eval do
7
+ extend Vanity::Rails::UseVanity
8
+ include Vanity::Rails::Filters
9
+ helper Vanity::Rails::Helpers
10
+ end
11
+
12
+ Rails.configuration.after_initialize do
13
+ # Use Rails logger by default.
14
+ Vanity.playground.logger ||= ActionController::Base.logger
15
+ Vanity.playground.load_path = "#{RAILS_ROOT}/experiments"
16
+
17
+ # Do this at the very end of initialization, allowing test environment to do
18
+ # Vanity.playground.mock! before any database access takes place.
19
+ Rails.configuration.after_initialize do
20
+ Vanity.playground.load!
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ module Vanity
2
+ module Rails
3
+ # Step 1: Add a new resource in config/routes.rb:
4
+ # map.vanity "/vanity/:action/:id", :controller=>:vanity
5
+ #
6
+ # Step 2: Create a new experiments controller:
7
+ # class VanityController < ApplicationController
8
+ # include Vanity::Rails::Dashboard
9
+ # end
10
+ #
11
+ # Step 3: Open your browser to http://localhost:3000/vanity
12
+ module Dashboard
13
+ def index
14
+ render Vanity.template("_report"), :content_type=>Mime::HTML, :layout=>true
15
+ end
16
+
17
+ def chooses
18
+ exp = Vanity.playground.experiment(params[:e])
19
+ exp.chooses(exp.alternatives[params[:a].to_i].value)
20
+ render :partial=>Vanity.template("experiment"), :locals=>{ :experiment=>exp }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,158 @@
1
+ module Vanity
2
+ # Helper methods for use in your controllers.
3
+ #
4
+ # 1) Use Vanity from within your controller:
5
+ #
6
+ # class ApplicationController < ActionController::Base
7
+ # use_vanity :current_user end
8
+ # end
9
+ #
10
+ # 2) Present different options for an A/B test:
11
+ #
12
+ # Get started for only $<%= ab_test :pricing %> a month!
13
+ #
14
+ # 3) Measure conversion:
15
+ #
16
+ # def signup
17
+ # track! :pricing
18
+ # . . .
19
+ # end
20
+ module Rails
21
+ module UseVanity
22
+
23
+ protected
24
+
25
+ # Defines the vanity_identity method and the set_identity_context filter.
26
+ #
27
+ # Call with the name of a method that returns an object whose identity
28
+ # will be used as the Vanity identity. Confusing? Let's try by example:
29
+ #
30
+ # class ApplicationController < ActionController::Base
31
+ # use_vanity :current_user
32
+ #
33
+ # def current_user
34
+ # User.find(session[:user_id])
35
+ # end
36
+ # end
37
+ #
38
+ # If that method (current_user in this example) returns nil, Vanity will
39
+ # set the identity for you (using a cookie to remember it across
40
+ # requests). It also uses this mechanism if you don't provide an
41
+ # identity object, by calling use_vanity with no arguments.
42
+ #
43
+ # Of course you can also use a block:
44
+ # class ProjectController < ApplicationController
45
+ # use_vanity { |controller| controller.params[:project_id] }
46
+ # end
47
+ def use_vanity(symbol = nil, &block)
48
+ if block
49
+ define_method(:vanity_identity) { block.call(self) }
50
+ else
51
+ define_method :vanity_identity do
52
+ return @vanity_identity if @vanity_identity
53
+ if symbol && object = send(symbol)
54
+ @vanity_identity = object.id
55
+ elsif response # everyday use
56
+ @vanity_identity = cookies["vanity_id"] || ActiveSupport::SecureRandom.hex(16)
57
+ cookies["vanity_id"] = { :value=>@vanity_identity, :expires=>1.month.from_now }
58
+ @vanity_identity
59
+ else # during functional testing
60
+ @vanity_identity = "test"
61
+ end
62
+ end
63
+ end
64
+ around_filter :vanity_context_filter
65
+ before_filter :vanity_reload_filter unless ::Rails.configuration.cache_classes
66
+ before_filter :vanity_query_parameter_filter
67
+ end
68
+
69
+ end
70
+
71
+ module Filters
72
+ protected
73
+
74
+ # Around filter that sets Vanity.context to controller.
75
+ def vanity_context_filter
76
+ previous, Vanity.context = Vanity.context, self
77
+ yield
78
+ ensure
79
+ Vanity.context = previous
80
+ end
81
+
82
+ # This filter allows user to choose alternative in experiment using query
83
+ # parameter.
84
+ #
85
+ # Each alternative has a unique fingerprint (run vanity list command to
86
+ # see them all). A request with the _vanity query parameter is
87
+ # intercepted, the alternative is chosen, and the user redirected to the
88
+ # same request URL sans _vanity parameter. This only works for GET
89
+ # requests.
90
+ #
91
+ # For example, if the user requests the page
92
+ # http://example.com/?_vanity=2907dac4de, the first alternative of the
93
+ # :null_abc experiment is chosen and the user redirected to
94
+ # http://example.com/.
95
+ def vanity_query_parameter_filter
96
+ if request.get? && params[:_vanity]
97
+ hashes = Array(params.delete(:_vanity))
98
+ Vanity.playground.experiments.each do |id, experiment|
99
+ if experiment.respond_to?(:alternatives)
100
+ experiment.alternatives.each do |alt|
101
+ if hash = hashes.delete(experiment.fingerprint(alt))
102
+ experiment.chooses alt.value
103
+ break
104
+ end
105
+ end
106
+ end
107
+ break if hashes.empty?
108
+ end
109
+ redirect_to url_for(params)
110
+ end
111
+ end
112
+
113
+ # Before filter to reload Vanity experiments/metrics. Enabled when
114
+ # cache_classes is false (typically, testing environment).
115
+ def vanity_reload_filter
116
+ Vanity.playground.reload!
117
+ end
118
+
119
+ end
120
+
121
+ module Helpers
122
+
123
+ # This method returns one of the alternative values in the named A/B test.
124
+ #
125
+ # @example A/B two alternatives for a page
126
+ # def index
127
+ # if ab_test(:new_page) # true/false test
128
+ # render action: "new_page"
129
+ # else
130
+ # render action: "index"
131
+ # end
132
+ # end
133
+ # @example Similar, alternative value is page name
134
+ # def index
135
+ # render action: ab_test(:new_page)
136
+ # end
137
+ # @example A/B test inside ERB template (condition)
138
+ # <%= if ab_test(:banner) %>100% less complexity!<% end %>
139
+ # @example A/B test inside ERB template (value)
140
+ # <%= ab_test(:greeting) %> <%= current_user.name %>
141
+ # @example A/B test inside ERB template (capture)
142
+ # <% ab_test :features do |count| %>
143
+ # <%= count %> features to choose from!
144
+ # <% end %>
145
+ def ab_test(name, &block)
146
+ value = Vanity.playground.experiment(name).choose
147
+ if block
148
+ content = capture(value, &block)
149
+ block_called_from_erb?(block) ? concat(content) : content
150
+ else
151
+ value
152
+ end
153
+ end
154
+
155
+ end
156
+
157
+ end
158
+ end