All of the interesting technological, artistic or just plain fun subjects I'd investigate if I had an infinite number of lifetimes. In other words, a dumping ground...

Monday 8 October 2007

Server logging visualisation using Ruby and OpenGL

From http://www.fudgie.org/



View real-time data and statistics from any logfile on any server you have
SSH access to, in an intuitive and entertaining way.


Here is the code:

#!/usr/bin/env ruby
# gl_tail.rb v0.01 - OpenGL visualization of your server traffic
# Copyright 2007 Erlend Simonsen
#
# Licensed under the GPLv2
#
# I know, horrible code and global variables, and what not. But, it works,
# and it's too fun to watch my servers traffic in real time to clean up
# everything before releasing it.
#
# Installation instructions (Ubuntu/Debian):
# sudo apt-get install libopengl-ruby rubygems
# sudo gem install -y net-ssh
#
# Installation instructions (Mac OS/X):
#
# sudo gem install -y ruby-opengl net-ssh
# (You might need option 2 or higher)
#
# Configuration:
# Modify $SERVERS, $BLOCKS & $PARSERS to your liking. Either include a
# :password for each of your servers, or make sure your ssh-keys are
# correctly set up.
#
# Running:
# ./gl_tail.rb
#
# Changelog:
# 06 Oct 2007 - v0.01 Initial release
# 07 Oct 2007 - v0.02 Postfix parser
# IIS parser (Tucker Sizemore )
#
# Further ideas:
# Get rid of GLUT.BitmapCharacter and use textured polygons instead
# Allow more indicators (pulsing color/size, cubes, teapots, etc)
# Clickable links
# Drag 'n drop organizing
# Hide/show blocks with keypresses
# Limit display to specific host
# Background IP lookups
# Geolocation on IPS
#

require 'rubygems'

require 'opengl'
if RUBY_PLATFORM == "i386-mswin32"
require 'glut_prev'
else
require 'glut'
end
#require 'cgi'
#require 'resolv'

require_gem 'net-ssh'


#################
### Configuration
###

ENV['__GL_SYNC_TO_VBLANK']="1"
$WINDOW_WIDTH = 1200
$WINDOW_HEIGHT = 760

# $MIN_BLOB_SIZE = 0.07
$MIN_BLOB_SIZE = 0.05
$MAX_BLOB_SIZE = 0.6

$SERVERS = [
# List of machines to log in to via SSH, and which files to tail for traffic data.
{:name => 'server1', :host => 'server1.example.com', :user => 'joeuser', :password => 'secret', :command => 'tail -f', :files => ['/var/log/apache/access_log'], :color => [0.6, 0.6, 1.0, 1.0], :parser => :apache },
{:name => 'server2', :host => 'login.mycoolsite.com', :user => 'otheruser', :password => 'othersecret', :port => 22222, :command => 'xtail', :files => ['/usr/local/www/apps/myapp/current/log/production.log'], :color => [0.1, 0.6, 0.6, 1.0], :parser => :rails },
{:name => 'mail', :host => 'mail.spamme.com', :user => 'otheruser', :password => 'othersecret', :command => 'tail -f', :files => ['/var/log/maillog'], :color => [0.8, 1.0, 0.0, 1.0], :parser => :postfix },
]

$BLOCKS = [
# Sections with different information to display on the screen
{ :name => 'info', :position => :left, :order => 0, :size => 10, :auto_clean => false, :show => :total },
{ :name => 'sites', :position => :left, :order => 1, :size => 10 },
{ :name => 'content', :position => :left, :order => 2, :size => 5, :show => :total, :color => [1.0, 0.8, 0.4, 1.0] },
{ :name => 'status', :position => :left, :order => 3, :size => 10, :color => [1.0, 0.8, 0.4, 1.0] },
{ :name => 'users', :position => :left, :order => 4, :size => 10 },
{ :name => 'smtp', :position => :left, :order => 5, :size => 5 },

{ :name => 'urls', :position => :right, :order => 0, :size => 15 },
{ :name => 'slow requests', :position => :right, :order => 1, :size => 5, :show => :average },
{ :name => 'referrers', :position => :right, :order => 2, :size => 10 },
{ :name => 'user agents', :position => :right, :order => 3, :size => 5, :color => [1.0, 1.0, 1.0, 1.0] },
{ :name => 'mail', :position => :right, :order => 4, :size => 5 },
]

