tally_counter 0.0.1

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/.gitignore ADDED
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .rvmrc
4
+ .bundle
5
+ .config
6
+ .yardoc
7
+ Gemfile.lock
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in tally_counter.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Brendon Murphy
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # TallyCounter
2
+
3
+ Tally web application hits with Rack & Redis sorted sets
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'tally_counter'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install tally_counter
18
+
19
+ ## Usage
20
+
21
+ In your `config.ru` or middleware configuration:
22
+
23
+ ```ruby
24
+ use TallyCounter::Middleware
25
+
26
+ # Options
27
+
28
+ # Provide a Redis instance
29
+ use TallyCounter::Middleware, :redis => $redis
30
+
31
+ # Use a 15 minute interval window
32
+ use TallyCounter::Middleware, :interval => 900
33
+
34
+ # Use a namespace prefix for keys
35
+ use TallyCounter::Middleware, :namespace => 'my_app_name'
36
+
37
+ # Timeout to Redis in 0.001 seconds
38
+ use TallyCounter::Middleware, :timeout => 0.001
39
+
40
+ # Inject a logger
41
+ use TallyCounter::Middleware, :logger => some_logger
42
+ ```
43
+
44
+ It is adviseable you configure `TallyCounter::Middleware` before
45
+ your main application (so Rails, Sinatra, etc) but after your
46
+ static/cache layer. You probably don't want to be tracking hits
47
+ against CSS, js, etc.
48
+
49
+ If you wish to avoid counting actions from further down the stack,
50
+ you may inject a response header:
51
+
52
+ ```ruby
53
+ headers['X-Tally-Counter-Skip'] = 'true'
54
+ ```
55
+
56
+ Note the mere presence of the header and not its value is enough
57
+ to cause a count skip.
58
+
59
+ ## Keys and Scoring
60
+
61
+ The system uses Redis sorted sets for tracking application hits.
62
+ For each request, a key will be counted, and the score for the
63
+ remote request ip will be incremented by 1.
64
+
65
+ Keys are generated like 'tally_counter:1371283200' where the time
66
+ is the epoch seconds floor for the current window. The floor for
67
+ a 5 minute interval at 12:38 would be 12:35, at 12:33 its 12:30,
68
+ and so on.
69
+
70
+ It is recommended to use a scheduled process to inspect tally_counter
71
+ sets past a certain age (say, 3 days) and prune them to keep your
72
+ data set small and clean.
73
+
74
+ Finding totals for a range of time can be accomplished via a Redis
75
+ zunionstore on a range of keys. For example, if you have a a 5
76
+ minute interval and want to see the last 15 minutes, simple grab
77
+ the current window and the 2 previous and union them with equally
78
+ weighted scoring. See `TallyCounter::Window#floow` for generating
79
+ window times and offsets.
80
+
81
+ ## Reporting
82
+
83
+ In the interest of giving this gem a single responsibility, reporting
84
+ can be offloaded to other systems. It should be easy to deploy
85
+ a separate admin application conneted to the same server, and use
86
+ the `TallyCounter::Window` class for generating keys.
87
+
88
+ ## Todo
89
+
90
+ Extract key generation to a utility for use by middleware and client
91
+ apps.
92
+
93
+ ## Contributing
94
+
95
+ 1. Fork it
96
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
97
+ 3. Commit your changes (`git commit -am 'Added some feature'`)
98
+ 4. Push to the branch (`git push origin my-new-feature`)
99
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env rake
2
+
3
+ require "bundler/gem_tasks"
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new do |t|
7
+ t.libs.push "lib"
8
+ t.test_files = FileList['test/*_test.rb']
9
+ t.verbose = true
10
+ end
@@ -0,0 +1,3 @@
1
+ module TallyCounter
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,79 @@
1
+ require "tally_counter/version"
2
+ require "logger"
3
+ require "redis"
4
+ require "timeout"
5
+
6
+ module TallyCounter
7
+ class Window
8
+
9
+ attr_reader :interval
10
+
11
+ # @param [Integer] seconds of granularity for the window
12
+ def initialize(interval)
13
+ @interval = interval
14
+ end
15
+
16
+ # Returns the floor time for a given window. For example,
17
+ # if the interval is 5 minutes and it is 12:38, the floow
18
+ # would be 12:35. If offset is 1, the floor would be 12:30
19
+ #
20
+ # @param [Time] a time instance, commonly Time.now
21
+ # @param [offet] an integer of windows step back to
22
+ def floor(time, offset = 0)
23
+ epoch_time = time.to_i - interval * offset
24
+ Time.at((epoch_time / interval) * interval)
25
+ end
26
+
27
+ end
28
+
29
+ class Middleware
30
+
31
+ # @param app - a rack compliant app
32
+ # @param options
33
+ # @option options :interval time in seconds for window granularity
34
+ # @option options :logger a logger instance
35
+ # @option options :namespace optional redis key namespace, like 'app_name'
36
+ # @option options :redis a redis instance
37
+ # @option options :timeout seconds before timing out a redis command
38
+ def initialize(app, options = {})
39
+ @app = app
40
+ @interval = options[:interval] || 300
41
+ @logger = options[:logger] || Logger.new($stdout)
42
+ @namespace = options[:namespace]
43
+ @redis = options[:redis] || Redis.current
44
+ @timeout = options[:timeout] || 0.007
45
+ end
46
+
47
+ def call(env)
48
+ tuple = @app.call(env)
49
+
50
+ unless tuple[1].delete('X-Tally-Counter-Skip')
51
+ req = Rack::Request.new(env)
52
+ track_ip(req.ip)
53
+ end
54
+
55
+ tuple
56
+ end
57
+
58
+ private
59
+
60
+ def track_ip(ip)
61
+ begin
62
+ Timeout::timeout(@timeout) do
63
+ @redis.zincrby current_key, 1, ip.to_s
64
+ end
65
+ rescue Timeout::Error, Redis::BaseError => e
66
+ @logger.error e.message
67
+ end
68
+ end
69
+
70
+ def current_key
71
+ [@namespace, "tally_counter", window_floor].compact.join(':')
72
+ end
73
+
74
+ def window_floor
75
+ TallyCounter::Window.new(@interval).floor(Time.now).to_i
76
+ end
77
+
78
+ end
79
+ end
@@ -0,0 +1,20 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/tally_counter/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Brendon Murphy"]
6
+ gem.email = ["xternal1+github@gmail.com"]
7
+ gem.summary = %q{Tally web application hits with Rack & Redis sorted sets}
8
+ gem.description = gem.summary
9
+ gem.homepage = ""
10
+
11
+ gem.files = `git ls-files`.split($\)
12
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
13
+ gem.test_files = gem.files.grep(%r{^test/})
14
+ gem.name = "tally_counter"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = TallyCounter::VERSION
17
+
18
+ gem.add_dependency "redis"
19
+ gem.add_development_dependency "rack-test"
20
+ end
@@ -0,0 +1,130 @@
1
+ require "test/unit"
2
+ require "rack/test"
3
+ require File.expand_path('../../lib/tally_counter', __FILE__)
4
+
5
+ module TestHelper
6
+ def test(name, &block)
7
+ name = name.gsub(/\W+/, ' ').strip
8
+ test_name = "test_#{name.gsub(/\s+/,'_')}".to_sym
9
+ define_method(test_name, &block)
10
+ end
11
+ end
12
+
13
+ class TallyCounterWindowTest < Test::Unit::TestCase
14
+ extend TestHelper
15
+
16
+ test "initializing with interval seconds" do
17
+ window = TallyCounter::Window.new(60)
18
+ assert_equal 60, window.interval
19
+ end
20
+
21
+ test "calculating the floor for a given time" do
22
+ window = TallyCounter::Window.new(300)
23
+ expected = Time.parse('2013-06-15 00:25:00 -0700')
24
+ now = Time.parse('2013-06-15 00:28:36 -0700')
25
+ assert_equal expected, window.floor(now)
26
+ end
27
+
28
+ test "calculating the floor for a given time with an offset" do
29
+ window = TallyCounter::Window.new(300)
30
+
31
+ expected = Time.parse('2013-06-15 00:20:00 -0700')
32
+ now = Time.parse('2013-06-15 00:28:36 -0700')
33
+ assert_equal expected, window.floor(now, 1)
34
+
35
+ expected = Time.parse('2013-06-15 00:15:00 -0700')
36
+ now = Time.parse('2013-06-15 00:28:36 -0700')
37
+ assert_equal expected, window.floor(now, 2)
38
+ end
39
+ end
40
+
41
+ class TallyCounterMiddlewareTest < Test::Unit::TestCase
42
+ extend TestHelper
43
+ include Rack::Test::Methods
44
+
45
+ def redis
46
+ @redis ||= Redis.new :db => 14
47
+ end
48
+
49
+ def app_options
50
+ options = @app_options || {}
51
+ {:redis => redis}.merge(options)
52
+ end
53
+
54
+ def app_options=(options)
55
+ @app_options = options
56
+ end
57
+
58
+ def app
59
+ builder = Rack::Builder.new
60
+ builder.use TallyCounter::Middleware, app_options
61
+ builder.run lambda { |env|
62
+ req = Rack::Request.new(env)
63
+ headers = {}
64
+
65
+ if req.params['skip']
66
+ headers['X-Tally-Counter-Skip'] = 'true'
67
+ end
68
+
69
+ [200, headers, ['ok']]
70
+ }
71
+ builder.to_app
72
+ end
73
+
74
+ test "incrementing the count for the remote address" do
75
+ window = TallyCounter::Window.new(300)
76
+ key = "tally_counter:#{window.floor(Time.now).to_i}"
77
+ redis.del key, "127.0.0.1"
78
+ get "/"
79
+ assert_equal 1.0, redis.zscore(key, "127.0.0.1")
80
+ get "/"
81
+ assert_equal 2.0, redis.zscore(key, "127.0.0.1")
82
+ end
83
+
84
+ test "providing a namespace for the keys" do
85
+ window = TallyCounter::Window.new(300)
86
+ key = "foobar:tally_counter:#{window.floor(Time.now).to_i}"
87
+ redis.del key
88
+ self.app_options = {:namespace => 'foobar'}
89
+ get "/"
90
+ assert_equal 1.0, redis.zscore(key, "127.0.0.1")
91
+ end
92
+
93
+ test "skipping incrementing when X-Skip-Tally-Counter header is present" do
94
+ window = TallyCounter::Window.new(300)
95
+ key = "tally_counter:#{window.floor(Time.now).to_i}"
96
+ redis.del key, "127.0.0.1"
97
+ get "/?skip=true"
98
+ assert_nil redis.zscore(key, "127.0.0.1")
99
+ end
100
+
101
+ test "passing through to the next app in middleware chain" do
102
+ get "/"
103
+ assert_match "ok", last_response.body
104
+ end
105
+
106
+ test "tolerating a timeout error" do
107
+ fake_redis = Object.new.tap do |r|
108
+ def r.zincrby(*)
109
+ sleep 5
110
+ end
111
+ end
112
+ self.app_options = {:redis => fake_redis}
113
+
114
+ assert_nothing_raised { get "/" }
115
+ assert_match "ok", last_response.body
116
+ end
117
+
118
+ test "tolerating a redis error" do
119
+ fake_redis = Object.new.tap do |r|
120
+ def r.zincrby(*)
121
+ raise Redis::ConnectionError
122
+ end
123
+ end
124
+ self.app_options = {:redis => fake_redis}
125
+
126
+ assert_nothing_raised { get "/" }
127
+ assert_match "ok", last_response.body
128
+ end
129
+
130
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tally_counter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Brendon Murphy
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-06-15 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: redis
16
+ requirement: &2152445000 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *2152445000
25
+ - !ruby/object:Gem::Dependency
26
+ name: rack-test
27
+ requirement: &2152444560 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: *2152444560
36
+ description: Tally web application hits with Rack & Redis sorted sets
37
+ email:
38
+ - xternal1+github@gmail.com
39
+ executables: []
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - .gitignore
44
+ - Gemfile
45
+ - LICENSE
46
+ - README.md
47
+ - Rakefile
48
+ - lib/tally_counter.rb
49
+ - lib/tally_counter/version.rb
50
+ - tally_counter.gemspec
51
+ - test/tally_counter_test.rb
52
+ homepage: ''
53
+ licenses: []
54
+ post_install_message:
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ none: false
60
+ requirements:
61
+ - - ! '>='
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubyforge_project:
72
+ rubygems_version: 1.8.15
73
+ signing_key:
74
+ specification_version: 3
75
+ summary: Tally web application hits with Rack & Redis sorted sets
76
+ test_files:
77
+ - test/tally_counter_test.rb