Ninjas on a Penny Farthing

  posted  
Use Twitter? Follow on Twitter.

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") end

sites.yml:

nhblog: http://blog.ninjahideout.com

About 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") end

which 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