panmind-usage-tracker 0.4.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/README.md +65 -0
- data/Rakefile +42 -0
- data/VERSION +1 -0
- data/bin/usage_tracker +4 -0
- data/config/usage_tracker.yml.sample +13 -0
- data/config/usage_tracker_upstart.conf +15 -0
- data/config/views.yml +139 -0
- data/lib/usage_tracker.rb +116 -0
- data/lib/usage_tracker/context.rb +27 -0
- data/lib/usage_tracker/log.rb +45 -0
- data/lib/usage_tracker/middleware.rb +94 -0
- data/lib/usage_tracker/railtie.rb +11 -0
- data/lib/usage_tracker/reactor.rb +132 -0
- data/middleware_test.rb +190 -0
- data/panmind-usage-tracker.gemspec +60 -0
- metadata +118 -0
data/README.md
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
Panmind Usage Tracker
|
2
|
+
---------------------
|
3
|
+
|
4
|
+
What is it?
|
5
|
+
===========
|
6
|
+
|
7
|
+
1. A `Rack::Middleware` that sends selected parts of the request environment to an UDP socket
|
8
|
+
2. An `EventMachine` daemon that opens an UDP socket and sends out received data to CouchDB
|
9
|
+
3. A set of CouchDB map-reduce views, for analysis
|
10
|
+
|
11
|
+
|
12
|
+
Does it work?
|
13
|
+
=============
|
14
|
+
|
15
|
+
Yes, but the release is still incomplete, because currently tests are too
|
16
|
+
tied to Panmind logic.
|
17
|
+
|
18
|
+
If you can help in complete the test suite, it is much appreciated :-).
|
19
|
+
|
20
|
+
Deploying
|
21
|
+
=========
|
22
|
+
|
23
|
+
* Add the usage_tracker gem to your Gemfile and require the middleware
|
24
|
+
|
25
|
+
gem 'usage_tracker', :require => 'usage_tracker/middleware'
|
26
|
+
|
27
|
+
* Add the Middleware to your application:
|
28
|
+
|
29
|
+
Your::Application.config.middleware.use UsageTracker::Middleware
|
30
|
+
|
31
|
+
* The daemon can be started manually with the following command, inside a Rails.root:
|
32
|
+
|
33
|
+
$ usage_tracker [environment]
|
34
|
+
|
35
|
+
`environment` is optional and will default to "development" if no command line
|
36
|
+
option nor the RAILS_ENV environment variable are set.
|
37
|
+
|
38
|
+
or can be put under Upstart using the provided configuration file located in
|
39
|
+
`config/usage_tracker_upstart.conf`. Check it out and modify it to suit your needs.
|
40
|
+
|
41
|
+
The daemon logs to `log/usage_tracker.log` and rotates its logs when receives
|
42
|
+
the USR1 signal.
|
43
|
+
|
44
|
+
* The daemon writes its pid into tmp/pids/usage_tracker.pid
|
45
|
+
|
46
|
+
* The daemon connects to a Couch database named `usage_tracker` running on `localhost`,
|
47
|
+
default port `5984/TCP`, and listens on `localhost`, port `5985/UDP` by default.
|
48
|
+
You can change these settings via a `config/usage_tracker.yml` file. See the example
|
49
|
+
in the `config` directory of the gem distribution.
|
50
|
+
|
51
|
+
* The CouchDB instance must be running, the database is created (and updated)
|
52
|
+
if necessary.
|
53
|
+
|
54
|
+
* If the daemon cannot start, e.g. because of unavailable database or listening
|
55
|
+
address, it will print a diagnostig message to STDERR, log to usage_tracker.log
|
56
|
+
and exit with status of 1.
|
57
|
+
|
58
|
+
* The daemon exits gracefully if it receives the INT or the TERM signals.
|
59
|
+
|
60
|
+
Testing
|
61
|
+
=======
|
62
|
+
|
63
|
+
The current test suite, brutally extracted from Panmind codebase, is in the
|
64
|
+
`middleware_test.rb` file at the root of the Gem distribution. It is of no
|
65
|
+
use except Panmind, but it's a start for writing new ones. Please help! :-)
|
data/Rakefile
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
#
|
3
|
+
require 'rake'
|
4
|
+
require 'rake/rdoctask'
|
5
|
+
|
6
|
+
begin
|
7
|
+
require 'jeweler'
|
8
|
+
|
9
|
+
Jeweler::Tasks.new do |gemspec|
|
10
|
+
gemspec.name = 'panmind-usage-tracker'
|
11
|
+
gemspec.summary = 'Write your application request logs in CouchDB'
|
12
|
+
gemspec.description = 'This software implements a Rails 3 Middleware and ' \
|
13
|
+
'an EventMachine reactor to store into CouchDB the ' \
|
14
|
+
'results of HTTP request processing'
|
15
|
+
|
16
|
+
gemspec.authors = ['Marcello Barnaba', 'Christian Wörner']
|
17
|
+
gemspec.homepage = 'http://github.com/Panmind/usage_tracker'
|
18
|
+
gemspec.email = 'vjt@openssl.it'
|
19
|
+
|
20
|
+
gemspec.add_dependency('rails', '~> 3.0')
|
21
|
+
gemspec.add_dependency('eventmachine')
|
22
|
+
gemspec.add_dependency('couchrest')
|
23
|
+
end
|
24
|
+
rescue LoadError
|
25
|
+
puts 'Jeweler not available. Install it with: gem install jeweler'
|
26
|
+
end
|
27
|
+
|
28
|
+
desc 'Generate the rdoc'
|
29
|
+
Rake::RDocTask.new do |rdoc|
|
30
|
+
rdoc.rdoc_files.add %w( README.md lib/**/*.rb )
|
31
|
+
|
32
|
+
rdoc.main = 'README.md'
|
33
|
+
rdoc.title = 'Rails Application Usage Tracker on CouchDB'
|
34
|
+
end
|
35
|
+
|
36
|
+
desc 'Will someone help write tests?'
|
37
|
+
task :default do
|
38
|
+
puts
|
39
|
+
puts 'Can you help in writing tests? Please do :-)'
|
40
|
+
puts
|
41
|
+
end
|
42
|
+
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.4.0
|
data/bin/usage_tracker
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
defaults: &defaults
|
2
|
+
couchdb: "http://admin:suxsux@127.0.0.1:5984/usage_tracker"
|
3
|
+
listen: "127.0.0.1:5985"
|
4
|
+
|
5
|
+
development:
|
6
|
+
<<: *defaults
|
7
|
+
|
8
|
+
production:
|
9
|
+
<<: *defaults
|
10
|
+
|
11
|
+
test:
|
12
|
+
<<: *defaults
|
13
|
+
couchdb: "http://admin:suxsux@127.0.0.1:5984/usage_tracker_test"
|
@@ -0,0 +1,15 @@
|
|
1
|
+
description "Panmind Usage Tracker Daemon"
|
2
|
+
author "Marcello Barnaba <marcello.barnaba@gmail.com>"
|
3
|
+
version "1.1"
|
4
|
+
|
5
|
+
start on runlevel [2345]
|
6
|
+
stop on shutdown
|
7
|
+
respawn
|
8
|
+
|
9
|
+
# The following line assumes that you're using RVM, that's why
|
10
|
+
# bash is invoked: to load rvm setup scripts.
|
11
|
+
#
|
12
|
+
# You should change the user under your webapp runs (panmind
|
13
|
+
# in this example) and the Rails.root (panmind/deploy).
|
14
|
+
#
|
15
|
+
exec sudo -i -H -u panmind bash -c 'echo; cd panmind/deploy; exec usage_tracker'
|
data/config/views.yml
ADDED
@@ -0,0 +1,139 @@
|
|
1
|
+
<%
|
2
|
+
# Currently defined path prefixes, in JS Regexp format
|
3
|
+
#
|
4
|
+
_AREAS_RE = '\/(' << %w( inbox res projects users account publish search ).join('|') << ')'
|
5
|
+
|
6
|
+
# Expands to JS code that defines the "area" variable by
|
7
|
+
# executing the _AREAS_RE regexp onto the "path_info"
|
8
|
+
# property of the "doc" object.
|
9
|
+
#
|
10
|
+
_GET_AREA = %(
|
11
|
+
var match = doc.env.path_info.match (/#{_AREAS_RE}/);
|
12
|
+
var area = match ? match[1] : 'other';
|
13
|
+
).gsub(/\s+/x, ' ').strip
|
14
|
+
%>
|
15
|
+
|
16
|
+
_id : '_design/basic'
|
17
|
+
language: 'javascript'
|
18
|
+
version : '2010110401' # Format: YYYY MM DD VV
|
19
|
+
views :
|
20
|
+
by_timestamp:
|
21
|
+
map: |
|
22
|
+
function (doc) {
|
23
|
+
if (doc.env)
|
24
|
+
emit (doc._id, doc);
|
25
|
+
}
|
26
|
+
|
27
|
+
by_date:
|
28
|
+
map: |
|
29
|
+
function (doc) {
|
30
|
+
var date = new Date (parseFloat (doc._id) * 1000);
|
31
|
+
emit([date.getUTCFullYear (), date.getUTCMonth (), date.getUTCDate ()], 1);
|
32
|
+
}
|
33
|
+
|
34
|
+
reduce: |
|
35
|
+
function (keys, values) {
|
36
|
+
return sum (values);
|
37
|
+
}
|
38
|
+
|
39
|
+
by_user_and_timestamp:
|
40
|
+
map: |
|
41
|
+
function (doc) {
|
42
|
+
if (doc.env)
|
43
|
+
emit ([doc.user_id, doc._id], doc);
|
44
|
+
}
|
45
|
+
|
46
|
+
res_count:
|
47
|
+
map: |
|
48
|
+
function (doc) {
|
49
|
+
if (doc.env && doc.env.path_info.indexOf ('res/') > 0)
|
50
|
+
emit (doc.env.path_info.split ('/')[2], 1);
|
51
|
+
}
|
52
|
+
reduce: |
|
53
|
+
function (keys, values, rereduce) {
|
54
|
+
return sum (values);
|
55
|
+
}
|
56
|
+
|
57
|
+
res_item_count:
|
58
|
+
map: |
|
59
|
+
function (doc) {
|
60
|
+
if (doc.env && doc.env.path_info.indexOf ('res/') > 0) {
|
61
|
+
var pieces = doc.env.path_info.split ('/');
|
62
|
+
if (pieces.length > 3)
|
63
|
+
emit ([pieces[2], pieces[3]], 1);
|
64
|
+
}
|
65
|
+
}
|
66
|
+
reduce: |
|
67
|
+
function (keys, values, rereduce) {
|
68
|
+
return sum (values);
|
69
|
+
}
|
70
|
+
|
71
|
+
user_res_count:
|
72
|
+
map: |
|
73
|
+
function (doc) {
|
74
|
+
if (doc.env && doc.env.path_info.indexOf ('res/') != -1)
|
75
|
+
emit ([doc.user_id, doc.env.path_info.split ('/')[2]], 1);
|
76
|
+
}
|
77
|
+
reduce: |
|
78
|
+
function (keys, values, rereduce) {
|
79
|
+
return sum (values);
|
80
|
+
}
|
81
|
+
|
82
|
+
user_area_count:
|
83
|
+
map: |
|
84
|
+
function (doc) {
|
85
|
+
if (doc.env) {
|
86
|
+
<%= _GET_AREA %>
|
87
|
+
emit ([doc.user_id, area], 1);
|
88
|
+
}
|
89
|
+
}
|
90
|
+
reduce: |
|
91
|
+
function (keys, values, rereduce) {
|
92
|
+
return sum (values);
|
93
|
+
}
|
94
|
+
|
95
|
+
area_count:
|
96
|
+
map: |
|
97
|
+
function (doc) {
|
98
|
+
if (doc.env) {
|
99
|
+
<%= _GET_AREA %>
|
100
|
+
emit (area, 1);
|
101
|
+
}
|
102
|
+
}
|
103
|
+
reduce: |
|
104
|
+
function (keys, values, rereduce) {
|
105
|
+
return sum (values);
|
106
|
+
}
|
107
|
+
|
108
|
+
average_duration_of_path:
|
109
|
+
map: |
|
110
|
+
function (doc) {
|
111
|
+
if (doc.duration)
|
112
|
+
emit (doc.env.path_info, doc.duration);
|
113
|
+
}
|
114
|
+
reduce: |
|
115
|
+
function (keys, values){
|
116
|
+
return Math.round (sum (values) / values.length);
|
117
|
+
}
|
118
|
+
|
119
|
+
average_duration_of_area:
|
120
|
+
map: |
|
121
|
+
function (doc) {
|
122
|
+
if (doc.duration) {
|
123
|
+
<%= _GET_AREA %>
|
124
|
+
emit (area, doc.duration)
|
125
|
+
}
|
126
|
+
}
|
127
|
+
reduce: |
|
128
|
+
function (keys, values){
|
129
|
+
return Math.round (sum (values) / values.length);
|
130
|
+
}
|
131
|
+
|
132
|
+
by_user_timestamp_area:
|
133
|
+
map: |
|
134
|
+
function (doc) {
|
135
|
+
if (doc.env) {
|
136
|
+
<%= _GET_AREA %>
|
137
|
+
emit ([doc.user_id, doc._id, area], doc);
|
138
|
+
}
|
139
|
+
}
|
@@ -0,0 +1,116 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'erb'
|
3
|
+
require 'yaml'
|
4
|
+
require 'pathname'
|
5
|
+
require 'ostruct'
|
6
|
+
require 'couchrest'
|
7
|
+
require 'active_support/core_ext/object/blank'
|
8
|
+
require 'usage_tracker/log'
|
9
|
+
|
10
|
+
module UsageTracker
|
11
|
+
class << self
|
12
|
+
# Memoizes the current environment
|
13
|
+
def env
|
14
|
+
@env ||= ENV['RAILS_ENV'] || ARGV[0] || 'development'
|
15
|
+
end
|
16
|
+
|
17
|
+
@@defaults = {
|
18
|
+
'couchdb' => 'http://localhost:5984/usage_tracker',
|
19
|
+
'listen' => '127.0.0.1:5985'
|
20
|
+
}
|
21
|
+
|
22
|
+
# Memoizes settings from the ./config/usage_tracker.yml file,
|
23
|
+
# relative from __FILE__ and searches for the "usage_tracker"
|
24
|
+
# configuration block. Raises RuntimeError if it cannot find
|
25
|
+
# the configuration.
|
26
|
+
#
|
27
|
+
def settings
|
28
|
+
@settings ||= begin
|
29
|
+
log "Loading #{env} environment"
|
30
|
+
|
31
|
+
rc_file = Pathname.new('.').join('config', 'usage_tracker.yml')
|
32
|
+
settings = YAML.load(rc_file.read)[env] if rc_file.exist?
|
33
|
+
|
34
|
+
if settings.blank?
|
35
|
+
settings = @@defaults
|
36
|
+
log "#{env} configuration block not found in #{rc_file}, using defaults"
|
37
|
+
elsif settings.values_at(*%w(couchdb listen)).any?(&:blank?)
|
38
|
+
raise "Incomplete configuration: please set the 'couchdb' and 'listen' keys"
|
39
|
+
end
|
40
|
+
|
41
|
+
host, port = settings.delete('listen').split(':')
|
42
|
+
|
43
|
+
if [host, port].any? {|x| x.strip.empty?}
|
44
|
+
raise "Please specify where to listen as host:port"
|
45
|
+
end
|
46
|
+
|
47
|
+
settings['host'], settings['port'] = host, port.to_i
|
48
|
+
|
49
|
+
OpenStruct.new settings
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def database
|
54
|
+
@database or raise "Not connected to the database"
|
55
|
+
end
|
56
|
+
|
57
|
+
# Connects to the configured CouchDB and memoizes the
|
58
|
+
# CouchRest::Database connection into an instance variable
|
59
|
+
# and calls +load_views!+
|
60
|
+
#
|
61
|
+
# Raises RuntimeError if the connection could not be established
|
62
|
+
#
|
63
|
+
def connect!
|
64
|
+
@database =
|
65
|
+
CouchRest.database!(settings.couchdb).tap do |db|
|
66
|
+
db.info
|
67
|
+
log "Connected to database #{settings.couchdb}"
|
68
|
+
end
|
69
|
+
|
70
|
+
load_views!
|
71
|
+
rescue Errno::ECONNREFUSED, RestClient::Exception => e
|
72
|
+
raise "Unable to connect to database #{settings.couchdb}: #{e.message}"
|
73
|
+
end
|
74
|
+
|
75
|
+
def write_pid!(pid = $$)
|
76
|
+
dir = Pathname.new('.').join('tmp', 'pids')
|
77
|
+
dir = Pathname.new(Dir.tmpdir) unless dir.directory?
|
78
|
+
dir.join('usage_tracker.pid').open('w+') {|f| f.write(pid)}
|
79
|
+
end
|
80
|
+
|
81
|
+
def log(message = nil)
|
82
|
+
@log ||= Log.new
|
83
|
+
message ? @log.info(message) : @log
|
84
|
+
end
|
85
|
+
|
86
|
+
def raise(message)
|
87
|
+
log.error message
|
88
|
+
Kernel.raise Error, message
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
# Loads CouchDB views from views.yml and verifies that
|
93
|
+
# they are loaded in the current instance, upgrading
|
94
|
+
# them if necessary.
|
95
|
+
def load_views!
|
96
|
+
new = YAML.load ERB.new(
|
97
|
+
Pathname.new(__FILE__).dirname.join('..', 'config', 'views.yml').read
|
98
|
+
).result
|
99
|
+
|
100
|
+
id = new['_id']
|
101
|
+
old = database.get id
|
102
|
+
|
103
|
+
if old['version'].to_i < new['version'].to_i
|
104
|
+
log "Upgrading Design Document #{id} to v#{new['version']}"
|
105
|
+
database.delete_doc old
|
106
|
+
database.save_doc new
|
107
|
+
end
|
108
|
+
|
109
|
+
rescue RestClient::ResourceNotFound
|
110
|
+
log "Creating Design Document #{id} v#{new['version']}"
|
111
|
+
database.save_doc new
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
class Error < StandardError; end
|
116
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'usage_tracker/log'
|
2
|
+
|
3
|
+
module UsageTracker
|
4
|
+
module Context
|
5
|
+
@@key = 'usage_tracker.context'.freeze
|
6
|
+
mattr_reader :key
|
7
|
+
|
8
|
+
# Sets the env +Key+ variable with the provided +data+
|
9
|
+
#
|
10
|
+
def usage_tracker_context=(data)
|
11
|
+
unless request.env[key].blank?
|
12
|
+
unless Rails.env.test? && !caller.grep(/test\/functional/).blank?
|
13
|
+
UsageTracker.log 'WARNING: overwriting context data!'
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
request.env[key] = data
|
18
|
+
end
|
19
|
+
|
20
|
+
# Shorthand for self.usage_tracker_context = data
|
21
|
+
#
|
22
|
+
def usage_tracker_context(data)
|
23
|
+
self.usage_tracker_context = data
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
module UsageTracker
|
4
|
+
class Log
|
5
|
+
attr_reader :path
|
6
|
+
|
7
|
+
[:debug, :info, :warn, :error, :fatal].each do |severity|
|
8
|
+
define_method(severity) {|*args| @logger.send(severity, *args)}
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
open
|
13
|
+
end
|
14
|
+
|
15
|
+
def path
|
16
|
+
@path ||= if File.directory?('log')
|
17
|
+
Pathname.new('.').join('log', 'usage_tracker.log')
|
18
|
+
else
|
19
|
+
Pathname.new('usage_tracker.log')
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def open
|
24
|
+
@logger = Logger.new(path.to_s)
|
25
|
+
@logger.formatter = Logger::Formatter.new
|
26
|
+
@logger.info 'Log opened'
|
27
|
+
|
28
|
+
rescue
|
29
|
+
raise Error, "Cannot open log file #{path}"
|
30
|
+
end
|
31
|
+
|
32
|
+
def close
|
33
|
+
return unless @logger
|
34
|
+
|
35
|
+
@logger.info 'Log closed'
|
36
|
+
@logger.close
|
37
|
+
@logger = nil
|
38
|
+
end
|
39
|
+
|
40
|
+
def rotate
|
41
|
+
close
|
42
|
+
open
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
require 'timeout'
|
2
|
+
require 'usage_tracker'
|
3
|
+
require 'usage_tracker/context'
|
4
|
+
require 'usage_tracker/railtie' if defined?(Rails)
|
5
|
+
|
6
|
+
# This middleware extracts some data from the incoming request
|
7
|
+
# and sends it to the reactor, that parses and stores it.
|
8
|
+
#
|
9
|
+
module UsageTracker
|
10
|
+
class Middleware
|
11
|
+
@@headers = [
|
12
|
+
"REMOTE_ADDR",
|
13
|
+
"REQUEST_METHOD",
|
14
|
+
"PATH_INFO",
|
15
|
+
"REQUEST_URI",
|
16
|
+
"SERVER_PROTOCOL",
|
17
|
+
#"HTTP_VERSION",
|
18
|
+
"HTTP_HOST",
|
19
|
+
"HTTP_USER_AGENT",
|
20
|
+
"HTTP_ACCEPT",
|
21
|
+
"HTTP_ACCEPT_LANGUAGE",
|
22
|
+
"HTTP_X_FORWARDED_FOR",
|
23
|
+
"HTTP_X_FORWARDED_PROTO",
|
24
|
+
#"HTTP_ACCEPT_LANGUAGE",
|
25
|
+
#"HTTP_ACCEPT_ENCODING",
|
26
|
+
#"HTTP_ACCEPT_CHARSET",
|
27
|
+
#"HTTP_KEEP_ALIVE",
|
28
|
+
"HTTP_CONNECTION",
|
29
|
+
#"HTTP_COOKIE",
|
30
|
+
#"HTTP_CACHE_CONTROL",
|
31
|
+
#"SERVER_NAME",
|
32
|
+
#"SERVER_PORT",
|
33
|
+
"QUERY_STRING"
|
34
|
+
].freeze
|
35
|
+
|
36
|
+
@@backend, @@host, @@port = [
|
37
|
+
`hostname`.strip,
|
38
|
+
UsageTracker.settings.host,
|
39
|
+
UsageTracker.settings.port
|
40
|
+
].each(&:freeze)
|
41
|
+
|
42
|
+
def initialize(app)
|
43
|
+
@app = app
|
44
|
+
end
|
45
|
+
|
46
|
+
def call(env)
|
47
|
+
req_start = Time.now.to_f
|
48
|
+
response = @app.call env
|
49
|
+
req_end = Time.now.to_f
|
50
|
+
|
51
|
+
begin
|
52
|
+
data = {
|
53
|
+
:user_id => env['rack.session'][:user_id],
|
54
|
+
:duration => ((req_end - req_start) * 1000).to_i,
|
55
|
+
:backend => @@backend,
|
56
|
+
:xhr => env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest',
|
57
|
+
:context => env[Context.key],
|
58
|
+
:env => {},
|
59
|
+
:status => response[0] # response contains [status, headers, body]
|
60
|
+
}
|
61
|
+
|
62
|
+
@@headers.each {|key| data[:env][key.downcase] = env[key] unless env[key].blank?}
|
63
|
+
|
64
|
+
self.class.track(data.to_json)
|
65
|
+
|
66
|
+
rescue
|
67
|
+
raise unless response # Error in the application, raise it up
|
68
|
+
|
69
|
+
# Error in usage tracker itself
|
70
|
+
UsageTracker.log($!.message)
|
71
|
+
UsageTracker.log($!.backtrace.join("\n"))
|
72
|
+
end
|
73
|
+
|
74
|
+
return response
|
75
|
+
end
|
76
|
+
|
77
|
+
class << self
|
78
|
+
# Writes the given `data` to the reactor, using the UDP protocol.
|
79
|
+
# Times out after 1 second. If a write error occurs, data is lost.
|
80
|
+
#
|
81
|
+
def track(data)
|
82
|
+
Timeout.timeout(1) do
|
83
|
+
UDPSocket.open do |sock|
|
84
|
+
sock.connect(@@host, @@port.to_i)
|
85
|
+
sock.write_nonblock(data << "\n")
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
rescue Timeout::Error, Errno::EWOULDBLOCK, Errno::EAGAIN, Errno::EINTR
|
90
|
+
UsageTracker.log "Cannot track data: #{$!.message}"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
require 'usage_tracker/context'
|
2
|
+
|
3
|
+
module UsageTracker
|
4
|
+
class Railtie < Rails::Railtie
|
5
|
+
initializer 'usage_tracker.insert_into_action_controller' do
|
6
|
+
ActiveSupport.on_load :action_controller do
|
7
|
+
ActionController::Base.instance_eval { include UsageTracker::Context }
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'usage_tracker'
|
4
|
+
require 'eventmachine'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
module UsageTracker
|
8
|
+
module Reactor
|
9
|
+
# This method is called upon every data reception
|
10
|
+
#
|
11
|
+
def receive_data(data)
|
12
|
+
doc = parse(data)
|
13
|
+
if doc && check(doc)
|
14
|
+
store(doc)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Debug hook
|
19
|
+
if UsageTracker.env == 'test'
|
20
|
+
alias :real_receive_data :receive_data
|
21
|
+
def receive_data(data)
|
22
|
+
UsageTracker.log.debug "Received #{data.inspect}"
|
23
|
+
ret = real_receive_data(data)
|
24
|
+
UsageTracker.log.debug ret ? "Stored #{ret}" : 'Failed to store input data'
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
def parse(data)
|
30
|
+
JSON(data).tap {|h| h.reject! {|k,v| v.nil?}}
|
31
|
+
rescue JSON::ParserError
|
32
|
+
UsageTracker.log.error "Tossing out invalid JSON #{data.inspect} (#{$!.message.inspect})"
|
33
|
+
return nil
|
34
|
+
end
|
35
|
+
|
36
|
+
def check(doc)
|
37
|
+
error =
|
38
|
+
if !doc.kind_of?(Hash) then 'invalid'
|
39
|
+
elsif doc.empty? then 'empty'
|
40
|
+
elsif !(missing = check_keys(doc)).empty?
|
41
|
+
"#{missing.join(', ')} missing"
|
42
|
+
end
|
43
|
+
|
44
|
+
if error
|
45
|
+
UsageTracker.log.error "Tossing out invalid document #{doc.inspect}: #{error}"
|
46
|
+
return nil
|
47
|
+
else
|
48
|
+
return true
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def check_keys(doc)
|
53
|
+
%w( duration env status ).reject {|k| doc.has_key?(k)}
|
54
|
+
end
|
55
|
+
|
56
|
+
def store(doc)
|
57
|
+
tries = 0
|
58
|
+
|
59
|
+
begin
|
60
|
+
doc['_id'] = make_id
|
61
|
+
UsageTracker.database.save_doc(doc)
|
62
|
+
|
63
|
+
rescue RestClient::Conflict => e
|
64
|
+
if (tries += 1) < 10
|
65
|
+
UsageTracker.log.warn "Retrying to save #{doc.inspect}, try #{tries}"
|
66
|
+
retry
|
67
|
+
else
|
68
|
+
UsageTracker.log.error "Losing '#{doc.inspect}' because of too many conflicts"
|
69
|
+
end
|
70
|
+
|
71
|
+
rescue Encoding::UndefinedConversionError
|
72
|
+
UsageTracker.log.error "Losing '#{doc.inspect}' because #$!" # FIXME handle this error properly
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# Timestamp as _id has the advantage that documents
|
77
|
+
# are sorted automatically by CouchDB.
|
78
|
+
#
|
79
|
+
# Eventual duplication (multiple servers) is (possibly)
|
80
|
+
# avoided by adding a random digit at the end.
|
81
|
+
#
|
82
|
+
def make_id
|
83
|
+
Time.now.to_f.to_s.ljust(16, '0') + rand(10).to_s
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
connect!
|
88
|
+
|
89
|
+
# Setup signal handlers
|
90
|
+
#
|
91
|
+
# * INT, TERM: graceful exit
|
92
|
+
# * USR1 : rotate logs
|
93
|
+
#
|
94
|
+
def self.sigexit(sig)
|
95
|
+
log "Received SIG#{sig}"
|
96
|
+
EventMachine.stop_event_loop
|
97
|
+
end
|
98
|
+
|
99
|
+
trap('INT') { sigexit 'INT' }
|
100
|
+
trap('TERM') { sigexit 'TERM' }
|
101
|
+
trap('USR1') { log.rotate }
|
102
|
+
|
103
|
+
# Run the Event Loop
|
104
|
+
#
|
105
|
+
EventMachine.run do
|
106
|
+
begin
|
107
|
+
host, port = UsageTracker.settings.host, UsageTracker.settings.port
|
108
|
+
|
109
|
+
unless (1024..65535).include? port.to_i
|
110
|
+
raise "Please set a listening port between 1024 and 65535"
|
111
|
+
end
|
112
|
+
|
113
|
+
EventMachine.open_datagram_socket host, port, Reactor
|
114
|
+
log "Listening on #{host}:#{port} UDP"
|
115
|
+
write_pid!
|
116
|
+
|
117
|
+
$stderr.puts "Started, logging to #{log.path}"
|
118
|
+
[$stdin, $stdout, $stderr].each {|io| io.reopen '/dev/null'}
|
119
|
+
|
120
|
+
rescue Exception => e
|
121
|
+
message = e.message == 'no datagram socket' ? "Unable to bind #{host}:#{port}" : e
|
122
|
+
log.fatal message
|
123
|
+
$stderr.puts message unless $stderr.closed?
|
124
|
+
EventMachine.stop_event_loop
|
125
|
+
exit 1
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# Goodbye!
|
130
|
+
#
|
131
|
+
log 'Exiting'
|
132
|
+
end
|
data/middleware_test.rb
ADDED
@@ -0,0 +1,190 @@
|
|
1
|
+
################################################################
|
2
|
+
# ATTENTION
|
3
|
+
# make sure that a event-machine test reactor process is running
|
4
|
+
# ruby extras/usage_tracker/reactor.rb test
|
5
|
+
#
|
6
|
+
################################################################
|
7
|
+
|
8
|
+
# this checks the end-point of the usage tracking (arrival in the database) ->
|
9
|
+
# consider checking intermediate steps.......
|
10
|
+
require 'usage_tracker'
|
11
|
+
|
12
|
+
module UsageTracker
|
13
|
+
class IntegrationTest < ActionController::IntegrationTest
|
14
|
+
UsageTracker.connect!
|
15
|
+
|
16
|
+
context 'a request from a guest' do
|
17
|
+
should 'get tracked when successful' do
|
18
|
+
assert_difference 'doc_count' do
|
19
|
+
get '/'
|
20
|
+
assert_response :success
|
21
|
+
end
|
22
|
+
|
23
|
+
doc = last_tracking
|
24
|
+
assert_equal '/', doc.env.request_uri
|
25
|
+
assert_equal nil, doc.user_id
|
26
|
+
assert_equal 200, doc.status
|
27
|
+
assert doc.duration > 0
|
28
|
+
end
|
29
|
+
|
30
|
+
should 'get tracked when not found' do
|
31
|
+
get '/nonexistant'
|
32
|
+
assert_response :not_found
|
33
|
+
|
34
|
+
doc = last_tracking
|
35
|
+
assert_equal '/nonexistant', doc.env.request_uri
|
36
|
+
assert_equal 404, doc.status
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
context 'a request from a logged-in user' do
|
41
|
+
setup do
|
42
|
+
@user = Factory.create(:confirmed_user)
|
43
|
+
post '/login', {:email => @user.email, :password => @user.password}, {'HTTPS' => 'on'}
|
44
|
+
assert_redirected_to plain_root_url
|
45
|
+
end
|
46
|
+
|
47
|
+
should 'get tracked when successful' do
|
48
|
+
assert_difference 'doc_count' do
|
49
|
+
get '/_'
|
50
|
+
assert_response :success
|
51
|
+
end
|
52
|
+
|
53
|
+
doc = last_tracking
|
54
|
+
|
55
|
+
assert_equal '/_', doc.env.request_uri
|
56
|
+
assert_equal @user.id, doc.user_id
|
57
|
+
assert_equal 200, doc.status
|
58
|
+
end
|
59
|
+
|
60
|
+
should 'get tracked when not found' do
|
61
|
+
get '/nonexistant'
|
62
|
+
assert_response :not_found
|
63
|
+
|
64
|
+
doc = last_tracking
|
65
|
+
|
66
|
+
assert_equal '/nonexistant', doc.env.request_uri
|
67
|
+
assert_equal @user.id, doc.user_id
|
68
|
+
assert_equal 404, doc.status
|
69
|
+
assert_equal false, doc.xhr
|
70
|
+
end
|
71
|
+
|
72
|
+
should 'get tracked when failed' do
|
73
|
+
xhr :get, '/projects/1/error', {}, {'HTTPS' => 'on'}
|
74
|
+
assert_response :internal_server_error
|
75
|
+
|
76
|
+
doc = last_tracking
|
77
|
+
|
78
|
+
assert_equal '/projects/1/error', doc.env.request_uri
|
79
|
+
assert_equal @user.id, doc.user_id
|
80
|
+
assert_equal 500, doc.status
|
81
|
+
assert_equal true, doc.xhr
|
82
|
+
assert_equal `hostname`.strip, doc.backend
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
fast_context "a search request" do
|
87
|
+
setup do
|
88
|
+
@res, @users, @assets =
|
89
|
+
mock_search_results_for(Array.new(2) { Factory.create(:res) }),
|
90
|
+
mock_search_results_for(Array.new(3) { Factory.create(:user) }),
|
91
|
+
mock_search_results_for(Network::AssetsController::ContentModels.map {|name|
|
92
|
+
Array.new(2) { Factory(name.underscore.to_sym).reload.asset } }.flatten)
|
93
|
+
|
94
|
+
Res.stubs(:search).returns(@res)
|
95
|
+
User.stubs(:search).returns(@users)
|
96
|
+
NetworkAsset.stubs(:search).returns(@assets)
|
97
|
+
end
|
98
|
+
|
99
|
+
should "be tracked with results" do
|
100
|
+
get '/search/e'
|
101
|
+
assert_response :success
|
102
|
+
|
103
|
+
doc = last_tracking
|
104
|
+
assert !doc.context.blank?
|
105
|
+
|
106
|
+
assert_equal 'e', doc.context.query
|
107
|
+
assert_equal [], doc.context.tags
|
108
|
+
assert_equal nil, doc.context.cat
|
109
|
+
|
110
|
+
assert_equal @res.map(&:id), doc.context.results.res
|
111
|
+
assert_equal @users.map(&:id), doc.context.results.users
|
112
|
+
assert_equal @assets.map(&:id), doc.context.results.assets
|
113
|
+
end
|
114
|
+
|
115
|
+
should "be tracked with tags" do
|
116
|
+
get '/search', :tag => 'a,b,c'
|
117
|
+
assert_response :success
|
118
|
+
|
119
|
+
doc = last_tracking
|
120
|
+
assert !doc.context.blank?
|
121
|
+
|
122
|
+
assert_equal '', doc.context.query
|
123
|
+
assert_equal %w(a b c), doc.context.tags
|
124
|
+
assert_equal nil, doc.context.cat
|
125
|
+
end
|
126
|
+
|
127
|
+
should "be tracked with tags and query" do
|
128
|
+
get '/search/antani', :tag => 'd,e,f'
|
129
|
+
assert_response :success
|
130
|
+
|
131
|
+
doc = last_tracking
|
132
|
+
assert !doc.context.blank?
|
133
|
+
|
134
|
+
assert_equal 'antani', doc.context.query
|
135
|
+
assert_equal %w(d e f), doc.context.tags
|
136
|
+
assert_equal nil, doc.context.cat
|
137
|
+
end
|
138
|
+
|
139
|
+
should "be tracked with category" do
|
140
|
+
cat = Factory.create(:res_category)
|
141
|
+
get '/search', :cat => cat.shortcut
|
142
|
+
assert_response :success
|
143
|
+
|
144
|
+
doc = last_tracking
|
145
|
+
assert !doc.context.blank?
|
146
|
+
|
147
|
+
assert_equal '', doc.context.query
|
148
|
+
assert_equal [], doc.context.tags
|
149
|
+
assert_equal cat.id, doc.context.cat
|
150
|
+
end
|
151
|
+
|
152
|
+
should "be tracked with category and query" do
|
153
|
+
cat = Factory.create(:res_category)
|
154
|
+
get '/search/res/asd', :cat => cat.shortcut
|
155
|
+
assert_response :success
|
156
|
+
|
157
|
+
doc = last_tracking
|
158
|
+
assert !doc.context.blank?
|
159
|
+
|
160
|
+
assert_equal 'asd', doc.context.query
|
161
|
+
assert_equal [], doc.context.tags
|
162
|
+
assert_equal cat.id, doc.context.cat
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
context "the middleware" do
|
167
|
+
should "not wait for more than a second before aborting" do
|
168
|
+
UDPSocket.expects(:open).once.yields(Class.new do
|
169
|
+
def write_nonblock(*args); sleep 0.7 end
|
170
|
+
def connect(*args) ; sleep 0.7 end
|
171
|
+
end.new)
|
172
|
+
|
173
|
+
assert_no_difference 'doc_count' do
|
174
|
+
get '/_'
|
175
|
+
assert_response :success
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
def last_tracking
|
181
|
+
sleep 0.3
|
182
|
+
UsageTracker.database.view('basic/by_timestamp', :descending => true, :limit => 1).rows.first.value
|
183
|
+
end
|
184
|
+
|
185
|
+
def doc_count
|
186
|
+
sleep 0.3
|
187
|
+
UsageTracker.database.info['doc_count']
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
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{panmind-usage-tracker}
|
8
|
+
s.version = "0.4.0"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Marcello Barnaba", "Christian Wo\u0308rner"]
|
12
|
+
s.date = %q{2010-12-03}
|
13
|
+
s.default_executable = %q{usage_tracker}
|
14
|
+
s.description = %q{This software implements a Rails 3 Middleware and an EventMachine reactor to store into CouchDB the results of HTTP request processing}
|
15
|
+
s.email = %q{vjt@openssl.it}
|
16
|
+
s.executables = ["usage_tracker"]
|
17
|
+
s.extra_rdoc_files = [
|
18
|
+
"README.md"
|
19
|
+
]
|
20
|
+
s.files = [
|
21
|
+
"README.md",
|
22
|
+
"Rakefile",
|
23
|
+
"VERSION",
|
24
|
+
"bin/usage_tracker",
|
25
|
+
"config/usage_tracker.yml.sample",
|
26
|
+
"config/usage_tracker_upstart.conf",
|
27
|
+
"config/views.yml",
|
28
|
+
"lib/usage_tracker.rb",
|
29
|
+
"lib/usage_tracker/context.rb",
|
30
|
+
"lib/usage_tracker/log.rb",
|
31
|
+
"lib/usage_tracker/middleware.rb",
|
32
|
+
"lib/usage_tracker/railtie.rb",
|
33
|
+
"lib/usage_tracker/reactor.rb",
|
34
|
+
"middleware_test.rb"
|
35
|
+
]
|
36
|
+
s.homepage = %q{http://github.com/Panmind/usage_tracker}
|
37
|
+
s.require_paths = ["lib"]
|
38
|
+
s.rubygems_version = %q{1.3.7}
|
39
|
+
s.summary = %q{Write your application request logs in CouchDB}
|
40
|
+
|
41
|
+
if s.respond_to? :specification_version then
|
42
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
43
|
+
s.specification_version = 3
|
44
|
+
|
45
|
+
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
46
|
+
s.add_runtime_dependency(%q<rails>, ["~> 3.0"])
|
47
|
+
s.add_runtime_dependency(%q<eventmachine>, [">= 0"])
|
48
|
+
s.add_runtime_dependency(%q<couchrest>, [">= 0"])
|
49
|
+
else
|
50
|
+
s.add_dependency(%q<rails>, ["~> 3.0"])
|
51
|
+
s.add_dependency(%q<eventmachine>, [">= 0"])
|
52
|
+
s.add_dependency(%q<couchrest>, [">= 0"])
|
53
|
+
end
|
54
|
+
else
|
55
|
+
s.add_dependency(%q<rails>, ["~> 3.0"])
|
56
|
+
s.add_dependency(%q<eventmachine>, [">= 0"])
|
57
|
+
s.add_dependency(%q<couchrest>, [">= 0"])
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
metadata
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: panmind-usage-tracker
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease: false
|
5
|
+
segments:
|
6
|
+
- 0
|
7
|
+
- 4
|
8
|
+
- 0
|
9
|
+
version: 0.4.0
|
10
|
+
platform: ruby
|
11
|
+
authors:
|
12
|
+
- Marcello Barnaba
|
13
|
+
- "Christian Wo\xCC\x88rner"
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2010-12-03 00:00:00 +01:00
|
19
|
+
default_executable: usage_tracker
|
20
|
+
dependencies:
|
21
|
+
- !ruby/object:Gem::Dependency
|
22
|
+
name: rails
|
23
|
+
prerelease: false
|
24
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
segments:
|
30
|
+
- 3
|
31
|
+
- 0
|
32
|
+
version: "3.0"
|
33
|
+
type: :runtime
|
34
|
+
version_requirements: *id001
|
35
|
+
- !ruby/object:Gem::Dependency
|
36
|
+
name: eventmachine
|
37
|
+
prerelease: false
|
38
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
segments:
|
44
|
+
- 0
|
45
|
+
version: "0"
|
46
|
+
type: :runtime
|
47
|
+
version_requirements: *id002
|
48
|
+
- !ruby/object:Gem::Dependency
|
49
|
+
name: couchrest
|
50
|
+
prerelease: false
|
51
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
52
|
+
none: false
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
segments:
|
57
|
+
- 0
|
58
|
+
version: "0"
|
59
|
+
type: :runtime
|
60
|
+
version_requirements: *id003
|
61
|
+
description: This software implements a Rails 3 Middleware and an EventMachine reactor to store into CouchDB the results of HTTP request processing
|
62
|
+
email: vjt@openssl.it
|
63
|
+
executables:
|
64
|
+
- usage_tracker
|
65
|
+
extensions: []
|
66
|
+
|
67
|
+
extra_rdoc_files:
|
68
|
+
- README.md
|
69
|
+
files:
|
70
|
+
- README.md
|
71
|
+
- Rakefile
|
72
|
+
- VERSION
|
73
|
+
- bin/usage_tracker
|
74
|
+
- config/usage_tracker.yml.sample
|
75
|
+
- config/usage_tracker_upstart.conf
|
76
|
+
- config/views.yml
|
77
|
+
- lib/usage_tracker.rb
|
78
|
+
- lib/usage_tracker/context.rb
|
79
|
+
- lib/usage_tracker/log.rb
|
80
|
+
- lib/usage_tracker/middleware.rb
|
81
|
+
- lib/usage_tracker/railtie.rb
|
82
|
+
- lib/usage_tracker/reactor.rb
|
83
|
+
- middleware_test.rb
|
84
|
+
- panmind-usage-tracker.gemspec
|
85
|
+
has_rdoc: true
|
86
|
+
homepage: http://github.com/Panmind/usage_tracker
|
87
|
+
licenses: []
|
88
|
+
|
89
|
+
post_install_message:
|
90
|
+
rdoc_options: []
|
91
|
+
|
92
|
+
require_paths:
|
93
|
+
- lib
|
94
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
95
|
+
none: false
|
96
|
+
requirements:
|
97
|
+
- - ">="
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
segments:
|
100
|
+
- 0
|
101
|
+
version: "0"
|
102
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
103
|
+
none: false
|
104
|
+
requirements:
|
105
|
+
- - ">="
|
106
|
+
- !ruby/object:Gem::Version
|
107
|
+
segments:
|
108
|
+
- 0
|
109
|
+
version: "0"
|
110
|
+
requirements: []
|
111
|
+
|
112
|
+
rubyforge_project:
|
113
|
+
rubygems_version: 1.3.7
|
114
|
+
signing_key:
|
115
|
+
specification_version: 3
|
116
|
+
summary: Write your application request logs in CouchDB
|
117
|
+
test_files: []
|
118
|
+
|