$PARSERS = {
# Parser which handles access_logs in combined format from Apache
:apache => Proc.new { |server,line|
_, host, user, domain, date, url, status, size, referrer, useragent = /^([\d.]+) (\S+) (\S+) \[([^\]]+)\] \"(.+?)\" (\d+) ([\S]+) \"([^\"]+)\" \"([^\"]+)\"/.match(line).to_a

if host
method, url, http_version = url.split(" ")

url, parameters = url.split('?')

server.add_activity(:block => 'sites', :name => server.name, :size => size.to_i/1000000.0) # Size of activity based on size of request
server.add_activity(:block => 'urls', :name => url)
server.add_activity(:block => 'users', :name => host, :size => size.to_i/1000000.0)
server.add_activity(:block => 'referrers', :name => referrer) unless (referrer.include?(server.name) || referrer.include?(server.host))
server.add_activity(:block => 'user agents', :name => useragent, :type => 3)

if( url.include?('.gif') || url.include?('.jpg') || url.include?('.png') || url.include?('.ico'))
type = 'image'
elsif url.include?('.css')
type = 'css'
elsif url.include?('.js')
type = 'javascript'
elsif url.include?('.swf')
type = 'flash'
elsif( url.include?('.avi') || url.include?('.ogm') || url.include?('.flv') || url.include?('.mpg') )
type = 'movie'
elsif( url.include?('.mp3') || url.include?('.wav') || url.include?('.fla') || url.include?('.aac') || url.include?('.ogg'))
type = 'music'
else
type = 'page'
end
server.add_activity(:block => 'content', :name => type)
server.add_activity(:block => 'status', :name => status, :type => 3) # don't show a blob

# Events to pop up
server.add_event(:block => 'info', :name => "Logins", :message => "Login...", :update_stats => true, :color => [1.5, 1.0, 0.5, 1.0]) if method == "POST" && url.include?('login')
server.add_event(:block => 'info', :name => "Sales", :message => "$", :update_stats => true, :color => [1.5, 0.0, 0.0, 1.0]) if method == "POST" && url.include?('/checkout')
server.add_event(:block => 'info', :name => "Signups", :message => "New User...", :update_stats => true, :color => [1.0, 1.0, 1.0, 1.0]) if( method == "POST" && (url.include?('/signup') || url.include?('/users/create')))
end
},

:rails => Proc.new { |server,line|
#Completed in 0.02100 (47 reqs/sec) | Rendering: 0.01374 (65%) | DB: 0.00570 (27%) | 200 OK [http://example.com/whatever/whatever]
_, ms, url = /^Completed in ([\d.]+) .* \[([^\]]+)\]/.match(line).to_a


if url
_, host, url = /^http[s]?:\/\/([^\/]+)(.*)/.match(url).to_a

server.add_activity(:block => 'sites', :name => host, :size => ms.to_f) # Size of activity based on request time.
server.add_activity(:block => 'urls', :name => url, :size => ms.to_f)
server.add_activity(:block => 'slow requests', :name => url, :size => ms.to_f)

# Events to pop up
server.add_event(:block => 'info', :name => "Logins", :message => "Login...", :update_stats => true, :color => [0.5, 1.0, 0.5, 1.0]) if url.include?('/login')
server.add_event(:block => 'info', :name => "Sales", :message => "$", :update_stats => true, :color => [1.5, 0.0, 0.0, 1.0]) if url.include?('/checkout')
server.add_event(:block => 'info', :name => "Signups", :message => "New User...", :update_stats => true, :color => [1.0, 1.0, 1.0, 1.0]) if(url.include?('/signup') || url.include?('/users/create'))
elsif line.include?('Processing ')
#Processing TasksController#update_sheet_info (for 123.123.123.123 at 2007-10-05 22:34:33) [POST]
_, host = /^Processing .* \(for (\d+.\d+.\d+.\d+) at .*\).*$/.match(line).to_a
if host
server.add_activity(:block => 'users', :name => host)
end
elsif line.include?('Error (')
_, error, msg = /^([^ ]+Error) \((.*)\):/.match(line).to_a
if error
server.add_event(:block => 'info', :name => "Exceptions", :message => error, :update_stats => true, :color => [1.0, 0.0, 0.0, 1.0])
server.add_event(:block => 'info', :name => "Exceptions", :message => msg, :update_stats => false, :color => [1.0, 0.0, 0.0, 1.0])
end
end
},

