If you run Linux or Windows servers behind Nginx or Apache, you already have a goldmine of operational data sitting in your access logs — and no easy way to read it. This tutorial builds a self-contained Ruby command-line tool that parses those logs, summarizes traffic patterns, and raises an alert when your error rate crosses a threshold you choose. It is the kind of script you can drop into a cron job tonight and have real visibility into your web server by tomorrow morning.

The problem this solves

Access logs answer questions that matter every single day: which IPs are hammering your server, which endpoints are slow, and whether your 500 error rate just spiked after a deploy. Most teams either pay for a log aggregation platform they don’t fully need, or they never look at the logs at all until something is already on fire. Ruby’s standard library — Regexp, OptionParser, and Struct — is more than enough to build a fast, dependency-free analyzer that answers those questions in under a second, on a log file with tens of thousands of lines.

Prerequisites

You will need:

  • Ruby 2.7 or newer (this was written and tested on Ruby 3.0). No gems required — everything used ships in Ruby’s standard library.
  • A Linux or Windows machine with an Nginx or Apache access log in the standard “combined” log format (the default for both servers).
  • Read access to the log file (or root/sudo if you’re pointing it at /var/log/nginx/access.log directly).

How it works

The script is split into three small, single-purpose classes rather than one big script, which makes it much easier to test and extend:

  • LogParser turns one raw log line into a typed LogEntry struct using a single regular expression with named captures.
  • LogEntry is a lightweight value object (a Struct) that also knows how to classify itself as a client error (4xx) or server error (5xx).
  • LogAnalyzer collects LogEntry objects one at a time (so it can process a file of any size without loading everything into memory twice) and computes the aggregate statistics on demand.
Data flow diagram: access log file to LogParser to LogEntry structs to LogAnalyzer to Report and Alert

The complete code

Save this as log_analyzer.rb. It is fully self-contained — no gems to install.

#!/usr/bin/env ruby
# log_analyzer.rb
#
# A Ruby-based log analyzer for Nginx/Apache "combined" access logs.
#
# It parses a log file, computes summary statistics (status code
# breakdown, top client IPs, top requested paths, response-time
# percentiles) and raises an alert if the error rate crosses a
# configurable threshold. Designed to run as a one-off report tool
# or on a cron schedule for daily/hourly log review.
#
# Usage:
#   ruby log_analyzer.rb /var/log/nginx/access.log
#   ruby log_analyzer.rb /var/log/nginx/access.log --error-threshold 5
#   cat access.log | ruby log_analyzer.rb -
#
# Author: tha-shed.com tutorials

require 'optparse'
require 'time'

# ---------------------------------------------------------------------------
# LogEntry: a small value object representing one parsed access-log line.
# ---------------------------------------------------------------------------
LogEntry = Struct.new(:ip, :time, :method, :path, :status, :bytes, :response_time) do
  def error?
    status >= 500
  end

  def client_error?
    status >= 400 && status < 500
  end
end

# ---------------------------------------------------------------------------
# LogParser: turns raw lines into LogEntry objects.
#
# Matches Nginx/Apache "combined" log format, with an optional trailing
# request-time field (common in Nginx configs that add $request_time):
#
#   127.0.0.1 - - [10/Oct/2023:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 612 "-" "curl/7.68.0" 0.002
# ---------------------------------------------------------------------------
class LogParser
  # Named captures keep the regex readable and let us build a LogEntry
  # directly from `match.named_captures`.
  # Uses %r{...} instead of /.../ so literal slashes inside comments and
  # the date string (e.g. 10 Oct 2023) don't need escaping.
  LINE_REGEX = %r{
    ^(?<ip>\S+)\s+           # client IP
    \S+\s+\S+\s+             # ident, authuser (usually "-")
    \[(?<time>[^\]]+)\]\s+   # timestamp, e.g. 10 Oct 2023 13:55:36 +0000
    "(?<method>[A-Z]+)\s+(?<path>\S+)\s+[^"]*"\s+ # e.g. GET /path HTTP/1.1
    (?<status>\d{3})\s+      # status code, e.g. 200
    (?<bytes>\S+)            # bytes sent, e.g. 612 or -
    (?:\s+"[^"]*"\s+"[^"]*")? # optional referrer/user-agent
    (?:\s+(?<rt>[\d.]+))?    # optional trailing request time in seconds
  }x

  def self.parse_line(line)
    m = LINE_REGEX.match(line)
    return nil unless m

    LogEntry.new(
      m[:ip],
      begin
        Time.strptime(m[:time], '%d/%b/%Y:%H:%M:%S %z')
      rescue ArgumentError
        nil
      end,
      m[:method],
      m[:path],
      m[:status].to_i,
      m[:bytes] == '-' ? 0 : m[:bytes].to_i,
      m[:rt] ? m[:rt].to_f : nil
    )
  end
