counter 0.0.5
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/.document +5 -0
- data/.gitignore +5 -0
- data/LICENSE +13 -0
- data/README.rdoc +82 -0
- data/Rakefile +57 -0
- data/VERSION +1 -0
- data/counter.gemspec +59 -0
- data/lib/counter.rb +1 -0
- data/lib/counter/counter.rb +76 -0
- data/lib/counter/moving_count.rb +105 -0
- data/test/schema.rb +18 -0
- data/test/test_counter.rb +50 -0
- data/test/test_helper.rb +28 -0
- data/test/test_moving_count.rb +115 -0
- metadata +81 -0
data/.document
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
Copyright (c) 2010 The New York Times Company
|
2
|
+
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
you may not use this library except in compliance with the License.
|
5
|
+
You may obtain a copy of the License at
|
6
|
+
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
See the License for the specific language governing permissions and
|
13
|
+
limitations under the License.
|
data/README.rdoc
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
= counter
|
2
|
+
|
3
|
+
Count things and rank them, as a one-off or over time.
|
4
|
+
|
5
|
+
Major components:
|
6
|
+
* Counter - a simple class to track counts, then sort and rank
|
7
|
+
* MovingCount - a base class built atop ActiveRecord to aggregate counts over time (think {rrdtool}[http://oss.oetiker.ch/rrdtool/] for counts)
|
8
|
+
|
9
|
+
|
10
|
+
=== Getting started
|
11
|
+
|
12
|
+
gem install counter
|
13
|
+
config.gem 'counter'
|
14
|
+
|
15
|
+
require 'counter'
|
16
|
+
|
17
|
+
To use MovingCount, you'll also need to
|
18
|
+
require 'counter/moving_count'
|
19
|
+
|
20
|
+
(separate to prevent ActiveRecord dependency pollution)
|
21
|
+
|
22
|
+
=== Counter
|
23
|
+
|
24
|
+
c = Counter.new
|
25
|
+
|
26
|
+
# Counts can be incremented as a key appears
|
27
|
+
c.increment('some-key')
|
28
|
+
c.increment('some-key')
|
29
|
+
|
30
|
+
# Or set directly
|
31
|
+
c.set('another-key', 42)
|
32
|
+
|
33
|
+
# You can retrieve all
|
34
|
+
c.counts
|
35
|
+
=> [['another-key',42],['some-key',2]]
|
36
|
+
|
37
|
+
# Or just the top-n
|
38
|
+
c.top(1)
|
39
|
+
=> [['another-key',42]]
|
40
|
+
|
41
|
+
See Counter docs for detail.
|
42
|
+
|
43
|
+
=== MovingCount
|
44
|
+
|
45
|
+
require 'counter/moving_count'
|
46
|
+
class PageView < MovingCount
|
47
|
+
end
|
48
|
+
|
49
|
+
# In a migration:
|
50
|
+
# create_table :page_views, :force => true do |t|
|
51
|
+
# t.string :category, :null => false
|
52
|
+
# t.integer :count, :null => false, :default => 0
|
53
|
+
# t.datetime :sample_time, :null => false
|
54
|
+
# end
|
55
|
+
#
|
56
|
+
# add_index :page_views, :category
|
57
|
+
|
58
|
+
# First set of samples...
|
59
|
+
PageView.record_counts(Time.now - 5.minutes) do |c|
|
60
|
+
c.increment('a-key')
|
61
|
+
c.increment('a-key')
|
62
|
+
end
|
63
|
+
|
64
|
+
# Second set...
|
65
|
+
PageView.record_counts(Time.now) do |c|
|
66
|
+
c.increment('a-key')
|
67
|
+
c.increment('another-key')
|
68
|
+
end
|
69
|
+
|
70
|
+
# Contribute to totals.
|
71
|
+
PageView.totals
|
72
|
+
=> [['a-key',3],['another-key',1]]
|
73
|
+
|
74
|
+
See MovingCount docs for detail.
|
75
|
+
|
76
|
+
== Author
|
77
|
+
|
78
|
+
Ben Koski, bkoski@nytimes.com
|
79
|
+
|
80
|
+
== Copyright
|
81
|
+
|
82
|
+
Copyright (c) 2010 The New York Times Company. See LICENSE for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "counter"
|
8
|
+
gem.summary = %Q{count things, either as a one-off or aggregated over time}
|
9
|
+
gem.description = %Q{count things, either as a one-off or aggregated over time}
|
10
|
+
gem.email = "bkoski@nytimes.com"
|
11
|
+
gem.homepage = "http://github.com/nytimes/counter"
|
12
|
+
gem.authors = ["Ben Koski"]
|
13
|
+
gem.add_development_dependency "thoughtbot-shoulda"
|
14
|
+
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
15
|
+
end
|
16
|
+
Jeweler::GemcutterTasks.new
|
17
|
+
rescue LoadError
|
18
|
+
puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
|
19
|
+
end
|
20
|
+
|
21
|
+
require 'rake/testtask'
|
22
|
+
Rake::TestTask.new(:test) do |test|
|
23
|
+
test.libs << 'lib' << 'test'
|
24
|
+
test.pattern = 'test/**/*_test.rb'
|
25
|
+
test.verbose = true
|
26
|
+
end
|
27
|
+
|
28
|
+
begin
|
29
|
+
require 'rcov/rcovtask'
|
30
|
+
Rcov::RcovTask.new do |test|
|
31
|
+
test.libs << 'test'
|
32
|
+
test.pattern = 'test/**/*_test.rb'
|
33
|
+
test.verbose = true
|
34
|
+
end
|
35
|
+
rescue LoadError
|
36
|
+
task :rcov do
|
37
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
task :test => :check_dependencies
|
42
|
+
|
43
|
+
task :default => :test
|
44
|
+
|
45
|
+
require 'rake/rdoctask'
|
46
|
+
Rake::RDocTask.new do |rdoc|
|
47
|
+
if File.exist?('VERSION')
|
48
|
+
version = File.read('VERSION')
|
49
|
+
else
|
50
|
+
version = ""
|
51
|
+
end
|
52
|
+
|
53
|
+
rdoc.rdoc_dir = 'rdoc'
|
54
|
+
rdoc.title = "counter #{version}"
|
55
|
+
rdoc.rdoc_files.include('README*')
|
56
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
57
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.0.5
|
data/counter.gemspec
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run `rake gemspec`
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{counter}
|
8
|
+
s.version = "0.0.5"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Ben Koski"]
|
12
|
+
s.date = %q{2010-07-29}
|
13
|
+
s.description = %q{count things, either as a one-off or aggregated over time}
|
14
|
+
s.email = %q{bkoski@nytimes.com}
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"LICENSE",
|
17
|
+
"README.rdoc"
|
18
|
+
]
|
19
|
+
s.files = [
|
20
|
+
".document",
|
21
|
+
".gitignore",
|
22
|
+
"LICENSE",
|
23
|
+
"README.rdoc",
|
24
|
+
"Rakefile",
|
25
|
+
"VERSION",
|
26
|
+
"counter.gemspec",
|
27
|
+
"lib/counter.rb",
|
28
|
+
"lib/counter/counter.rb",
|
29
|
+
"lib/counter/moving_count.rb",
|
30
|
+
"test/schema.rb",
|
31
|
+
"test/test_counter.rb",
|
32
|
+
"test/test_helper.rb",
|
33
|
+
"test/test_moving_count.rb"
|
34
|
+
]
|
35
|
+
s.homepage = %q{http://github.com/nytimes/counter}
|
36
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
37
|
+
s.require_paths = ["lib"]
|
38
|
+
s.rubygems_version = %q{1.3.5}
|
39
|
+
s.summary = %q{count things, either as a one-off or aggregated over time}
|
40
|
+
s.test_files = [
|
41
|
+
"test/schema.rb",
|
42
|
+
"test/test_counter.rb",
|
43
|
+
"test/test_helper.rb",
|
44
|
+
"test/test_moving_count.rb"
|
45
|
+
]
|
46
|
+
|
47
|
+
if s.respond_to? :specification_version then
|
48
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
49
|
+
s.specification_version = 3
|
50
|
+
|
51
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
52
|
+
s.add_development_dependency(%q<thoughtbot-shoulda>, [">= 0"])
|
53
|
+
else
|
54
|
+
s.add_dependency(%q<thoughtbot-shoulda>, [">= 0"])
|
55
|
+
end
|
56
|
+
else
|
57
|
+
s.add_dependency(%q<thoughtbot-shoulda>, [">= 0"])
|
58
|
+
end
|
59
|
+
end
|
data/lib/counter.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'counter/counter'
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# Basic counting.
|
2
|
+
#
|
3
|
+
# === To count:
|
4
|
+
#
|
5
|
+
# Easiest way to count is to increment while iterating over data:
|
6
|
+
#
|
7
|
+
# c = Counter.new
|
8
|
+
# c.increment('some-key')
|
9
|
+
# c.saw('some-key') # alias
|
10
|
+
#
|
11
|
+
# But you can also set counts directly:
|
12
|
+
# c = Counter.new
|
13
|
+
# c.set('another-key', 42)
|
14
|
+
#
|
15
|
+
# === To get counts back:
|
16
|
+
#
|
17
|
+
# You can use Hash-style syntax:
|
18
|
+
# puts c['some-key]
|
19
|
+
# => 2
|
20
|
+
#
|
21
|
+
# or ask for the top-n keys as an array of [key, count]
|
22
|
+
#
|
23
|
+
# puts c.top(2)
|
24
|
+
# => [['key-1',50],['key-2',38]]
|
25
|
+
#
|
26
|
+
# or just ask for data for all keys:
|
27
|
+
# puts c.counts
|
28
|
+
# => [['key-1',50],['key-2',38],['key-3',22]]
|
29
|
+
#
|
30
|
+
# === Some notes
|
31
|
+
# * 0 is returned, even if key has never been seen
|
32
|
+
# * if ties exist in a top-n ranking, they are broken by sort on key
|
33
|
+
class Counter
|
34
|
+
|
35
|
+
def initialize
|
36
|
+
@data = Hash.new(0)
|
37
|
+
end
|
38
|
+
|
39
|
+
# key++
|
40
|
+
def increment key
|
41
|
+
@data[key] += 1
|
42
|
+
true
|
43
|
+
end
|
44
|
+
alias_method :saw, :increment
|
45
|
+
|
46
|
+
# sets the key to a specified value
|
47
|
+
def set key, count
|
48
|
+
raise ArgumentError, "count must be numeric" if !count.is_a?(Numeric)
|
49
|
+
@data[key] = count
|
50
|
+
end
|
51
|
+
|
52
|
+
# Retrieve the count for a particular key
|
53
|
+
def [] key
|
54
|
+
@data[key]
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns all elements as [key, count] array
|
58
|
+
def counts
|
59
|
+
sorted_data
|
60
|
+
end
|
61
|
+
|
62
|
+
# Returns top <tt>n</tt> elements as [key,count] array
|
63
|
+
def top n
|
64
|
+
sorted_data[0,n]
|
65
|
+
end
|
66
|
+
|
67
|
+
def to_a
|
68
|
+
@data.to_a
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
def sorted_data
|
73
|
+
@data.to_a.sort { |a,b| a[1] != b[1] ? a[1] <=> b[1] : b[0] <=> a[0] }.reverse
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
|
3
|
+
# Capture counts, then aggregate them across time. Think of this as an rrdtool for counts.
|
4
|
+
#
|
5
|
+
# === Setup
|
6
|
+
# Inherit from this class
|
7
|
+
#
|
8
|
+
# class PageView < MovingCount
|
9
|
+
# end
|
10
|
+
#
|
11
|
+
# Then drop the following into a migration:
|
12
|
+
#
|
13
|
+
# create_table :page_views, :force => true do |t|
|
14
|
+
# t.string :category, :null => false
|
15
|
+
# t.integer :count, :null => false, :default => 0
|
16
|
+
# t.datetime :sample_time, :null => false
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# add_index :page_views, :category
|
20
|
+
#
|
21
|
+
# Two optional class-level settings:
|
22
|
+
# * *set_history_to_keep*, depth of history to keep in seconds - defaults to 1 hour of history
|
23
|
+
# * *set_sample_interval*, minimum distance between samples in seconds (enforced at record time) - defaults to 1 minute
|
24
|
+
#
|
25
|
+
# === Recording counts
|
26
|
+
#
|
27
|
+
# PageView.record_counts(Time.at(1280420630)) do |c|
|
28
|
+
# c.increment('key1')
|
29
|
+
# c.increment('key1')
|
30
|
+
# c.increment('key2')
|
31
|
+
# end
|
32
|
+
#
|
33
|
+
# records observed counts for Thu Jul 29 12:23:50. You can omit the param to record counts at Time.now.
|
34
|
+
#
|
35
|
+
# 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
|
36
|
+
# a sample at 50s). This prevents accidental duping of counts on restarts, etc.
|
37
|
+
#
|
38
|
+
# === Checking totals
|
39
|
+
#
|
40
|
+
# PageView.totals
|
41
|
+
#
|
42
|
+
# returns a [key,count] array of all keys ordered by count. An optional <tt>limit</tt> param restricts results to the top n.
|
43
|
+
class MovingCount < ActiveRecord::Base
|
44
|
+
self.abstract_class = true
|
45
|
+
|
46
|
+
# Sets the depth of history to keep, in seconds.
|
47
|
+
# Default is 1 hour.
|
48
|
+
def self.set_history_to_keep time_range
|
49
|
+
@history_to_keep = time_range
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.history_to_keep
|
53
|
+
@history_to_keep || 1.hour
|
54
|
+
end
|
55
|
+
|
56
|
+
# Sets the minimum distance between recorded samples, in seconds.
|
57
|
+
# Default is 1 minute.
|
58
|
+
def self.set_sample_interval interval
|
59
|
+
@sample_interval = interval
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.sample_interval
|
63
|
+
@sample_interval || 1.minute
|
64
|
+
end
|
65
|
+
|
66
|
+
# Records counts at the specified timestamp. If timestamp omitted, counts are recorded at Time.now.
|
67
|
+
# Yields a Counter instance, all standard methods apply.
|
68
|
+
# Raises an exception if the timestamp would fall too soon under the <tt>sample_interval</tt>.
|
69
|
+
def self.record_counts timestamp=Time.now, &block
|
70
|
+
c = Counter.new
|
71
|
+
yield(c)
|
72
|
+
|
73
|
+
self.transaction do
|
74
|
+
check_sample_valid(timestamp)
|
75
|
+
|
76
|
+
q = "INSERT INTO #{self.table_name} (sample_time, category, count) VALUES "
|
77
|
+
c.counts.each { |key, count| q += self.sanitize_sql(['(?, ?, ?),', timestamp, key, count]) }
|
78
|
+
q.chop!
|
79
|
+
|
80
|
+
self.connection.execute(q)
|
81
|
+
self.delete_all(['sample_time < ?', (timestamp - history_to_keep)])
|
82
|
+
end
|
83
|
+
|
84
|
+
true
|
85
|
+
end
|
86
|
+
|
87
|
+
# Returns totals across entire history. Optional limit param restricts resultset.
|
88
|
+
def self.totals limit=nil
|
89
|
+
values = self.connection.select_rows("SELECT category, SUM(count) AS cnt FROM #{self.table_name} GROUP BY category ORDER BY cnt DESC #{"LIMIT #{limit}" if limit};")
|
90
|
+
values.map { |v| v[1] = v[1].to_i }
|
91
|
+
return values
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
def self.check_sample_valid timestamp
|
96
|
+
latest_sample = self.maximum(:sample_time)
|
97
|
+
return if latest_sample.nil? # sample is of course valid if no data exists
|
98
|
+
|
99
|
+
distance = (timestamp - latest_sample).abs
|
100
|
+
if distance < sample_interval
|
101
|
+
raise "Data recorded at #{self.maximum(:sample_time)} making #{timestamp} #{'%0.2f' % (sample_interval - distance)}s too soon on a #{sample_interval} interval."
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
data/test/schema.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
ActiveRecord::Schema.define(:version => 1) do
|
2
|
+
|
3
|
+
create_table :page_views, :force => true do |t|
|
4
|
+
t.string :category, :null => false
|
5
|
+
t.integer :count, :null => false, :default => 0
|
6
|
+
t.datetime :sample_time, :null => false
|
7
|
+
end
|
8
|
+
|
9
|
+
add_index :page_views, :category
|
10
|
+
|
11
|
+
create_table :clicks, :force => true do |t|
|
12
|
+
t.string :category, :null => false
|
13
|
+
t.integer :count, :null => false, :default => 0
|
14
|
+
t.datetime :sample_time, :null => false
|
15
|
+
end
|
16
|
+
|
17
|
+
|
18
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class CounterTest < Test::Unit::TestCase
|
4
|
+
|
5
|
+
def setup
|
6
|
+
@c = Counter.new
|
7
|
+
end
|
8
|
+
|
9
|
+
should "increment count every time increment is called for a key" do
|
10
|
+
@c.increment('some-key')
|
11
|
+
@c.increment('some-key')
|
12
|
+
assert_equal 2, @c['some-key']
|
13
|
+
end
|
14
|
+
|
15
|
+
should "return 0 even if key has never been incremented" do
|
16
|
+
assert_equal 0, @c['some-key']
|
17
|
+
end
|
18
|
+
|
19
|
+
should "return count directly if it was set" do
|
20
|
+
@c.set('some-key', 65)
|
21
|
+
assert_equal 65, @c['some-key']
|
22
|
+
end
|
23
|
+
|
24
|
+
should "raise an ArgumentError if set called with a non-numeric" do
|
25
|
+
assert_raises(ArgumentError) { @c.set('some-key', Time.now) }
|
26
|
+
end
|
27
|
+
|
28
|
+
should "return counts as a [[key,cnt]] array" do
|
29
|
+
@c.set('some-key', 32)
|
30
|
+
@c.set('another-key', 45)
|
31
|
+
|
32
|
+
assert_equal [['another-key',45],['some-key',32]], @c.counts
|
33
|
+
end
|
34
|
+
|
35
|
+
should "return top(n) as a [[key,cnt]] array of top n objects based on count" do
|
36
|
+
@c.set('some-key', 32)
|
37
|
+
@c.set('another-key', 45)
|
38
|
+
@c.set('some-key-3', 90)
|
39
|
+
|
40
|
+
assert_equal [['some-key-3',90],['another-key',45]], @c.top(2)
|
41
|
+
end
|
42
|
+
|
43
|
+
should "break top(n) ties on key name" do
|
44
|
+
@c.set('some-key-1', 34)
|
45
|
+
@c.set('some-key-2', 34)
|
46
|
+
|
47
|
+
assert_equal [['some-key-1', 34]], @c.top(1)
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'test/unit'
|
3
|
+
require 'shoulda'
|
4
|
+
require 'mocha'
|
5
|
+
require 'timecop'
|
6
|
+
|
7
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
8
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
9
|
+
require 'counter'
|
10
|
+
|
11
|
+
class Test::Unit::TestCase
|
12
|
+
end
|
13
|
+
|
14
|
+
require 'counter/moving_count'
|
15
|
+
|
16
|
+
require 'active_record'
|
17
|
+
ActiveRecord::Base.establish_connection :adapter => 'mysql', :database => 'counter_test'
|
18
|
+
silence_stream(STDOUT) do
|
19
|
+
load(File.dirname(__FILE__) + "/schema.rb")
|
20
|
+
end
|
21
|
+
|
22
|
+
class PageView < MovingCount
|
23
|
+
end
|
24
|
+
|
25
|
+
class Click < MovingCount
|
26
|
+
set_sample_interval 5.seconds
|
27
|
+
set_history_to_keep 2.minutes
|
28
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class MovingCountTest < Test::Unit::TestCase
|
4
|
+
|
5
|
+
def setup
|
6
|
+
Timecop.return
|
7
|
+
[PageView, Click].each { |m| m.delete_all }
|
8
|
+
end
|
9
|
+
|
10
|
+
context "record" do
|
11
|
+
|
12
|
+
should "save counts to the database" do
|
13
|
+
PageView.record_counts do |c|
|
14
|
+
c.saw('http://www.nytimes.com')
|
15
|
+
c.saw('http://www.nytimes.com')
|
16
|
+
c.saw('http://www.nytimes.com/article.html')
|
17
|
+
end
|
18
|
+
|
19
|
+
assert_equal 2, PageView.find_by_category('http://www.nytimes.com').count
|
20
|
+
assert_equal 1, PageView.find_by_category('http://www.nytimes.com/article.html').count
|
21
|
+
end
|
22
|
+
|
23
|
+
context "sample interval" do
|
24
|
+
should "raise a RuntimeError to enforce sample interval" do
|
25
|
+
setup_existing_counts(59)
|
26
|
+
assert_raises(RuntimeError) { PageView.record_counts { |c| c.saw('http://www.nytimes.com') } }
|
27
|
+
end
|
28
|
+
|
29
|
+
should "default sample interval to 60 seconds" do
|
30
|
+
setup_existing_counts(60)
|
31
|
+
assert_nothing_raised { PageView.record_counts { |c| c.saw('http://www.nytimes.com') } }
|
32
|
+
end
|
33
|
+
|
34
|
+
should "respect custom sample interval" do
|
35
|
+
setup_existing_counts(5, Click)
|
36
|
+
assert_nothing_raised { Click.record_counts { |c| c.saw('http://www.nytimes.com') } }
|
37
|
+
end
|
38
|
+
|
39
|
+
should "allow samples to be backfilled" do
|
40
|
+
setup_existing_counts(60)
|
41
|
+
assert_nothing_raised { PageView.record_counts(Time.now - 3600) { |c| c.saw('http://www.nytimes.com') } }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
context "sample_time" do
|
46
|
+
should "record provided timestamp as sample time" do
|
47
|
+
stamp = Time.now - 300
|
48
|
+
PageView.record_counts(stamp) do |c|
|
49
|
+
c.saw('http://www.nytimes.com')
|
50
|
+
c.saw('http://www.nytimes.com/article.html')
|
51
|
+
end
|
52
|
+
|
53
|
+
assert_equal 2, PageView.count(:conditions => {:sample_time => stamp})
|
54
|
+
end
|
55
|
+
|
56
|
+
should "use Time.now as the timestamp if none specified" do
|
57
|
+
Timecop.freeze
|
58
|
+
|
59
|
+
PageView.record_counts do |c|
|
60
|
+
c.saw('http://www.nytimes.com')
|
61
|
+
c.saw('http://www.nytimes.com/article.html')
|
62
|
+
end
|
63
|
+
|
64
|
+
assert_equal 2, PageView.count(:conditions => {:sample_time => Time.now})
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
context "purge" do
|
69
|
+
should "purge data older than history_to_keep" do
|
70
|
+
setup_existing_counts(2.hours)
|
71
|
+
PageView.record_counts { |c| c.saw('http://www.nytimes.com') }
|
72
|
+
assert_equal 1, PageView.count
|
73
|
+
end
|
74
|
+
|
75
|
+
should "set default history_to_keep at 1 hour" do
|
76
|
+
setup_existing_counts(59.minutes)
|
77
|
+
PageView.record_counts { |c| c.saw('http://www.nytimes.com') }
|
78
|
+
assert_equal 2, PageView.count
|
79
|
+
end
|
80
|
+
|
81
|
+
should "respect custom history_to_keep" do
|
82
|
+
setup_existing_counts(5.minutes, Click)
|
83
|
+
Click.record_counts { |c| c.saw('http://www.nytimes.com') }
|
84
|
+
assert_equal 1, Click.count
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
|
89
|
+
context "totals" do
|
90
|
+
should "returns totals across all samples in the history" do
|
91
|
+
setup_existing_counts(10.minutes)
|
92
|
+
setup_existing_counts(5.minutes)
|
93
|
+
PageView.record_counts { |c| c.saw('http://www.nytimes.com'); c.saw('http://www.nytimes.com/article.html') }
|
94
|
+
|
95
|
+
assert_equal [['http://www.nytimes.com',3],['http://www.nytimes.com/article.html',1]], PageView.totals
|
96
|
+
end
|
97
|
+
|
98
|
+
should "respect limit if provided" do
|
99
|
+
setup_existing_counts(10.minutes)
|
100
|
+
setup_existing_counts(5.minutes)
|
101
|
+
PageView.record_counts { |c| c.saw('http://www.nytimes.com'); c.saw('http://www.nytimes.com/article.html') }
|
102
|
+
|
103
|
+
assert_equal [['http://www.nytimes.com',3]], PageView.totals(1)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
|
109
|
+
def setup_existing_counts distance, model=PageView
|
110
|
+
Timecop.freeze(Time.now - distance)
|
111
|
+
model.record_counts { |c| c.saw('http://www.nytimes.com') }
|
112
|
+
Timecop.freeze(Time.now + distance)
|
113
|
+
end
|
114
|
+
|
115
|
+
end
|
metadata
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: counter
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.5
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ben Koski
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2010-07-29 00:00:00 -04:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: thoughtbot-shoulda
|
17
|
+
type: :development
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0"
|
24
|
+
version:
|
25
|
+
description: count things, either as a one-off or aggregated over time
|
26
|
+
email: bkoski@nytimes.com
|
27
|
+
executables: []
|
28
|
+
|
29
|
+
extensions: []
|
30
|
+
|
31
|
+
extra_rdoc_files:
|
32
|
+
- LICENSE
|
33
|
+
- README.rdoc
|
34
|
+
files:
|
35
|
+
- .document
|
36
|
+
- .gitignore
|
37
|
+
- LICENSE
|
38
|
+
- README.rdoc
|
39
|
+
- Rakefile
|
40
|
+
- VERSION
|
41
|
+
- counter.gemspec
|
42
|
+
- lib/counter.rb
|
43
|
+
- lib/counter/counter.rb
|
44
|
+
- lib/counter/moving_count.rb
|
45
|
+
- test/schema.rb
|
46
|
+
- test/test_counter.rb
|
47
|
+
- test/test_helper.rb
|
48
|
+
- test/test_moving_count.rb
|
49
|
+
has_rdoc: true
|
50
|
+
homepage: http://github.com/nytimes/counter
|
51
|
+
licenses: []
|
52
|
+
|
53
|
+
post_install_message:
|
54
|
+
rdoc_options:
|
55
|
+
- --charset=UTF-8
|
56
|
+
require_paths:
|
57
|
+
- lib
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
59
|
+
requirements:
|
60
|
+
- - ">="
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: "0"
|
63
|
+
version:
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: "0"
|
69
|
+
version:
|
70
|
+
requirements: []
|
71
|
+
|
72
|
+
rubyforge_project:
|
73
|
+
rubygems_version: 1.3.5
|
74
|
+
signing_key:
|
75
|
+
specification_version: 3
|
76
|
+
summary: count things, either as a one-off or aggregated over time
|
77
|
+
test_files:
|
78
|
+
- test/schema.rb
|
79
|
+
- test/test_counter.rb
|
80
|
+
- test/test_helper.rb
|
81
|
+
- test/test_moving_count.rb
|