:postfix => Proc.new { |server,line|

if line.include?(': connect from')
_, host, ip = /: connect from ([^\[]+)\[(\d+.\d+.\d+.\d+)\]/.match(line).to_a
if host
server.add_activity(:block => 'smtp', :name => host, :size => 0.03)
end
elsif line.include?(' from=<') _, from, size = /: from=<([^>]+)>, size=(\d+)/.match(line).to_a
if from
server.add_activity(:block => 'mail', :name => from, :size => size.to_f/100000.0)
end
elsif line.include?(' to=<') if line.include?('relay=local') # Incoming _, to, delay, status = /: to=<([^>]+)>, .*delay=([\d.]+).*status=([^ ]+)/.match(line).to_a
server.add_activity(:block => 'mail', :name => to, :size => delay.to_f/10.0, :type => 5, :color => [1.0, 0.0, 1.0, 1.0])
server.add_activity(:block => 'status', :name => 'received', :size => delay.to_f/10.0, :type => 3)
else
# Outgoing
_, to, relay_host, delay, status = /: to=<([^>]+)>, relay=([^\[,]+).*delay=([\d.]+).*status=([^ ]+)/.match(line).to_a
server.add_activity(:block => 'mail', :name => to, :size => delay.to_f/10.0)
server.add_activity(:block => 'smtp', :name => relay_host, :size => delay.to_f/10.0)
server.add_activity(:block => 'status', :name => status, :size => delay.to_f/10.0, :type => 3)
end
end

},

:iis => Proc.new { |server,line|
_, date, time,serverip, url, referrer, port, size, host, useragent, status = /^([\d-]+) ([\d:]+) ([\d.]+) (.+? .+?) (\S+) (.+?) (\S+) ([\d.]+) (.+?) (\d+) (.*)$/.match(line).to_a

if host
method, url, http_version = url.split(" ")

url, parameters = url.split('?')

server.add_activity(:block => 'sites', :name => server.name, :size => size.to_i/1000000.0) # Size of activity based on size of request
server.add_activity(:block => 'urls', :name => url)
server.add_activity(:block => 'users', :name => host, :size => size.to_i/1000000.0)
server.add_activity(:block => 'referrers', :name => referrer) unless (referrer.include?(server.name) || referrer.include?(server.host))
server.add_activity(:block => 'user agents', :name => useragent, :type => 3)

if( url.include?('.gif') || url.include?('.jpg') || url.include?('.png') || url.include?('.ico'))
type = 'image'
elsif url.include?('.css')
type = 'css'
elsif url.include?('.js')
type = 'javascript'
elsif url.include?('.swf')
type = 'flash'
elsif( url.include?('.avi') || url.include?('.ogm') || url.include?('.flv') || url.include?('.mpg') )
type = 'movie'
elsif( url.include?('.mp3') || url.include?('.wav') || url.include?('.fla') || url.include?('.aac') || url.include?('.ogg'))
type = 'music'
else
type = 'page'
end
server.add_activity(:block => 'content', :name => type)
server.add_activity(:block => 'status', :name => status, :type => 3) # don't show a blob

# Events to pop up
server.add_event(:block => 'info', :name => "Logins", :message => "Login...", :update_stats => true, :color => [1.5, 1.0, 0.5, 1.0]) if method == "POST" && url.include?('login')
server.add_event(:block => 'info', :name => "Sales", :message => "$", :update_stats => true, :color => [1.5, 0.0, 0.0, 1.0]) if method == "POST" && url.include?('/checkout')
server.add_event(:block => 'info', :name => "Signups", :message => "New User...", :update_stats => true, :color => [1.0, 1.0, 1.0, 1.0]) if( method == "POST" && (url.include?('/signup') || url.include?('/users/create')))
end
}

}

###
### Configuration end
######################
# Lots of hacks and bad code below. :-)

$BLOBS = { }
$FPS = 50.0
$ASPECT = 0.6

$TOP = 11.0
$RIGHT_COL = 11.0
$LEFT_COL = -20.0
$LINE_SIZE = 0.3
$BLOB_OFFSET = 7.0
$STATS = []


class Item
attr_accessor :message, :size, :color, :type

def initialize(message, size, color, type)
@message = message
@size = size
@color = color
@type = type
end

end

class Activity
attr_accessor :x, :y, :z, :type, :wx, :wy, :wz

def initialize(message, x,y,z, color, size, type=0)
@message = message
@x, @y, @z = x, y, z
# @xi, @yi, @zi = 0.18 + ( (rand(100)/100.0 - 0.5) * 0.02 ), (rand(100)/100.0 - 0.5) * 0.02, 0
@xi, @yi, @zi = 0.18 , 0.03, 0

if @x >= 0.0
@xi = -@xi
end

@xi = (rand(100)/100.0 * 0.02) - 0.01 if type == 2

@color = color
@size = size
@type = type
end

def render

if @type == 5
dy = @wy - @y
if dy.abs < y =" @wy" dx =" @wx" x =" @wx" x ="="" x =" 20.0" y =" -$TOP" yi =" -@yi" x =" 30.0" type ="="" type ="="" type ="="" list =" GL.GenLists(1)" type ="="" list =" GL.GenLists(1)" type ="="" list =" GL.GenLists(1)" type =" 0," right =" false," start_position =" -$TOP)" name =" name" right =" right" x =" (right" y =" start_position" z =" 0" wy =" start_position" color =" color" size =" 0.01" queue =" []" pending =" []" activities =" []" messages =" 0" rate =" 0" total =" 0" sum =" 0" average =" 0.0" last_time =" 0" step =" 0," updates =" 0" active =" false" type =" type" type ="="" average =" @sum" rate ="="" rate =" 1.0" messages =" 0" rate ="="" rate =" 1.0" messages =" 0" active =" true"> 0
@updates += 1
@rate = (@rate.to_f * 59 + @messages) / 60
@messages = 0
if @pending.size > 0
if @pending.size == 1
@step = rand(1000)
else
@step = 1.0 / (@queue.size + @pending.size) * 1000
end
@queue = @queue + @pending
@pending = []
else
@step = 0
end
@last_time = GLUT.Get(GLUT::ELAPSED_TIME)
@last_time -= @step unless @queue.size == 1
end

def render(options = { })
@x = (@right ? $RIGHT_COL : $LEFT_COL)

d = @wy - @y
if d.abs < y =" @wy"> 0 ? [10.0, 1.0, 1.0, 1.0] : @color ))
GL.Translate(@x, @y, @z)
GL.RasterPos(0.0, 0.0)

