This project is a single-file Perl script that turns your raw NGINX access logs into static HTML graphs.
No database, no JavaScript, no frameworks — just logs, JSON, and SVG output.
It is designed for simplicity: drop it on any Linux web host with Perl installed, point it at your NGINX log, and it will create a lightweight HTML dashboard showing traffic counts by five-minute, hourly, and monthly buckets.
How It Works
Reads NGINX logs: The script parses access logs line by line, matching IP, timestamp, and request info.
Aggregates counts: It buckets requests into 5-minute, hourly, and monthly time intervals.
Persists state: A small JSON file tracks file offsets and inode numbers, so it only reads new log lines each run (ideal for cron jobs).
Handles rotation: When NGINX rotates logs, the script detects it by inode change and reads any remaining lines from the rotated file.
Outputs metrics: Another JSON file stores historical counts, which are merged and pruned over time.
Builds static HTML: Finally, it writes out a fully self-contained HTML page with embedded SVG charts — no external libraries required.
Run it every 5 minutes via cron, and you will have a living traffic dashboard built from pure server logs.
It’s the simplest way to visualize site traffic without setting up databases, collectors, or front-end frameworks.
Configuration
Edit the top of the script to set:
$LOG – Path to your NGINX access log
$STATE – Where to store incremental read position
$METRICS – Where to write historical counts
$HTML_OUT – Output HTML file path
Example cron entry:
*/5 * * * * /usr/local/bin/nginx-stats.pl
The Code
Drop the following into nginx-stats.pl and make it executable:
#!/usr/bin/env perl
use strict;
use warnings;
use Fcntl qw(:seek);
use File::stat;
use File::Temp qw(tempfile);
use POSIX qw(strftime);
use JSON::PP;
use File::Spec;
# --------------------------------------------------------------------
# CONFIGURATION
# --------------------------------------------------------------------
# Set these paths to your own environment
my $LOG = '/var/log/nginx/your-site/access.log'; # Path to your Nginx access log
my $STATE = '/var/www/your-stats-site/data/state.json'; # Tracks inode/offset so script can resume
my $METRICS = '/var/www/your-stats-site/data/metrics.json'; # Stores aggregated metrics
my $HTML_OUT = '/var/www/your-stats-site/html/index.html'; # Output HTML file
my $ROT_GLOB = '/var/log/nginx/your-site/*'; # Log rotation glob pattern
# --------------------------------------------------------------------
# STATE HANDLING
# --------------------------------------------------------------------
sub read_state {
return {} unless -e $STATE;
local $/;
open my $fh, '<', $STATE or return {};
my $data = eval { decode_json(<$fh>) } || {};
close $fh;
return $data;
}
sub write_state {
my ($state) = @_;
my ($fh, $tmp) = tempfile(DIR => '/var/www/your-stats-site/data');
print $fh JSON::PP->new->pretty->encode($state);
close $fh;
rename $tmp, $STATE or die "rename $tmp -> $STATE: $!";
}
sub stat_file {
my ($p) = @_;
return undef unless -f $p;
my $st = stat($p);
return { ino => $st->ino, size => $st->size };
}
sub find_rotated {
my ($inode) = @_;
for my $p (glob $ROT_GLOB) {
next unless -f $p;
my $st = stat($p) or next;
return $p if $st->ino == $inode;
}
return;
}
# --------------------------------------------------------------------
# PARSER AND AGGREGATORS
# --------------------------------------------------------------------
my $log_re = qr{
^(\S+) \s+ \S+ \s+ \S+ \s+
\[([^\]]+)\] \s+
"(\S+) \s+ (\S+) \s+ [^"]+" \s+
(\d{3}) \s+ (\S+) \s+
"([^"]*)" \s+ "([^"]*)"
}x;
my %m2n = (Jan=>1,Feb=>2,Mar=>3,Apr=>4,May=>5,Jun=>6,Jul=>7,
Aug=>8,Sep=>9,Oct=>10,Nov=>11,Dec=>12);
my %five; my %hour; my %month;
my $state = read_state();
my $last_ino = $state->{ino} // 0;
my $offset = $state->{offset} // 0;
my $cur = stat_file($LOG) or die "Cannot stat $LOG: $!";
my @lines;
if ($cur->{ino} == $last_ino) {
open my $fh, '<', $LOG or die "open: $!";
if ($cur->{size} < $offset) { $offset = 0 } # truncated
seek $fh, $offset, SEEK_SET;
push @lines, <$fh>;
$offset = tell($fh);
close $fh;
} else {
my $rot = find_rotated($last_ino);
if ($rot) {
open my $rfh, '<', $rot or die "open rotated: $!";
seek $rfh, $offset, SEEK_SET;
push @lines, <$rfh>;
close $rfh;
}
open my $cfh, '<', $LOG or die "open current: $!";
push @lines, <$cfh>;
$offset = tell($cfh);
close $cfh;
}
for my $L (@lines) {
next unless $L =~ $log_re;
my ($ip,$ts) = ($1,$2);
my ($d,$monname,$y,$h,$m,$s) = $ts =~ m!(\d{2})/([A-Za-z]{3})/(\d{4}):(\d{2}):(\d{2}):(\d{2})!;
next unless $y && $monname;
my $mon = $m2n{$monname} || 0;
my $hourbucket = sprintf "%04d-%02d-%02dT%02d", $y,$mon,$d,$h;
my $monthbucket = sprintf "%04d-%02d", $y,$mon;
my $fivebucket = sprintf "%04d-%02d-%02dT%02d:%02d", $y,$mon,$d,$h,int($m/5)*5;
$five{$fivebucket}++;
$hour{$hourbucket}++;
$month{$monthbucket}++;
}
# --------------------------------------------------------------------
# MERGE WITH EXISTING METRICS
# --------------------------------------------------------------------
my %existing;
if (-e $METRICS) {
open my $fh, '<', $METRICS or warn "Cannot read old metrics: $!";
local $/;
my $old = <$fh>;
close $fh;
eval {
my $data = decode_json($old);
%existing = %$data if $data && ref $data eq 'HASH';
};
}
sub merge_counts {
my ($src, $dst) = @_;
for my $k (keys %$src) {
$dst->{$k} += $src->{$k};
}
}
merge_counts(\%five, $existing{fivemin} ||= {});
merge_counts(\%hour, $existing{hourly} ||= {});
merge_counts(\%month, $existing{monthly} ||= {});
# --------------------------------------------------------------------
# PRUNE OLD BUCKETS
# --------------------------------------------------------------------
sub prune_old {
my ($hash, $limit) = @_;
my @sorted = sort keys %$hash;
my $count = @sorted;
while ($count > $limit) {
my $oldest = shift @sorted;
delete $hash->{$oldest};
$count--;
}
}
prune_old($existing{fivemin}, 12 * 24 * 7); # 7 days
prune_old($existing{hourly}, 24 * 60); # 60 days
prune_old($existing{monthly}, 24); # 2 years
my $now = strftime("%Y-%m-%dT%H:%M:%SZ", gmtime);
$existing{generated_at} = $now;
$existing{added_lines} = scalar @lines;
# --------------------------------------------------------------------
# WRITE UPDATED METRICS + STATE
# --------------------------------------------------------------------
{
my ($fh,$tmp) = tempfile(DIR => '/var/www/your-stats-site/data');
print $fh JSON::PP->new->utf8->pretty->encode(\%existing);
close $fh;
rename $tmp, $METRICS or die "rename metrics: $!";
}
write_state({ ino => $cur->{ino}, offset => $offset, updated => $now });
# --------------------------------------------------------------------
# SVG GENERATION
# --------------------------------------------------------------------
sub svg_line {
my ($data,$width,$height,$color) = @_;
$width ||= 600;
$height ||= 180;
$color ||= '#0b61a4';
my @values = sort { $a->[0] cmp $b->[0] }
map { [$_,$data->{$_}] } keys %$data;
return "
No data
" unless @values > 1;
my $max = 0;
for my $v (@values) { $max = $v->[1] if $v->[1] > $max }
$max ||= 1;
my $pad_l = 45; my $pad_b = 25; my $pad_t = 10; my $pad_r = 10;
my $plot_w = $width - $pad_l - $pad_r;
my $plot_h = $height - $pad_b - $pad_t;
my $xstep = @values>1 ? $plot_w / (@values - 1) : $plot_w;
my $yscale = $plot_h / $max;
my @pts;
for my $i (0..$#values) {
my ($bucket,$val) = @{$values[$i]};
my $x = $pad_l + $i * $xstep;
my $y = $height - $pad_b - ($val * $yscale);
push @pts, sprintf("%.1f,%.1f",$x,$y);
}
my $points = join(' ',@pts);
my $yticks = 4;
my @yaxis;
for my $t (0..$yticks) {
my $val = int($max * $t / $yticks);
my $y = $height - $pad_b - ($val * $yscale);
push @yaxis, qq{};
push @yaxis, qq{$val};
}
my $label_step = int(@values/6) || 1;
my @xlabels;
for my $i (grep { $_ % $label_step == 0 } 0..$#values) {
my $bucket = $values[$i][0];
my $label = $bucket;
$label =~ s/^.*T//;
my $x = $pad_l + $i * $xstep;
my $y = $height - 5;
push @xlabels, qq{$label};
}
return qq{
};
}
# --------------------------------------------------------------------
# HTML PAGE RENDERING
# --------------------------------------------------------------------
sub build_html {
my ($metrics) = @_;
my $gen = $metrics->{generated_at};
my $html = <<"HTML";
Site Access Stats
Make sure the directories exist and are writable by the user running the script.
License and Simplicity
This script is intentionally public-domain level simple.
You can modify, rehost, or extend it any way you want — it’s meant to show that observability can be done
without bloat, frameworks, or data lock-in. Perl still gets the job done.