tupalo-vanity 1.5.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. data/CHANGELOG +243 -0
  2. data/Gemfile +24 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.rdoc +74 -0
  5. data/Rakefile +189 -0
  6. data/bin/vanity +69 -0
  7. data/lib/vanity.rb +36 -0
  8. data/lib/vanity/adapters/abstract_adapter.rb +135 -0
  9. data/lib/vanity/adapters/active_record_adapter.rb +304 -0
  10. data/lib/vanity/adapters/mock_adapter.rb +157 -0
  11. data/lib/vanity/adapters/mongodb_adapter.rb +162 -0
  12. data/lib/vanity/adapters/redis_adapter.rb +154 -0
  13. data/lib/vanity/backport.rb +26 -0
  14. data/lib/vanity/commands/list.rb +21 -0
  15. data/lib/vanity/commands/report.rb +64 -0
  16. data/lib/vanity/commands/upgrade.rb +34 -0
  17. data/lib/vanity/experiment/ab_test.rb +482 -0
  18. data/lib/vanity/experiment/base.rb +212 -0
  19. data/lib/vanity/frameworks/rails.rb +244 -0
  20. data/lib/vanity/helpers.rb +59 -0
  21. data/lib/vanity/metric/active_record.rb +83 -0
  22. data/lib/vanity/metric/base.rb +244 -0
  23. data/lib/vanity/metric/google_analytics.rb +83 -0
  24. data/lib/vanity/metric/remote.rb +53 -0
  25. data/lib/vanity/playground.rb +332 -0
  26. data/lib/vanity/templates/_ab_test.erb +28 -0
  27. data/lib/vanity/templates/_experiment.erb +5 -0
  28. data/lib/vanity/templates/_experiments.erb +7 -0
  29. data/lib/vanity/templates/_metric.erb +14 -0
  30. data/lib/vanity/templates/_metrics.erb +13 -0
  31. data/lib/vanity/templates/_report.erb +27 -0
  32. data/lib/vanity/templates/flot.min.js +1 -0
  33. data/lib/vanity/templates/jquery.min.js +19 -0
  34. data/lib/vanity/templates/vanity.css +26 -0
  35. data/lib/vanity/templates/vanity.js +82 -0
  36. data/lib/vanity/version.rb +11 -0
  37. data/test/experiment/ab_test.rb +700 -0
  38. data/test/experiment/base_test.rb +136 -0
  39. data/test/experiments/age_and_zipcode.rb +19 -0
  40. data/test/experiments/metrics/cheers.rb +3 -0
  41. data/test/experiments/metrics/signups.rb +2 -0
  42. data/test/experiments/metrics/yawns.rb +3 -0
  43. data/test/experiments/null_abc.rb +5 -0
  44. data/test/metric/active_record_test.rb +249 -0
  45. data/test/metric/base_test.rb +293 -0
  46. data/test/metric/google_analytics_test.rb +104 -0
  47. data/test/metric/remote_test.rb +108 -0
  48. data/test/myapp/app/controllers/application_controller.rb +2 -0
  49. data/test/myapp/app/controllers/main_controller.rb +7 -0
  50. data/test/myapp/config/boot.rb +110 -0
  51. data/test/myapp/config/environment.rb +10 -0
  52. data/test/myapp/config/environments/production.rb +0 -0
  53. data/test/myapp/config/routes.rb +3 -0
  54. data/test/passenger_test.rb +43 -0
  55. data/test/playground_test.rb +10 -0
  56. data/test/rails_test.rb +294 -0
  57. data/test/test_helper.rb +134 -0
  58. data/tupalo-vanity.gemspec +25 -0
  59. metadata +152 -0
