VisitTracker
Being bored today, I decided to take a bit more in depth look at Rack as well as trying to write a nice little demo app with it. The result you see here is today is VisitTracker - a mini hit logger for websites that produces CSV log files with minimal amounts of info.
The Background
Rack is a nice little WSGI-like interface for Ruby - it lets you really easily write Rack adapters which can be plugged into handlers - Web servers - as well as doing all sorts of cool stuff. you can install it by typing gem install rack. For the purpose of this article, I'm using the built in Mongrel handler for testing and Thin for production (yes, I'm an idiot.)
The Application
For the impatient, here is the resultant code (and an explanation follows):
visittracker.rb:
require 'rubygems'
require 'rack'
require 'rack/request'
require 'rack/response'
require 'yaml'
require 'singleton'
require 'markaby'
trap("SIGINT") do
puts "Server Killed..."
exit(0)
end
module SuttoNet
def self.run_default(opts = {})
items = {:sites_file => "sites.yml", :log => "hits.log", :port => 8532}.merge(opts)
port = items.delete(:port)
application = VisitTracker.new(items)
puts "Running server..."
Rack::Handler::Mongrel.run(application, :port => port)
end
class VisitTracker
attr_accessor :request, :response, :site, :response_html
REQUEST_PATH = /^\/hits\/([A-Za-z0-9]+)$/
def initialize(opts = {})
SiteHash.load_file(opts.delete(:sites_file))
Logger.instance.setup(opts.delete(:log))
generate_response_html!
end
def call(env)
self.request = Rack::Request.new(env)
self.response = Rack::Response.new
process!
respond!
end
def process!
load_vars_from_url
request_hash = {
:site => self.site,
:time => Time.now,
:referer => request.referer,
:user => request.env["REMOTE_USER"],
:addr => request.env["REMOTE_ADDR"],
:page => request.GET["page"]
}
Logger.instance.record_hit(request_hash)
end
def load_vars_from_url
if request.path_info =~ REQUEST_PATH
if defined?(DEBUG_REQUESTS)
puts "Matches path"
puts "Hash -> #{$1}"
end
self.site = SiteHash.site_for($1)
puts "Site is #{self.site}" if defined?(DEBUG_REQUESTS)
else
self.site = "unknown"
end
end
def respond!
Rack::Response.new.finish do |res|
res.write response_html
end
end
def generate_response_html!
self.response_html = Markaby::Builder.new.html do
head do
title "VisitTracker"
end
body do
strong "Recorded Hit"
end
end.to_s
end
end
class SiteHash
def self.load_file(filename)
@@sites = YAML::load(File.read(filename))
end
def self.site_for(hash)
@@sites ||= {}
@@sites[hash] or "unknown"
end
end
class Logger
include Singleton
attr_accessor :buffer, :file, :last_hit
MAX_BUFFER = 100
def record_hit(details)
self.buffer = MAX_BUFFER
end
def format_string(opts = {})
log_string = "#{opts[:user]},#{opts[:addr]},#{opts[:page]},#{opts[:site]},#{opts[:time]},#{opts[:referrer]}"
puts log_string if defined?(DEBUG_REQUESTS)
return log_string
end
end
end
if $0 == __FILE__
DEBUG_REQUESTS = true if (ARGV.length > 1 and ARGV[1].to_s == "debug")
app = SuttoNet::VisitTracker.new(:sites_file => "sites.yml", :log => "hits.log")
Rack::Handler::Mongrel.run app, :Port => (ARGV[0] || "9292")
endsites.yml:
nhblog: http://blog.ninjahideout.comAbout the application
First off, I designed it to be used in two scenarios - by itself, and in rackup configurations. If you want to try just by itself, you can use: ruby visittracker.rb {port} [debug]. Adding debug just gives nifty debug output.
Whenever the application is hit with a request of /hits/{key}, it will hit the Site's config class (SuttoNet::SiteHash) to check if it exists. We load these hashes from a config file at startup. The server then uses this plus the current time and the referrer (among other items) and logs it to a file. We use a buffered logger to avoiding hitting the file all the time - instead hitting every 100 requests unless it's been 15 minutes since the last request.
We then respond with a simple HTML page (generated via Markaby on first request) noting that there was a hit to the site. All in all, a very simple and relatively efficient hit tracker.
Usage
If you're using rails, theres a simple helper you can add - but change the VisitTracker host to your own.
def visit_tracker(name, server = "http://hits.ninjahideout.com/")
url = server + "hits/#{name}?page=#{URI.escape(request.request_uri)}"
content_tag(:iframe, "", :src => url, :style => "display: none", :id => "visittracker")
endwhich can then be called via..
And finally, the following is the script I use on my server (run via sudo ./start_visittracker:
start_visittracker
#!/usr/bin/ruby
require 'visittracker'
require 'thin'
VISIT_TRACKER_ROOT = "/etc/visittracker"
class VisitTrackerController
def self.start(port = "6789")
puts "Starting VisitTracker on Port #{port}..."
fork do
File.open("#{VISIT_TRACKER_ROOT}/vt.#{port}.pid", "w+") do |f|
f.write(Process.pid)
end
app = SuttoNet::VisitTracker.new(:sites_file => "#{VISIT_TRACKER_ROOT}/sites.yml", :log => "#{VISIT_TRACKER_ROOT}/hits.log")
Rack::Handler::Thin.run app, :Port => port
end
end
def self.stop(port = "6789")
puts "Stopping VisitTracker on Port #{port}..."
if File.exist?("#{VISIT_TRACKER_ROOT}/vt.#{port}.pid")
pid = File.read("#{VISIT_TRACKER_ROOT}/vt.#{port}.pid").to_i
Process.kill(1, pid) unless pid == 0
File.open("#{VISIT_TRACKER_ROOT}/vt.#{port}.pid", "w+") do |f|
f.write ""
end
else
puts "Process not running (atleast not on the specified port)"
exit(1)
end
end
def self.restart(port = "6789")
self.stop(port)
self.start(port)
end
end
case ARGV[0]
when nil, "start"
VisitTrackerController.start
when "stop"
VisitTrackerController.stop
when "restart"
VisitTrackerController.restart
end