if @type == 0
if @rate < txt = " r/m " txt = "#{sprintf(" type ="="" total ="="" txt = " total " txt = "#{sprintf(" type ="="" average ="="" txt = " avg " txt = "#{sprintf(" list =" GL.GenLists(1)" list =" GL.GenLists(1)" t =" GLUT.Get(GLUT::ELAPSED_TIME)"> 0 && @last_time + @step < last_time =" t" item =" @queue.pop" url =" item.message" color =" item.color" size =" item.size" type =" item.type" size =" $MIN_BLOB_SIZE"> $MAX_BLOB_SIZE
size = $MAX_BLOB_SIZE
end

if type == 2
@activities.push Activity.new(url, 0.0 - (0.043 * url.length), $TOP, 0.0, color, size, type)
elsif type == 5
a = Activity.new(url, 0.0, $TOP, 0.0, color, size, type)
a.wx = @x
a.wy = @y + 0.05
@activities.push a
elsif type != 4
if @x >= 0
@activities.push Activity.new(url, @x, @y, @z, color, size, type)
else
@activities.push Activity.new(url, @x + $BLOB_OFFSET, @y, @z, color, size, type)
end
end
end

@activities.each do |a|
if a.x > 18.0 || a.x < -18.0 @activities.delete a else a.wy = @y + 0.05 if a.type == 5 a.render $STATS[1] += 1 end end end end class Block attr_reader :name, :position, :order, :bottom_position def initialize(options) @name = options[:name] @position = options[:position] || :left @size = options[:size] || 10 @clean = options[:auto_clean] || true @order = options[:order] || 100 @color = options[:color] @show = case options[:show] when :rate: 0 when :total: 1 when :average: 2 else 0 end @header = Element.new(@name.upcase , [1.0, 1.0, 1.0, 1.0], @show, @position == :right) @elements = { } @bottom_position = -$TOP end def render(num) return num if @elements.size == 0 @header.wy = $TOP - (num * $LINE_SIZE) @header.render num += 1 sorted = case @show when 0: @elements.values.sort { |k,v| v.rate <=> k.rate}[0..@size-1]
when 1: @elements.values.sort { |k,v| v.total <=> k.total}[0..@size-1]
when 2: @elements.values.sort { |k,v| v.average <=> k.average}[0..@size-1]
end