@@ -0,0 +1,53 @@
1
+ require "net/http"
2
+ require "cgi"
3
+
4
+ module Vanity
5
+ class Metric
6
+
7
+ # Specifies the base URL to use for a remote metric. For example:
8
+ # metric :sandbox do
9
+ # remote "http://api.vanitydash.com/metrics/sandbox"
10
+ # end
11
+ def remote(url = nil)
12
+ @remote_url = URI.parse(url) if url
13
+ @mutex ||= Mutex.new
14
+ extend Remote
15
+ @remote_url
16
+ end
17
+
18
+ # To update a remote metric, make a POST request to the metric URL with the
19
+ # content type "application/x-www-form-urlencoded" and the following
20
+ # fields:
21
+ # - The +metric+ identifier,
22
+ # - The +timestamp+ must be RFC 2616 formatted (in Ruby just call +httpdate+
23
+ # on the Time object),
24
+ # - The +identity+ (optional),
25
+ # - Pass consecutive values using the field +values[]+, or
26
+ # - Set values by their index using +values[0]+, +values[1]+, etc or
27
+ # - Set values by series name using +values[foo]+, +values[bar]+, etc.
28
+ module Remote
29
+
30
+ def track!(args = nil)
31
+ return unless @playground.collecting?
32
+ timestamp, identity, values = track_args(args)
33
+ params = ["metric=#{CGI.escape @id.to_s}", "timestamp=#{CGI.escape timestamp.httpdate}"]
34
+ params << "identity=#{CGI.escape identity.to_s}" if identity
35
+ params.concat values.map { |v| "values[]=#{v.to_i}" }
36
+ params << @remote_url.query if @remote_url.query
37
+ @mutex.synchronize do
38
+ @http ||= Net::HTTP.start(@remote_url.host, @remote_url.port)
39
+ @http.request Net::HTTP::Post.new(@remote_url.path, "Content-Type"=>"application/x-www-form-urlencoded"), params.join("&")
40
+ end
41
+ rescue Timeout::Error, StandardError
42
+ @playground.logger.error "Error sending data for metric #{name}: #{$!}"
43
+ @http = nil
44
+ ensure
45
+ call_hooks timestamp, identity, values
46
+ end
47
+
48
+ # "Don't worry, be crappy. Revolutionary means you ship and then test."
49
+ # -- Guy Kawazaki
50
+
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,332 @@
1
+ require "uri"
2
+
3
+ module Vanity
4
+
5
+ # Playground catalogs all your experiments, holds the Vanity configuration.
6
+ #
7
+ # @example
8
+ # Vanity.playground.logger = my_logger
9
+ # puts Vanity.playground.map(&:name)
10
+ class Playground
11
+
12
+ DEFAULTS = { :collecting => true, :load_path=>"experiments" }
13
+
14
+ # Created new Playground. Unless you need to, use the global
15
+ # Vanity.playground.
16
+ #
17
+ # First argument is connection specification (see #redis=), last argument is
18
+ # a set of options, both are optional. Supported options are:
19
+ # - connection -- Connection specification
20
+ # - namespace -- Namespace to use
21
+ # - load_path -- Path to load experiments/metrics from
22
+ # - logger -- Logger to use
23
+ def initialize(*args)
24
+ options = args.pop if Hash === args.last
25
+ @options = DEFAULTS.merge(options || {})
26
+ if @options.values_at(:host, :port, :db).any?
27
+ warn "Deprecated: please specify Redis connection as URL (\"redis://host:port/db\")"
28
+ establish_connection :adapter=>"redis", :host=>options[:host], :port=>options[:port], :database=>options[:db]
29
+ elsif @options[:redis]
30
+ @adapter = RedisAdapter.new(:redis=>@options[:redis])
31
+ else
32
+ connection_spec = args.shift || @options[:connection]
33
+ establish_connection "redis://" + connection_spec if connection_spec
34
+ end
35
+
36
+ warn "Deprecated: namespace option no longer supported directly" if @options[:namespace]
37
+ @load_path = @options[:load_path] || DEFAULTS[:load_path]
38
+ unless @logger = @options[:logger]
39
+ @logger = Logger.new(STDOUT)
40
+ @logger.level = Logger::ERROR
41
+ end
42
+ @loading = []
43
+ @collecting = @options[:collecting]
44
+ end
45
+
46
+ # Deprecated. Use redis.server instead.
47
+ attr_accessor :host, :port, :db, :password, :namespace
48
+
49
+ # Path to load experiment files from.
50
+ attr_accessor :load_path
51
+
52
+ # Logger.
53
+ attr_accessor :logger
54
+
55
+ # Defines a new experiment. Generally, do not call this directly,
56
+ # use one of the definition methods (ab_test, measure, etc).
57
+ #
58
+ # @see Vanity::Experiment
59
+ def define(name, type, options = {}, &block)
60
+ warn "Deprecated: if you need this functionality let's make a better API"
61
+ id = name.to_s.downcase.gsub(/\W/, "_").to_sym
62
+ raise "Experiment #{id} already defined once" if experiments[id]
63
+ klass = Experiment.const_get(type.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase })
64
+ experiment = klass.new(self, id, name, options)
65
+ experiment.instance_eval &block
66
+ experiment.save
67
+ experiments[id] = experiment
68
+ end
69
+
70
+ # Returns the experiment. You may not have guessed, but this method raises
71
+ # an exception if it cannot load the experiment's definition.
72
+ #
73
+ # @see Vanity::Experiment
74
+ def experiment(name)
75
+ id = name.to_s.downcase.gsub(/\W/, "_").to_sym
76
+ warn "Deprecated: pleae call experiment method with experiment identifier (a Ruby symbol)" unless id == name
77
+ experiments[id.to_sym] or raise NameError, "No experiment #{id}"
78
+ end
79
+
80
+ # Returns hash of experiments (key is experiment id).
81
+ #
82
+ # @see Vanity::Experiment
83
+ def experiments
84
+ unless @experiments
85
+ @experiments = {}
86
+ @logger.info "Vanity: loading experiments from #{load_path}"
87
+ Dir[File.join(load_path, "*.rb")].each do |file|
88
+ Experiment::Base.load self, @loading, file
89
+ end
90
+ end
91
+ @experiments
92
+ end
93
+
94
+ # Reloads all metrics and experiments. Rails calls this for each request in
95
+ # development mode.
96
+ def reload!
97
+ @experiments = nil
98
+ @metrics = nil
99
+ load!
100
+ end
101
+
102
+ # Loads all metrics and experiments. Rails calls this during
103
+ # initialization.
104
+ def load!
105
+ experiments
106
+ metrics
107
+ end
108
+
109
+ # Returns a metric (raises NameError if no metric with that identifier).
110
+ #
111
+ # @see Vanity::Metric
112
+ # @since 1.1.0
113
+ def metric(id)
114
+ metrics[id.to_sym] or raise NameError, "No metric #{id}"
115
+ end
116
+
117
+ # True if collection data (metrics and experiments). You only want to
118
+ # collect data in production environment, everywhere else run with
119
+ # collection off.
120
+ #
121
+ # @since 1.4.0
122
+ def collecting?
123
+ @collecting
124
+ end
125
+
126
+ # Turns data collection on and off.
127
+ #
128
+ # @since 1.4.0
129
+ def collecting=(enabled)
130
+ @collecting = !!enabled
131
+ end
132
+
133
+ # Returns hash of metrics (key is metric id).
134
+ #
135
+ # @see Vanity::Metric
136
+ # @since 1.1.0
137
+ def metrics
138
+ unless @metrics
139
+ @metrics = {}
140
+ @logger.info "Vanity: loading metrics from #{load_path}/metrics"
141
+ Dir[File.join(load_path, "metrics/*.rb")].each do |file|
142
+ Metric.load self, @loading, file
143
+ end
144
+ if File.exist?("config/vanity.yml") && remote = YAML.load(ERB.new(File.read("config/vanity.yml")).result)["metrics"]
145
+ remote.each do |id, url|
146
+ fail "Metric #{id} already defined in playground" if metrics[id.to_sym]
147
+ metric = Metric.new(self, id)
148
+ metric.remote url
149
+ metrics[id.to_sym] = metric
150
+ end
151
+ end
152
+ end
153
+ @metrics
154
+ end
155
+
156
+ # Tracks an action associated with a metric.
157
+ #
158
+ # @example
159
+ # Vanity.playground.track! :uploaded_video
160
+ #
161
+ # @since 1.1.0
162
+ def track!(id, count = 1)
163
+ metric(id).track! count
164
+ end
165
+
166
+
167
+ # -- Connection management --
168
+
169
+ # This is the preferred way to programmatically create a new connection (or
170
+ # switch to a new connection). If no connection was established, the
171
+ # playground will create a new one by calling this method with no arguments.
172
+ #
173
+ # With no argument, uses the connection specified in config/vanity.yml file
174
+ # for the current environment (RACK_ENV, RAILS_ENV or development). If there
175
+ # is no config/vanity.yml file, picks the configuration from
176
+ # config/redis.yml, or defaults to Redis on localhost, port 6379.
177
+ #
178
+ # If the argument is a symbol, uses the connection specified in
179
+ # config/vanity.yml for that environment. For example:
180
+ # Vanity.playground.establish_connection :production
181
+ #
182
+ # If the argument is a string, it is processed as a URL. For example:
183
+ # Vanity.playground.establish_connection "redis://redis.local/5"
184
+ #
185
+ # Otherwise, the argument is a hash and specifies the adapter name and any
186
+ # additional options understood by that adapter (as with config/vanity.yml).
187
+ # For example:
188
+ # Vanity.playground.establish_connection :adapter=>:redis,
189
+ # :host=>"redis.local"
190
+ #
191
+ # @since 1.4.0
192
+ def establish_connection(spec = nil)
193
+ disconnect! if @adapter
194
+ case spec
195
+ when nil
196
+ if File.exists?("config/vanity.yml")
197
+ env = ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development"
198
+ spec = YAML.load(ERB.new(File.read("config/vanity.yml")).result)[env]
199
+ fail "No configuration for #{env}" unless spec
200
+ establish_connection spec
201
+ elsif File.exists?("config/redis.yml")
202
+ env = ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development"
203
+ redis = YAML.load(ERB.new(File.read("config/redis.yml")).result)[env]
204
+ fail "No configuration for #{env}" unless redis
205
+ establish_connection "redis://" + redis
206
+ else
207
+ establish_connection :adapter=>"redis"
208
+ end
209
+ when Symbol
210
+ spec = YAML.load(ERB.new(File.read("config/vanity.yml")).result)[spec.to_s]
211
+ establish_connection spec
212
+ when String
213
+ uri = URI.parse(spec)
214
+ params = CGI.parse(uri.query) if uri.query
215
+ establish_connection :adapter=>uri.scheme, :username=>uri.user, :password=>uri.password,
216
+ :host=>uri.host, :port=>uri.port, :path=>uri.path, :params=>params
217
+ else
218
+ spec = spec.inject({}) { |hash,(k,v)| hash[k.to_sym] = v ; hash }
219
+ begin
220
+ require "vanity/adapters/#{spec[:adapter]}_adapter"
221
+ rescue LoadError
222
+ raise "Could not find #{spec[:adapter]} in your load path"
223
+ end
224
+ @adapter = Adapters.establish_connection(spec)
225
+ end
226
+ end
227
+
228
+ # Returns the current connection. Establishes new connection is necessary.
229
+ #
230
+ # @since 1.4.0
231
+ def connection
232
+ @adapter || establish_connection
233
+ end
234
+
235
+ # Returns true if connection is open.
236
+ #
237
+ # @since 1.4.0
238
+ def connected?
239
+ @adapter && @adapter.active?
240
+ end
241
+
242
+ # Closes the current connection.
243
+ #
244
+ # @since 1.4.0
245
+ def disconnect!
246
+ @adapter.disconnect! if @adapter
247
+ end
248
+
249
+ # Closes the current connection and establishes a new one.
250
+ #
251
+ # @since 1.3.0
252
+ def reconnect!
253
+ establish_connection
254
+ end
255
+
256
+ # Deprecated. Use Vanity.playground.collecting = true/false instead. Under
257
+ # Rails, collecting is true in production environment, false in all other
258
+ # environments, which is exactly what you want.
259
+ def test!
260
+ warn "Deprecated: use collecting = false instead"
261
+ self.collecting = false
262
+ end
263
+
264
+ # Deprecated. Use establish_connection or configuration file instead.
265
+ def redis=(spec_or_connection)
266
+ warn "Deprecated: use establish_connection method instead"
267
+ case spec_or_connection
268
+ when String
269
+ establish_connection "redis://" + spec_or_connection
270
+ when ::Redis
271
+ @connection = Adapters::RedisAdapter.new(spec_or_connection)
272
+ when :mock
273
+ establish_connection :adapter=>:mock
274
+ else
275
+ raise "I don't know what to do with #{spec_or_connection.inspect}"
276
+ end
277
+ end
278
+
279
+ def redis
280
+ warn "Deprecated: use connection method instead"
281
+ connection
282
+ end
283
+
284
+ end
285
+
286
+ # In the case of Rails, use the Rails logger and collect only for
287
+ # production environment by default.
288
+ @playground = Playground.new(defined?(Rails) ? { :logger => Rails.logger, :collecting => Rails.env.production? } : {})
289
+ class << self
290
+
291
+ # The playground instance.
292
+ #
293
+ # @see Vanity::Playground
294
+ attr_accessor :playground
295
+
296
+ # Returns the Vanity context. For example, when using Rails this would be
297
+ # the current controller, which can be used to get/set the vanity identity.
298
+ def context
299
+ Thread.current[:vanity_context]
300
+ end
301
+
302
+ # Sets the Vanity context. For example, when using Rails this would be
303
+ # set by the set_vanity_context before filter (via Vanity::Rails#use_vanity).
304
+ def context=(context)
305
+ Thread.current[:vanity_context] = context
306
+ end
307
+
308
+ # Path to template.
309
+ def template(name)
310
+ path = File.join(File.dirname(__FILE__), "templates/#{name}")
311
+ path << ".erb" unless name["."]
312
+ path
313
+ end
314
+
315
+ end
316
+ end
317
+
318
+
319
+ class Object
320
+
321
+ # Use this method to access an experiment by name.
322
+ #
323
+ # @example
324
+ # puts experiment(:text_size).alternatives
325
+ #
326
+ # @see Vanity::Playground#experiment
327
+ # @deprecated
328
+ def experiment(name)
329
+ warn "Deprecated. Please call Vanity.playground.experiment directly."
330
+ Vanity.playground.experiment(name)
331
+ end
332
+ end
@@ -0,0 +1,28 @@
1
+ <% score = experiment.score %>
2
+ <table>
3
+ <caption>
4
+ <%= experiment.conclusion(score).join(" ") %></caption>
5
+ <% score.alts.each do |alt| %>
6
+ <tr class="<%= "choice" if score.choice == alt %>">
7
+ <td class="option"><%= alt.name.gsub(/^o/, "O") %>:</td>
8
+ <td class="value"><code><%=vanity_h alt.value.to_s %></code></td>
9
+ <td class="value"><%= alt.participants %> participants</td>
10
+ <td class="value"><%= alt.converted %> converted</td>
11
+ <td>
12
+ <%= "%.1f%%" % [alt.conversion_rate * 100] %>
13
+ <%= "(%d%% better than %s)" % [alt.difference, score.least.name] if alt.difference && alt.difference >= 1 %>
14
+ </td>
15
+ <td class="action">
16
+ <% if experiment.active? && respond_to?(:url_for) %>
17
+ <% if experiment.showing?(alt) %>
18
+ showing
19
+ <% else %>
20
+ <a class="button chooses" title="Show me this alternative from now on" href="#"
21
+ data-id="<%= experiment.id %>" data-url="<%= url_for(:action=>:chooses, :e=>experiment.id, :a=>alt.id) %>">show</a>
22
+ <% end %>
23
+ <% end %>
24
+ </td>
25
+ </tr>
26
+ <% end %>
27
+ </table>
28
+ <%= %>
@@ -0,0 +1,5 @@
1
+ <h3><%=vanity_h experiment.name %> <span class="type">(<%= experiment.class.friendly_name %>)</span></h3>
2
+ <%= experiment.description.to_s.split(/\n\s*\n/).map { |para| vanity_html_safe(%{<p class="description">#{vanity_h para}</p>}) }.join %>
3
+ <%= render :file => Vanity.template("_" + experiment.type), :locals => {:experiment => experiment} %>
4
+ <p class="meta">Started <%= experiment.created_at.strftime("%a, %b %d") %>
5
+ <%= " | Completed #{experiment.completed_at.strftime("%a, %b %d")}" unless experiment.active? %></p>
@@ -0,0 +1,7 @@
1
+ <ul class="experiments">
2
+ <% experiments.sort_by { |id, experiment| experiment.created_at }.reverse.each do |id, experiment| %>
3
+ <li class="experiment <%= experiment.type %>" id="experiment_<%=vanity_h id.to_s %>">
4
+ <%= render :file => Vanity.template("_experiment"), :locals => { :id => id, :experiment => experiment } %>
5
+ </li>
6
+ <% end %>
7
+ </ul>
@@ -0,0 +1,14 @@
1
+ <h3><%=vanity_h metric.name %></h3>
2
+ <%= vanity_simple_format vanity_h(Vanity::Metric.description(metric).to_s), :class=>"description" %>
3
+ <%=
4
+ begin
5
+ data = Vanity::Metric.data(metric)
6
+ min, max = data.map(&:last).minmax
7
+ js = data.map { |date,value| "['#{(date.to_time + 1.hour).httpdate}',#{value}]" }.join(",")
8
+ vanity_html_safe(%{<div class="chart"></div>
9
+ <script type="text/javascript">
10
+ $(function(){Vanity.metric("#{vanity_h id.to_s}").plot([{label:"#{vanity_h metric.name}", data: [#{js}]}])})
11
+ </script>})
12
+ rescue Exception=>ex
13
+ %{<div class="error">#{vanity_h ex.message}</div>}
14
+ end %>
@@ -0,0 +1,13 @@
1
+ <ul class="metrics">
2
+ <% metrics.sort_by { |id, metric| metric.name }.each do |id, metric| %>
3
+ <li class="metric" id="metric_<%= id %>">
4
+ <%= render :file=>Vanity.template("_metric"), :locals=>{:id=>id, :metric=>metric} %>
5
+ </li>
6
+ <% end %>
7
+ </ul>
8
+ <form id="milestones">
9
+ <% experiments.each do |id, experiment| %>
10
+ <label><input type="checkbox" name="milestone" data-start="<%= experiment.created_at.httpdate %>"
11
+ data-end="<%= (experiment.completed_at || Time.now).httpdate %>"><%=vanity_h experiment.name %></label>
12
+ <% end %>
13
+ </form>