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,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