sorted.each do |e|
e.wy = $TOP - (num * $LINE_SIZE)
e.render
$STATS[0] += 1
if e.rate <= 0.0001 && e.active && e.updates > 4
@elements.delete(e.name)
end
num += 1
end
(@elements.values - sorted).each do |e|
$STATS[0] += 1
e.activities.each do |a|
a.render
if a.x > 18.0 || a.x < -18.0 e.activities.delete a end end if e.activities.size == 0 && @clean && e.updates > 4
@elements.delete(e.name)
end
end
@elements.delete_if { |k,v| (!sorted.include? v) && v.active && v.activities.size == 0} if @clean
@bottom_position = $TOP - ((sorted.size > 0 ? (num-1) : num) * $LINE_SIZE)
num + 1
end

def add_activity(options = { })
@elements[options[:name]] ||= Element.new(options[:name], @color || options[:color], @show, @position == :right, @bottom_position)
@elements[options[:name]].add_activity(options[:message], options[:size] || 0.01, options[:type] || 0 )
end

def add_event(options = { })
@elements[options[:name]] ||= Element.new(options[:name], options[:color], @show, @position == :right)
@elements[options[:name]].add_event(options[:message], options[:update_stats] || false)
end

def update
@elements.each_value do |e|
e.update
end
end
end

class Server
attr_reader :name, :host, :color, :parser

def initialize(options)
@name = options[:name] || options[:host]
@host = options[:host]
@color = options[:color] || [1.0, 1.0, 1.0, 1.0]
@parser = $PARSERS[options[:parser]] || $PARSERS[:apache]
@blocks = options[:blocks]
end

#block, message, size
def add_activity(options = { })
block = @blocks[options[:block]].add_activity( { :name => @name, :color => @color, :size => 0.03 }.update(options) )
end

#block, message
def add_event(options = { })
block = @blocks[options[:block]].add_event( { :name => @name, :color => @color, :size => 0.03}.update(options) )
end

end

class GlTail

def draw
GL.Clear(GL::COLOR_BUFFER_BIT);
# GL.Clear(GL::COLOR_BUFFER_BIT | GL::DEPTH_BUFFER_BIT);

GL.PushMatrix()

positions = Hash.new

$STATS = [0,0]

@blocks.values.sort { |k,v| k.order <=> v.order}.each do |block|
positions[block.position] = block.render( positions[block.position] || 0 )
end

GL.PopMatrix()
GLUT.SwapBuffers()

@frames = 0 if not defined? @frames
@t0 = 0 if not defined? @t0

@frames += 1
t = GLUT.Get(GLUT::ELAPSED_TIME)
if t - @t0 >= 5000
seconds = (t - @t0) / 1000.0
$FPS = @frames / seconds
printf("%d frames in %6.3f seconds = %6.3f FPS\n",
@frames, seconds, $FPS)
@t0, @frames = t, 0
puts "Elements[#{$STATS[0]}], Activities[#{$STATS[1]}]"
end
end

def idle
GLUT.PostRedisplay()
do_process
end

# Change view angle, exit upon ESC
def key(k, x, y)
case k
when 27 # Escape
exit
end
GLUT.PostRedisplay()
end

# Change view angle
def special(k, x, y)
GLUT.PostRedisplay()
end

# New window size or exposure
def reshape(width, height)
$ASPECT = height.to_f / width.to_f

puts "Reshape: #{width}x#{height} = #{$ASPECT}"

GL.Viewport(0, 0, width, height)
GL.MatrixMode(GL::PROJECTION)
GL.LoadIdentity()

