tally_counter 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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