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.
- data/CHANGELOG +153 -0
- data/MIT-LICENSE +21 -0
- data/README.rdoc +83 -0
- data/bin/vanity +53 -0
- data/lib/vanity.rb +38 -0
- data/lib/vanity/backport.rb +43 -0
- data/lib/vanity/commands.rb +2 -0
- data/lib/vanity/commands/list.rb +21 -0
- data/lib/vanity/commands/report.rb +60 -0
- data/lib/vanity/experiment/ab_test.rb +477 -0
- data/lib/vanity/experiment/base.rb +212 -0
- data/lib/vanity/helpers.rb +59 -0
- data/lib/vanity/metric/active_record.rb +77 -0
- data/lib/vanity/metric/base.rb +221 -0
- data/lib/vanity/metric/google_analytics.rb +70 -0
- data/lib/vanity/mock_redis.rb +76 -0
- data/lib/vanity/playground.rb +197 -0
- data/lib/vanity/rails.rb +22 -0
- data/lib/vanity/rails/dashboard.rb +24 -0
- data/lib/vanity/rails/helpers.rb +158 -0
- data/lib/vanity/rails/testing.rb +11 -0
- data/lib/vanity/templates/_ab_test.erb +26 -0
- data/lib/vanity/templates/_experiment.erb +5 -0
- data/lib/vanity/templates/_experiments.erb +7 -0
- data/lib/vanity/templates/_metric.erb +14 -0
- data/lib/vanity/templates/_metrics.erb +13 -0
- data/lib/vanity/templates/_report.erb +27 -0
- data/lib/vanity/templates/flot.min.js +1 -0
- data/lib/vanity/templates/jquery.min.js +19 -0
- data/lib/vanity/templates/vanity.css +26 -0
- data/lib/vanity/templates/vanity.js +82 -0
- data/test/ab_test_test.rb +656 -0
- data/test/experiment_test.rb +136 -0
- data/test/experiments/age_and_zipcode.rb +19 -0
- data/test/experiments/metrics/cheers.rb +3 -0
- data/test/experiments/metrics/signups.rb +2 -0
- data/test/experiments/metrics/yawns.rb +3 -0
- data/test/experiments/null_abc.rb +5 -0
- data/test/metric_test.rb +518 -0
- data/test/playground_test.rb +10 -0
- data/test/rails_test.rb +104 -0
- data/test/test_helper.rb +135 -0
- data/vanity.gemspec +18 -0
- data/vendor/redis-rb/LICENSE +20 -0
- data/vendor/redis-rb/README.markdown +36 -0
- data/vendor/redis-rb/Rakefile +62 -0
- data/vendor/redis-rb/bench.rb +44 -0
- data/vendor/redis-rb/benchmarking/suite.rb +24 -0
- data/vendor/redis-rb/benchmarking/worker.rb +71 -0
- data/vendor/redis-rb/bin/distredis +33 -0
- data/vendor/redis-rb/examples/basic.rb +16 -0
- data/vendor/redis-rb/examples/incr-decr.rb +18 -0
- data/vendor/redis-rb/examples/list.rb +26 -0
- data/vendor/redis-rb/examples/sets.rb +36 -0
- data/vendor/redis-rb/lib/dist_redis.rb +124 -0
- data/vendor/redis-rb/lib/hash_ring.rb +128 -0
- data/vendor/redis-rb/lib/pipeline.rb +21 -0
- data/vendor/redis-rb/lib/redis.rb +370 -0
- data/vendor/redis-rb/lib/redis/raketasks.rb +1 -0
- data/vendor/redis-rb/profile.rb +22 -0
- data/vendor/redis-rb/redis-rb.gemspec +30 -0
- data/vendor/redis-rb/spec/redis_spec.rb +637 -0
- data/vendor/redis-rb/spec/spec_helper.rb +4 -0
- data/vendor/redis-rb/speed.rb +16 -0
- data/vendor/redis-rb/tasks/redis.tasks.rb +140 -0
- 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
|