end

# ---------------------------------------------------------------------------
# LogAnalyzer: aggregates LogEntry objects into a report.
# ---------------------------------------------------------------------------
class LogAnalyzer
  def initialize(error_threshold_pct: 5.0, top_n: 5)
    @entries = []
    @unparsed = 0
    @error_threshold_pct = error_threshold_pct
    @top_n = top_n
  end

  def feed(line)
    entry = LogParser.parse_line(line)
    if entry
      @entries << entry
    else
      @unparsed += 1
    end
  end

  def total
    @entries.size
  end

  def status_breakdown
    @entries.group_by(&:status).transform_values(&:size).sort.to_h
  end

  def top_ips
    @entries.group_by(&:ip).transform_values(&:size).sort_by { |_, c| -c }.first(@top_n)
  end

  def top_paths
    @entries.group_by(&:path).transform_values(&:size).sort_by { |_, c| -c }.first(@top_n)
  end

  def error_count
    @entries.count(&:error?)
  end

  def client_error_count
    @entries.count(&:client_error?)
  end

  def error_rate_pct
    return 0.0 if total.zero?
    (error_count.to_f / total * 100).round(2)
  end

  def response_times
    @entries.map(&:response_time).compact.sort
  end

  def percentile(p)
    times = response_times
    return nil if times.empty?
    idx = ((p / 100.0) * (times.size - 1)).round
    times[idx]
  end

  def alert?
    error_rate_pct >= @error_threshold_pct
  end

  def report
    lines = []
    lines << '=' * 60
    lines << 'LOG ANALYSIS REPORT'
    lines << '=' * 60
    lines << "Total requests parsed : #{total}"
    lines << "Unparsed lines        : #{@unparsed}"
    lines << ''
    lines << '-- Status code breakdown --'
    status_breakdown.each { |code, count| lines << "  #{code}: #{count}" }
    lines << ''
    lines << "-- Top #{@top_n} client IPs --"
    top_ips.each { |ip, count| lines << "  #{ip}: #{count} requests" }
    lines << ''
    lines << "-- Top #{@top_n} requested paths --"
    top_paths.each { |path, count| lines << "  #{path}: #{count} requests" }
    lines << ''
    unless response_times.empty?
      lines << '-- Response time (seconds) --'
      lines << "  p50: #{percentile(50)}  p90: #{percentile(90)}  p99: #{percentile(99)}"
      lines << ''
    end
    lines << "Client errors (4xx): #{client_error_count}"
    lines << "Server errors (5xx): #{error_count}"
    lines << "Error rate: #{error_rate_pct}% (alert threshold: #{@error_threshold_pct}%)"
    if alert?
      lines << ''
      lines << "*** ALERT: error rate #{error_rate_pct}% exceeds threshold #{@error_threshold_pct}% ***"
    end
    lines << '=' * 60
    lines.join("\n")
  end
end

# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
  options = { error_threshold: 5.0, top_n: 5 }
  parser = OptionParser.new do |opts|
    opts.banner = 'Usage: log_analyzer.rb LOGFILE [options]  (use "-" to read stdin)'
    opts.on('--error-threshold PCT', Float, 'Alert if 5xx error rate >= PCT (default 5.0)') do |v|
      options[:error_threshold] = v
    end
    opts.on('--top N', Integer, 'Number of top IPs/paths to show (default 5)') do |v|
      options[:top_n] = v
    end
  end
  parser.parse!

  path = ARGV.shift
  if path.nil?
    warn parser.banner
    exit 1
  end

  analyzer = LogAnalyzer.new(error_threshold_pct: options[:error_threshold], top_n: options[:top_n])

  io = path == '-' ? $stdin : File.open(path, 'r')
  begin
    io.each_line { |line| analyzer.feed(line) }
  ensure
    io.close unless path == '-'
  end

  puts analyzer.report
  exit(analyzer.alert? ? 2 : 0)
