← View Stats

← Visit BaremetalBridge.com

Do It Yourself

Simple NGINX Stats Page Generator in Perl

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

  1. Reads NGINX logs: The script parses access logs line by line, matching IP, timestamp, and request info.
  2. Aggregates counts: It buckets requests into 5-minute, hourly, and monthly time intervals.
  3. 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).
  4. Handles rotation: When NGINX rotates logs, the script detects it by inode change and reads any remaining lines from the rotated file.
  5. Outputs metrics: Another JSON file stores historical counts, which are merged and pruned over time.
  6. 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:

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 "<p>No data</p>" 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{<line x1="$pad_l" y1="$y" x2="}.($width-$pad_r).qq{" y2="$y" stroke="#eee"/>}; push @yaxis, qq{<text x="}.($pad_l-5).qq{" y="}.($y+4).qq{" font-size="10" text-anchor="end">$val</text>}; } 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{<text x="$x" y="$y" font-size="9" text-anchor="middle" fill="#444">$label</text>}; } return qq{ <svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg"> <rect x="$pad_l" y="$pad_t" width="$plot_w" height="$plot_h" fill="none" stroke="#ccc" stroke-width="1"/> @yaxis <polyline fill="none" stroke="$color" stroke-width="2" points="$points"/> @xlabels </svg> }; } # -------------------------------------------------------------------- # HTML PAGE RENDERING # -------------------------------------------------------------------- sub build_html { my ($metrics) = @_; my $gen = $metrics->{generated_at}; my $html = <<"HTML"; <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Site Access Stats</title> <meta name="viewport" content="width=device-width,initial-scale=1"> <style> body{font-family:system-ui,Arial,sans-serif;background:#fff;color:#111;line-height:1.4;padding:20px} h1,h2{margin-top:1em} .code{font-family:monospace;background:#f6f6f6;padding:4px 8px;border-radius:4px} svg{display:block;margin-top:8px;background:#fafafa;border:1px solid #ddd;border-radius:4px} footer{margin-top:2em;font-size:0.9rem;color:#555} a{color:#0b61a4;text-decoration:none} a:hover{text-decoration:underline} </style> </head> <body> <h1>Simple Nginx Site Stats</h1> <p>Generated at: <span class="code">$gen</span></p> <h2>Requests (5-minute view)</h2> @{[svg_line($metrics->{fivemin},600,150,'#0b61a4')]} <h2>Requests (hourly view)</h2> @{[svg_line($metrics->{hourly},600,150,'#a4500b')]} <h2>Requests (monthly view)</h2> @{[svg_line($metrics->{monthly},600,150,'#0ba44d')]} <footer> <p>Static page generated directly from Nginx logs. No database, no JS, no dependencies.</p> </footer> </body> </html> HTML return $html; } my $html = build_html(\%existing); { my ($fh,$tmp) = tempfile(DIR => '/var/www/your-stats-site/html'); print $fh $html; close $fh; rename $tmp, $HTML_OUT or die "rename html: $!"; } system("chmod 755 $HTML_OUT");

Directory Structure

/var/www/your-stats-site/
├── data/
│   ├── state.json
│   └── metrics.json
└── html/
    └── index.html

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.