Class: MovingCount

Inherits:
ActiveRecord::Base
  • Object
show all
Defined in:
lib/counter/moving_count.rb

Overview

Capture counts, then aggregate them across time. Think of this as an rrdtool for counts.

Setup

Inherit from this class

class PageView < MovingCount
end

Then drop the following into a migration:

create_table :page_views, :force => true do |t|
  t.string   :category,      :null => false
  t.integer  :count,         :null => false, :default => 0
  t.datetime :sample_time,   :null => false
end

add_index :page_views, :category

Two optional class-level settings:

  • set_history_to_keep, depth of history to keep in seconds - defaults to 1 hour of history

  • set_sample_interval, minimum distance between samples in seconds (enforced at record time) - defaults to 1 minute

Recording counts

PageView.record_counts(Time.at(1280420630)) do |c|
  c.increment('key1')
  c.increment('key1')
  c.increment('key2')
end

records observed counts for Thu Jul 29 12:23:50. You can omit the param to record counts at Time.now.

A runtime error will be raised if the sample is too close to the previous (for example, sample_interval is 60s and you tried to record a sample at 50s). This prevents accidental duping of counts on restarts, etc.

Checking totals

PageView.totals

returns a [key,count] array of all keys ordered by count. An optional limit param restricts results to the top n.

Class Method Summary collapse

Class Method Details

.grand_total(opts = {}) ⇒ Object

Returns single sum across all categories, limited by options. Use this to get, say, total across all categories matching “myhost…” Optional filters:

* <tt>:window</tt> limits totaled to samples to those in the past <em>n</em> seconds (can of course specify as 1.hour with ActiveSupport)
* <tt>:category_like</tt> run a LIKE match against categories before totaling, useful for limiting scope of totals.  '%' wildcards are allowed.


95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/counter/moving_count.rb', line 95

def self.grand_total opts={}
  latest_sample = self.maximum(:sample_time)
  return 0 if latest_sample.nil? # don't try to run counts against empty db

  q = "SELECT SUM(count) FROM #{self.table_name}"
  
  where = []
  where << self.sanitize_sql(['category LIKE ?', opts[:category_like]])                       if opts[:category_like]
  where << self.sanitize_sql(['sample_time > ?', latest_sample - opts[:window]]) if opts[:window]
  
  q += " WHERE #{where.join(' AND ')}" unless where.empty?
  
  self.connection.select_value(q).to_i
end

.history_to_keepObject



52
53
54
# File 'lib/counter/moving_count.rb', line 52

def self.history_to_keep
  @history_to_keep || 1.hour
end

.record_counts(timestamp = Time.now) {|c| ... } ⇒ Object

Records counts at the specified timestamp. If timestamp omitted, counts are recorded at Time.now. Yields a Counter instance, all standard methods apply. Raises an exception if the timestamp would fall too soon under the sample_interval.

Yields:

  • (c)


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/counter/moving_count.rb', line 69

def self.record_counts timestamp=Time.now, &block
 timestamp = Time.at(timestamp) if timestamp.is_a?(Integer)
 c = Counter.new
 yield(c)
 
 self.transaction do
   check_sample_valid(timestamp)

   unless c.counts.empty?
     q = "INSERT INTO #{self.table_name} (sample_time, category, count) VALUES "
     c.counts.each { |key, count| q += self.sanitize_sql(['(?, ?, ?),', timestamp, key, count]) }
     q.chop!
   
     self.connection.execute(q)
   end

   self.delete_all(['sample_time < ?', (timestamp - history_to_keep)])
 end
  
  true
end

.sample_intervalObject



62
63
64
# File 'lib/counter/moving_count.rb', line 62

def self.sample_interval
  @sample_interval || 1.minute
end

.set_history_to_keep(time_range) ⇒ Object

Sets the depth of history to keep, in seconds. Default is 1 hour.



48
49
50
# File 'lib/counter/moving_count.rb', line 48

def self.set_history_to_keep time_range
  @history_to_keep = time_range
end

.set_sample_interval(interval) ⇒ Object

Sets the minimum distance between recorded samples, in seconds. Default is 1 minute.



58
59
60
# File 'lib/counter/moving_count.rb', line 58

def self.set_sample_interval interval
  @sample_interval = interval
end

.totals(opts = {}) ⇒ Object

Returns totals grouped by category across entire history. Optional filters can be used to filter totals:

* <tt>:limit</tt> limits results to top <em>n</em>
* <tt>:window</tt> limits totaled to samples to those in the past <em>n</em> seconds (can of course specify as 1.hour with ActiveSupport)
* <tt>:category_like</tt> run a LIKE match against categories before totaling, useful for limiting scope of totals.  '%' wildcards are allowed.


115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/counter/moving_count.rb', line 115

def self.totals opts={}
  latest_sample = self.maximum(:sample_time)
  return [] if latest_sample.nil? # don't try to run counts against empty db
  
  q  = "SELECT category, SUM(count) AS cnt FROM #{self.table_name}"
  
  where = []
  where << self.sanitize_sql(['category LIKE ?', opts[:category_like]])                       if opts[:category_like]
  where << self.sanitize_sql(['sample_time > ?', latest_sample - opts[:window]]) if opts[:window]
  
  q += " WHERE #{where.join(' AND ')}" unless where.empty?
  
  q += ' GROUP BY category'
  q += ' ORDER BY cnt DESC'
  q += " LIMIT #{opts[:limit]}" if opts[:limit]
  
  values = self.connection.select_rows(q)
  values.map { |v| v[1] = v[1].to_i }
  
  return values
end