GL.Frustum(-2.0, 2.0, -$ASPECT*2, $ASPECT*2, 5.0, 60.0)

$TOP = 19.0 * $ASPECT
$LINE_SIZE = 0.5 * (1122/height.to_f) * $ASPECT
$BLOB_OFFSET = 11.6 * (1122/height.to_f) * $ASPECT
$RIGHT_COL = 18.3 * $ASPECT

GL.MatrixMode(GL::MODELVIEW)
GL.LoadIdentity()
GL.Translate(0.0, 0.0, -50.0)
end

def init
GL.Lightfv(GL::LIGHT0, GL::POSITION, [5.0, 5.0, 10.0, 0.0])
GL.Disable(GL::CULL_FACE)
GL.Enable(GL::LIGHTING)
GL.Enable(GL::LIGHT0)

GL.Disable(GL::DEPTH_TEST)
GL.Disable(GL::NORMALIZE)

@channels = Array.new
@sessions = Array.new
@servers = Hash.new
@blocks = Hash.new
@mode = 0

$BLOCKS.each do |b|
@blocks[b[:name]] = Block.new b
end

$SERVERS.each do |s|
puts "Connecting to #{s[:host]}..."
session_options = { }
session_options[:port] = s[:port] if s[:port]
# session_options[:verbose] = :debug

if s[:password]
session = Net::SSH.start(s[:host], s[:user], s[:password], session_options)
else
session = Net::SSH.start(s[:host], s[:user], session_options)
end
do_tail session, s[:name], s[:color], s[:files].join(" "), s[:command]
session.connection.process
@sessions.push session
@servers[s[:name]] ||= Server.new(:name => s[:name] || s[:host], :host => s[:host], :color => s[:color], :parser => s[:parser], :blocks => @blocks )
end

@since = GLUT.Get(GLUT::ELAPSED_TIME)
end

def visible(vis)
GLUT.IdleFunc((vis == GLUT::VISIBLE ? method(:idle).to_proc : nil))
end

def mouse(button, state, x, y)
@mouse = state
@x0, @y0 = x, y
end

def motion(x, y)
if @mouse == GLUT::DOWN then
end
@x0, @y0 = x, y
end

def initialize
GLUT.Init()
GLUT.InitDisplayMode(GLUT::RGB | GLUT::DOUBLE)
GLUT.InitDisplayMode(GLUT::RGB | GLUT::DEPTH | GLUT::DOUBLE)

GLUT.InitWindowPosition(0, 0)
GLUT.InitWindowSize($WINDOW_WIDTH, $WINDOW_HEIGHT)
GLUT.CreateWindow('glTail')
init()

GLUT.DisplayFunc(method(:draw).to_proc)
GLUT.ReshapeFunc(method(:reshape).to_proc)
GLUT.KeyboardFunc(method(:key).to_proc)
GLUT.SpecialFunc(method(:special).to_proc)
GLUT.VisibilityFunc(method(:visible).to_proc)
GLUT.MouseFunc(method(:mouse).to_proc)
GLUT.MotionFunc(method(:motion).to_proc)
end

def start
GLUT.MainLoop()
end

def parse_line( ch, data )
ch[:buffer].gsub(/\r\n/,"\n").gsub(/\n/, "\n\n").each("") do |line|

unless line.include? "\n\n"
ch[:buffer] = "#{line}"
next
end

line.gsub!(/\n\n/, "\n")
line.gsub!(/\n\n/, "\n")

server = @servers.values.find { |v| (v.host == ch[:host]) && (v.name == ch[:name]) }
server.parser.call(server, line)
end
ch[:buffer] = "" if ch[:buffer].include? "\n"
end

def do_tail( session, name, color, file, command )
session.open_channel do |channel|
puts "Channel opened on #{session.host}...\n"
channel[:host] = session.host
channel[:name] = name
channel[:color] = color
channel[:buffer] = ""
channel.request_pty :want_reply => true

channel.on_data do |ch, data|
ch[:buffer] << active =" 0" active ="="">= 1000
@since = GLUT.Get(GLUT::ELAPSED_TIME)
@channels.each { |ch| ch.connection.ping! }

@blocks.each_value do |b|
b.update
end
end
self
end

end

GlTail.new.start

No comments:

tim's shared items

Blog Archive

Add to Google Reader or Homepage