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
data/bin/vanity ADDED
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env ruby
2
+ # Is there a better way to detect Rails?
3
+ if File.exist?("config/boot.rb") && File.exist?("config/environment.rb")
4
+ require "config/environment"
5
+ else
6
+ path = File.expand_path("../lib", File.dirname(__FILE__))
7
+ $LOAD_PATH.unshift path unless $LOAD_PATH.include?(path)
8
+ require "vanity"
9
+ end
10
+
11
+ require "optparse"
12
+
13
+ playground = Vanity.playground
14
+ options = Struct.new(:output).new
15
+ opts = OptionParser.new("", 24, " ") do |opts|
16
+ opts.banner = "Usage: #{File.basename($0)} [options] command\n"
17
+ opts.banner << "Commands:\n"
18
+ opts.banner << " list List all experiments and metrics\n"
19
+ opts.banner << " report Report on all running experiments/metrics\n"
20
+ opts.banner << " upgrade Upgrade your database when deploying new release\n"
21
+
22
+ opts.separator ""
23
+ opts.separator "Reporting options:"
24
+ opts.on "--output FILE", "Write report to this file (default: stdout)" do |path|
25
+ options.output = path
26
+ end
27
+
28
+ opts.separator ""
29
+ opts.separator "Common options:"
30
+ opts.on "--load_path PATH", "Path to experiments directory (default: #{playground.load_path})" do |path|
31
+ playground.load_path = path
32
+ end
33
+ opts.on "-d", "--database url", "Database connection URL (e.g. redis:/localhost:6379)" do |conn|
34
+ playground.establish_connection conn
35
+ end
36
+ opts.on "--redis HOST:PORT:DB", "DEPRECATED: Redis server host (default: localhost:6379)" do |redis|
37
+ host, port, db = redis.split(":")
38
+ playground.establish_connection "redis:/#{host}:#{port}/#{db}"
39
+ end
40
+ opts.on_tail "-h", "--help", "Show this message" do
41
+ puts opts.to_s.gsub(/^.*DEPRECATED.*$/s, '')
42
+ exit
43
+ end
44
+ opts.on_tail "-v", "--version", "Show version" do
45
+ puts "Vanity #{Vanity::Version::STRING}"
46
+ exit
47
+ end
48
+ end
49
+
50
+ opts.parse!(ARGV)
51
+ if ARGV.empty?
52
+ puts opts.banner
53
+ exit
54
+ end
55
+
56
+ ARGV.each do |cmd|
57
+ case cmd
58
+ when "report"
59
+ require "vanity/commands/report"
60
+ Vanity::Commands.report options.output
61
+ when "list"
62
+ require "vanity/commands/list"
63
+ Vanity::Commands.list
64
+ when "upgrade"
65
+ require "vanity/commands/upgrade"
66
+ Vanity::Commands.upgrade
67
+ else fail "No such command: #{cmd}"
68
+ end
69
+ end
data/lib/vanity.rb ADDED
@@ -0,0 +1,36 @@
1
+ require "date"
2
+ require "time"
3
+ require "logger"
4
+ require "cgi"
5
+ require "erb"
6
+ require "yaml"
7
+
8
+ # All the cool stuff happens in other places.
9
+ # @see Vanity::Helper
10
+ # @see Vanity::Rails
11
+ # @see Vanity::Playground
12
+ # @see Vanity::Metric
13
+ # @see Vanity::Experiment
14
+ module Vanity
15
+ end
16
+
17
+ require "vanity/version"
18
+ require "vanity/backport" if RUBY_VERSION < "1.9"
19
+ # Metrics.
20
+ require "vanity/metric/base"
21
+ require "vanity/metric/active_record"
22
+ require "vanity/metric/google_analytics"
23
+ require "vanity/metric/remote"
24
+ # Experiments.
25
+ require "vanity/experiment/base"
26
+ require "vanity/experiment/ab_test"
27
+ # Database adapters
28
+ require "vanity/adapters/abstract_adapter"
29
+ require "vanity/adapters/redis_adapter"
30
+ require "vanity/adapters/mongodb_adapter"
31
+ require "vanity/adapters/mock_adapter"
32
+ # Playground.
33
+ require "vanity/playground"
34
+ require "vanity/helpers"
35
+ # Integration with various frameworks.
36
+ require "vanity/frameworks/rails" if defined?(Rails)
@@ -0,0 +1,135 @@
1
+ module Vanity
2
+ module Adapters
3
+
4
+ class << self
5
+ # Creates new connection to underlying datastore and returns suitable
6
+ # adapter (adapter object extends AbstractAdapter and wraps the
7
+ # connection). Vanity.playgroup.establish_connection uses this.
8
+ #
9
+ # @since 1.4.0
10
+ def establish_connection(spec)
11
+ adapter_method = "#{spec[:adapter]}_connection"
12
+ send adapter_method, spec
13
+ end
14
+ end
15
+
16
+ # Base class for all adapters. Adapters wrap underlying connection to a
17
+ # datastore and implement an API that Vanity can use to store/access
18
+ # metrics, experiments, etc.
19
+ class AbstractAdapter
20
+ # Returns true if connected.
21
+ def active?
22
+ false
23
+ end
24
+
25
+ # Close connection, release any resources.
26
+ def disconnect!
27
+ end
28
+
29
+ # Close and reopen connection.
30
+ def reconnect!
31
+ end
32
+
33
+ # Empty the database. This is used during tests.
34
+ def flushdb
35
+ end
36
+
37
+
38
+ # -- Metrics --
39
+
40
+ # Return when metric was last updated.
41
+ def get_metric_last_update_at(metric)
42
+ fail "Not implemented"
43
+ end
44
+
45
+ # Track metric data.
46
+ def metric_track(metric, timestamp, identity, values)
47
+ fail "Not implemented"
48
+ end
49
+
50
+ # Returns all the metric values between from and to time instances
51
+ # (inclusive). Returns pairs of date and total count for that date.
52
+ def metric_values(metric, from, to)
53
+ fail "Not implemented"
54
+ end
55
+
56
+ # Deletes all information about this metric.
57
+ def destroy_metric(metric)
58
+ fail "Not implemented"
59
+ end
60
+
61
+
62
+ # -- Experiments --
63
+
64
+ # Store when experiment was created (do not write over existing value).
65
+ def set_experiment_created_at(experiment, time)
66
+ fail "Not implemented"
67
+ end
68
+
69
+ # Return when experiment was created.
70
+ def get_experiment_created_at(experiment)
71
+ fail "Not implemented"
72
+ end
73
+
74
+ # Returns true if experiment completed.
75
+ def is_experiment_completed?(experiment)
76
+ fail "Not implemented"
77
+ end
78
+
79
+ # Returns counts for given A/B experiment and alternative (by index).
80
+ # Returns hash with values for the keys :participants, :converted and
81
+ # :conversions.
82
+ def ab_counts(experiment, alternative)
83
+ fail "Not implemented"
84
+ end
85
+
86
+ # Pick particular alternative (by index) to show to this particular
87
+ # participant (by identity).
88
+ def ab_show(experiment, identity, alternative)
89
+ fail "Not implemented"
90
+ end
91
+
92
+ # Indicates which alternative to show to this participant. See #ab_show.
93
+ def ab_showing(experiment, identity)
94
+ fail "Not implemented"
95
+ end
96
+
97
+ # Cancels previously set association between identity and alternative. See
98
+ # #ab_show.
99
+ def ab_not_showing(experiment, identity)
100
+ fail "Not implemented"
101
+ end
102
+
103
+ # Records a participant in this experiment for the given alternative.
104
+ def ab_add_participant(experiment, alternative, identity)
105
+ fail "Not implemented"
106
+ end
107
+
108
+ # Records a conversion in this experiment for the given alternative.
109
+ # Associates a value with the conversion (default to 1). If implicit is
110
+ # true, add particpant if not already recorded for this experiment. If
111
+ # implicit is false (default), only add conversion is participant
112
+ # previously recorded as participating in this experiment.
113
+ def ab_add_conversion(experiment, alternative, identity, count = 1, implicit = false)
114
+ fail "Not implemented"
115
+ end
116
+
117
+ # Returns the outcome of this expriment (if set), the index of a
118
+ # particular alternative.
119
+ def ab_get_outcome(experiment)
120
+ fail "Not implemented"
121
+ end
122
+
123
+ # Sets the outcome of this experiment to a particular alternative.
124
+ def ab_set_outcome(experiment, alternative = 0)
125
+ fail "Not implemented"
126
+ end
127
+
128
+ # Deletes all information about this experiment.
129
+ def destroy_experiment(experiment)
130
+ fail "Not implemented"
131
+ end
132
+
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,304 @@
1
+ module Vanity
2
+ module Adapters
3
+ class << self
4
+ # Creates new ActiveRecord connection and returns ActiveRecordAdapter.
5
+ def active_record_connection(spec)
6
+ require 'active_record'
7
+ ActiveRecordAdapter.new(spec)
8
+ end
9
+ end
10
+
11
+ # ActiveRecord adapter
12
+ class ActiveRecordAdapter < AbstractAdapter
13
+ # Base model, stores connection and defines schema
14
+ class VanityRecord < ActiveRecord::Base
15
+ def self.define_schema
16
+ # Create a schema table to store the schema version
17
+ unless connection.tables.include?('vanity_schema')
18
+ connection.create_table :vanity_schema do |t|
19
+ t.integer :version
20
+ end
21
+ end
22
+
23
+ # Migrate
24
+ unless VanitySchema.find_by_version(1)
25
+ connection.create_table :vanity_metrics do |t|
26
+ t.string :metric_id
27
+ t.datetime :updated_at
28
+ end
29
+ connection.add_index :vanity_metrics, [:metric_id]
30
+
31
+ connection.create_table :vanity_metric_values do |t|
32
+ t.integer :vanity_metric_id
33
+ t.integer :index
34
+ t.integer :value
35
+ t.string :date
36
+ end
37
+ connection.add_index :vanity_metric_values, [:vanity_metric_id]
38
+
39
+ connection.create_table :vanity_experiments do |t|
40
+ t.string :experiment_id
41
+ t.integer :outcome
42
+ t.datetime :created_at
43
+ t.datetime :completed_at
44
+ end
45
+ connection.add_index :vanity_experiments, [:experiment_id]
46
+
47
+ connection.create_table :vanity_conversions do |t|
48
+ t.integer :vanity_experiment_id
49
+ t.integer :alternative
50
+ t.integer :conversions
51
+ end
52
+ connection.add_index :vanity_conversions, [:vanity_experiment_id, :alternative]
53
+
54
+ connection.create_table :vanity_participants do |t|
55
+ t.string :experiment_id
56
+ t.string :identity
57
+ t.integer :shown
58
+ t.integer :seen
59
+ t.integer :converted
60
+ end
61
+ connection.add_index :vanity_participants, [:experiment_id]
62
+ connection.add_index :vanity_participants, [:experiment_id, :identity]
63
+ connection.add_index :vanity_participants, [:experiment_id, :shown]
64
+ connection.add_index :vanity_participants, [:experiment_id, :seen]
65
+ connection.add_index :vanity_participants, [:experiment_id, :converted]
66
+
67
+ VanitySchema.create(:version => 1)
68
+ end
69
+ end
70
+ end
71
+
72
+ # Schema model
73
+ class VanitySchema < VanityRecord
74
+ set_table_name :vanity_schema
75
+ end
76
+
77
+ # Metric model
78
+ class VanityMetric < VanityRecord
79
+ set_table_name :vanity_metrics
80
+ has_many :vanity_metric_values
81
+
82
+ def self.retrieve(metric)
83
+ find_or_create_by_metric_id(metric.to_s)
84
+ end
85
+ end
86
+
87
+ # Metric value
88
+ class VanityMetricValue < VanityRecord
89
+ set_table_name :vanity_metric_values
90
+ belongs_to :vanity_metric
91
+ end
92
+
93
+ # Experiment model
94
+ class VanityExperiment < VanityRecord
95
+ set_table_name :vanity_experiments
96
+ has_many :vanity_conversions, :dependent => :destroy
97
+
98
+ # Finds or creates the experiment
99
+ def self.retrieve(experiment)
100
+ find_or_create_by_experiment_id(experiment.to_s)
101
+ end
102
+
103
+ def increment_conversion(alternative, count = 1)
104
+ record = vanity_conversions.find_or_create_by_alternative(alternative)
105
+ record.increment!(:conversions, count)
106
+ end
107
+ end
108
+
109
+ # Conversion model
110
+ class VanityConversion < VanityRecord
111
+ set_table_name :vanity_conversions
112
+ belongs_to :vanity_experiment
113
+ end
114
+
115
+ # Participant model
116
+ class VanityParticipant < VanityRecord
117
+ set_table_name :vanity_participants
118
+
119
+ # Finds the participant by experiment and identity. If
120
+ # create is true then it will create the participant
121
+ # if not found. If a hash is passed then this will be
122
+ # passed to create if creating, or will be used to
123
+ # update the found participant.
124
+ def self.retrieve(experiment, identity, create = true, update_with = nil)
125
+ record = VanityParticipant.first(
126
+ :conditions =>
127
+ {:experiment_id => experiment.to_s, :identity => identity})
128
+
129
+ if record
130
+ record.update_attributes(update_with) if update_with
131
+ elsif create
132
+ record = VanityParticipant.create(
133
+ {:experiment_id => experiment.to_s,
134
+ :identity => identity}.merge(update_with || {}))
135
+ end
136
+
137
+ record
138
+ end
139
+ end
140
+
141
+ def initialize(options)
142
+ options[:adapter] = options[:active_record_adapter] if options[:active_record_adapter]
143
+
144
+ VanityRecord.establish_connection(options)
145
+ VanityRecord.define_schema
146
+ end
147
+
148
+ def active?
149
+ VanityRecord.connected?
150
+ end
151
+
152
+ def disconnect!
153
+ VanityRecord.connection.disconnect! if active?
154
+ end
155
+
156
+ def reconnect!
157
+ VanityRecord.connection.reconnect!
158
+ end
159
+
160
+ def flushdb
161
+ [VanityExperiment, VanityMetric, VanityParticipant, VanityMetricValue, VanityConversion].each do |klass|
162
+ klass.delete_all
163
+ end
164
+ end
165
+
166
+ def get_metric_last_update_at(metric)
167
+ record = VanityMetric.find_by_metric_id(metric.to_s)
168
+ record && record.updated_at
169
+ end
170
+
171
+ def metric_track(metric, timestamp, identity, values)
172
+ record = VanityMetric.retrieve(metric)
173
+
174
+ values.each_with_index do |value, index|
175
+ record.vanity_metric_values.create(:date => timestamp.to_date.to_s, :index => index, :value => value)
176
+ end
177
+
178
+ record.updated_at = Time.now
179
+ record.save
180
+ end
181
+
182
+ def metric_values(metric, from, to)
183
+ connection = VanityMetric.connection
184
+ record = VanityMetric.retrieve(metric)
185
+ dates = (from.to_date..to.to_date).map(&:to_s)
186
+ conditions = [connection.quote_column_name('date') + ' IN (?)', dates]
187
+ order = "#{connection.quote_column_name('date')}"
188
+ select = "sum(#{connection.quote_column_name('value')}) value, #{connection.quote_column_name('date')}"
189
+ group_by = "#{connection.quote_column_name('date')}"
190
+
191
+ values = record.vanity_metric_values.all(
192
+ :select => select,
193
+ :conditions => conditions,
194
+ :order => order,
195
+ :group => group_by
196
+ )
197
+
198
+ dates.map do |date|
199
+ value = values.detect{|v| v.date == date }
200
+ [(value && value.value) || 0]
201
+ end
202
+ end
203
+
204
+ def destroy_metric(metric)
205
+ record = VanityMetric.find_by_metric_id(metric.to_s)
206
+ record && record.destroy
207
+ end
208
+
209
+ # Store when experiment was created (do not write over existing value).
210
+ def set_experiment_created_at(experiment, time)
211
+ record = VanityExperiment.find_by_experiment_id(experiment.to_s) ||
212
+ VanityExperiment.new(:experiment_id => experiment.to_s)
213
+ record.created_at ||= time
214
+ record.save
215
+ end
216
+
217
+ # Return when experiment was created.
218
+ def get_experiment_created_at(experiment)
219
+ record = VanityExperiment.retrieve(experiment)
220
+ record && record.created_at
221
+ end
222
+
223
+ def set_experiment_completed_at(experiment, time)
224
+ VanityExperiment.retrieve(experiment).update_attribute(:completed_at, time)
225
+ end
226
+
227
+ def get_experiment_completed_at(experiment)
228
+ VanityExperiment.retrieve(experiment).completed_at
229
+ end
230
+
231
+ # Returns true if experiment completed.
232
+ def is_experiment_completed?(experiment)
233
+ !!VanityExperiment.retrieve(experiment).completed_at
234
+ end
235
+
236
+ # Returns counts for given A/B experiment and alternative (by index).
237
+ # Returns hash with values for the keys :participants, :converted and
238
+ # :conversions.
239
+ def ab_counts(experiment, alternative)
240
+ record = VanityExperiment.retrieve(experiment)
241
+ participants = VanityParticipant.count(:conditions => {:experiment_id => experiment.to_s, :seen => alternative})
242
+ converted = VanityParticipant.count(:conditions => {:experiment_id => experiment.to_s, :converted => alternative})
243
+ conversions = record.vanity_conversions.sum(:conversions, :conditions => {:alternative => alternative})
244
+
245
+ {
246
+ :participants => participants,
247
+ :converted => converted,
248
+ :conversions => conversions
249
+ }
250
+ end
251
+
252
+ # Pick particular alternative (by index) to show to this particular
253
+ # participant (by identity).
254
+ def ab_show(experiment, identity, alternative)
255
+ VanityParticipant.retrieve(experiment, identity, true, :shown => alternative)
256
+ end
257
+
258
+ # Indicates which alternative to show to this participant. See #ab_show.
259
+ def ab_showing(experiment, identity)
260
+ participant = VanityParticipant.retrieve(experiment, identity, false)
261
+ participant && participant.shown
262
+ end
263
+
264
+ # Cancels previously set association between identity and alternative. See
265
+ # #ab_show.
266
+ def ab_not_showing(experiment, identity)
267
+ VanityParticipant.retrieve(experiment, identity, true, :shown => nil)
268
+ end
269
+
270
+ # Records a participant in this experiment for the given alternative.
271
+ def ab_add_participant(experiment, alternative, identity)
272
+ VanityParticipant.retrieve(experiment, identity, true, :seen => alternative)
273
+ end
274
+
275
+ # Records a conversion in this experiment for the given alternative.
276
+ # Associates a value with the conversion (default to 1). If implicit is
277
+ # true, add particpant if not already recorded for this experiment. If
278
+ # implicit is false (default), only add conversion is participant
279
+ # previously recorded as participating in this experiment.
280
+ def ab_add_conversion(experiment, alternative, identity, count = 1, implicit = false)
281
+ VanityParticipant.retrieve(experiment, identity, implicit, :converted => alternative)
282
+ VanityExperiment.retrieve(experiment).increment_conversion(alternative, count)
283
+ end
284
+
285
+ # Returns the outcome of this experiment (if set), the index of a
286
+ # particular alternative.
287
+ def ab_get_outcome(experiment)
288
+ VanityExperiment.retrieve(experiment).outcome
289
+ end
290
+
291
+ # Sets the outcome of this experiment to a particular alternative.
292
+ def ab_set_outcome(experiment, alternative = 0)
293
+ VanityExperiment.retrieve(experiment).update_attribute(:outcome, alternative)
294
+ end
295
+
296
+ # Deletes all information about this experiment.
297
+ def destroy_experiment(experiment)
298
+ VanityParticipant.delete_all(:experiment_id => experiment.to_s)
299
+ record = VanityExperiment.find_by_experiment_id(experiment.to_s)
300
+ record && record.destroy
301
+ end
302
+ end
303
+ end
304
+ end