end

Step-by-step walkthrough

The regex. LINE_REGEX uses Ruby’s %r{...}x extended syntax instead of the usual /.../ delimiters. This matters more than it looks: the extended mode lets you spread the pattern across multiple lines with inline comments, which is the only way to keep a regex this complex readable six months from now. Using %r{...} instead of /.../ also means literal slashes — like the ones in a date such as “10/Oct/2023” — don’t need to be escaped or, worse, silently truncate your regex.

Named captures. Every group in the regex is named (?<ip>, ?<status>, and so on), so LogParser.parse_line can pull fields out with m[:ip] instead of tracking numbered groups by position. If you ever need to add a field, you just add a named group — nothing downstream breaks.

Streaming, not slurping. The CLI reads the file with io.each_line rather than File.read, so LogAnalyzer processes one line at a time. On a multi-gigabyte log file this is the difference between constant memory usage and an out-of-memory crash.

The alert exit code. When the 5xx error rate meets or exceeds your --error-threshold, the script exits with status code 2 instead of 0. This is deliberate: it lets you wire the script directly into cron with a mailer, or into a monitoring system that treats non-zero exit codes as failures, with zero extra glue code.

Example output

Running the analyzer against a sample log with a mix of 200s, 401s, 404s, and 500s produces a full report and, because the error rate exceeds the default 5% threshold, an alert:

Terminal output of running log_analyzer.rb showing a full report and an ALERT for exceeding the error threshold

Usage

# Analyze a log file directly
ruby log_analyzer.rb /var/log/nginx/access.log

# Only alert if the 5xx rate hits 10% or higher
ruby log_analyzer.rb /var/log/nginx/access.log --error-threshold 10

# Show the top 10 IPs and paths instead of the default 5
ruby log_analyzer.rb /var/log/nginx/access.log --top 10

# Pipe logs in from anywhere, e.g. journalctl or a remote tail over SSH
cat access.log | ruby log_analyzer.rb -

# Wire it into cron, alerting only when the exit code signals a problem
* * * * * ruby /opt/scripts/log_analyzer.rb /var/log/nginx/access.log --error-threshold 5 || mail -s "High error rate" ops@example.com < /dev/null

Troubleshooting

“Unparsed lines” is high or equal to my total line count. Your log format doesn’t match the standard “combined” format the regex expects. Check whether your Nginx or Apache config customizes the log_format directive — if so, adjust LINE_REGEX to match your actual field order. Printing a raw unparsed line with puts line inside the else branch of LogAnalyzer#feed is the fastest way to see exactly what’s not matching.

Timestamps come back as nil. Time.strptime is strict about the format string. If your logs use a different timezone format or separator, adjust the format string '%d/%b/%Y:%H:%M:%S %z' to match. The script degrades gracefully here — a bad timestamp doesn’t crash the run, it just means that entry’s time field is nil.

Response times never show up in the report. The trailing request-time field is optional in the regex because not every Nginx/Apache config logs it. Add $request_time (Nginx) or %D (Apache) to the end of your log format directive if you want percentile timing in the report.

Permission denied reading the log file. On most distributions, /var/log/nginx/access.log is only readable by root or the adm group. Run the script with sudo, or add your user to the appropriate group.

Extending this script

  • Ship alerts somewhere useful. Instead of relying on the exit code alone, POST the report to a Slack webhook or PagerDuty when analyzer.alert? is true.
  • Track trends over time. Append each run’s summary line (timestamp, total requests, error rate) to a CSV file, and you have a lightweight time series you can graph without standing up a metrics stack.
  • Support log rotation and gzip. Use Zlib::GzipReader to transparently read access.log.1.gz style rotated logs alongside the live file.
  • Filter by time window. Parse the time field and only include entries from the last N minutes, so the script can run frequently and only report on recent activity.
  • Add a JSON output mode alongside the human-readable report, so the script can feed structured data into other tools.

Sign in to take Cornell notes on this lesson — they save automatically and stay with your account.

Sign in

Click to access the login or register cheese