Skip to content

Implementation — Log Analyzer

Build the report incrementally, one section at a time.

Step 1 — Scaffold and Argument Parsing

#!/usr/bin/env bash
set -euo pipefail

TOP_N=10
OUTPUT=""
FILTER_STATUS=""
LOGFILE=""

usage() {
    cat <<EOF
Usage: $(basename "$0") [OPTIONS] LOGFILE

  -n, --top N       Show top N results (default: 10)
  -o, --output FILE Write report to FILE (default: stdout)
  -s, --status CODE Filter to a specific HTTP status code
  -h, --help        Show this help
EOF
}

while [[ "${1:-}" =~ ^- ]]; do
    case "$1" in
        -n|--top)    TOP_N="${2:?--top requires a value}"; shift ;;
        -o|--output) OUTPUT="${2:?--output requires a value}"; shift ;;
        -s|--status) FILTER_STATUS="${2:?--status requires a value}"; shift ;;
        -h|--help)   usage; exit 0 ;;
        *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
    esac
    shift
done

LOGFILE="${1:-}"
[[ -z "$LOGFILE" ]] && { echo "Error: LOGFILE required" >&2; usage; exit 1; }
[[ -f "$LOGFILE" ]] || { echo "Error: '$LOGFILE' not found" >&2; exit 1; }
[[ -r "$LOGFILE" ]] || { echo "Error: '$LOGFILE' not readable" >&2; exit 1; }

Step 2 — Output Routing

Route all report output to either a file or stdout:

# Open output descriptor
if [[ -n "$OUTPUT" ]]; then
    exec 3>"$OUTPUT"
else
    exec 3>&1
fi

report() { echo "$*" >&3; }
report_line() { printf "  %-35s %7s\n" "$1" "$2" >&3; }

Step 3 — Traffic Summary

print_traffic_summary() {
    local total unique_ips total_bytes
    total=$(wc -l < "$LOGFILE")
    unique_ips=$(awk '{print $1}' "$LOGFILE" | sort -u | wc -l)
    total_bytes=$(awk '{sum += $10} END {print sum+0}' "$LOGFILE")

    # Convert bytes to human-readable
    local human_bytes
    if (( total_bytes > 1073741824 )); then
        human_bytes=$(awk -v b="$total_bytes" 'BEGIN { printf "%.1f GB", b/1073741824 }')
    elif (( total_bytes > 1048576 )); then
        human_bytes=$(awk -v b="$total_bytes" 'BEGIN { printf "%.1f MB", b/1048576 }')
    else
        human_bytes=$(awk -v b="$total_bytes" 'BEGIN { printf "%.1f KB", b/1024 }')
    fi

    report "── Traffic ──────────────────────────────"
    report_line "Total requests:" "$total"
    report_line "Unique IPs:"    "$unique_ips"
    report_line "Total bytes:"   "$human_bytes"
    report ""
}

Step 4 — Status Code Breakdown

print_status_codes() {
    local total
    total=$(wc -l < "$LOGFILE")

    report "── Status Codes ─────────────────────────"

    awk '{print $9}' "$LOGFILE" | sort | uniq -c | sort -rn |
    while read -r count code; do
        local pct
        pct=$(awk -v c="$count" -v t="$total" 'BEGIN { printf "%.1f%%", c/t*100 }')
        printf "  %-6s  %7d  (%s)\n" "$code" "$count" "$pct" >&3
    done

    report ""
}

sort | uniq -c | sort -rn is the standard shell frequency analysis pattern

  • sort — groups identical lines together (required for uniq)
  • uniq -c — prefixes each group with its count
  • sort -rn — sort by count, descending, numerically

Step 5 — Top N IPs

print_top_ips() {
    report "── Top ${TOP_N} IPs ─────────────────────────"
    awk '{print $1}' "$LOGFILE" |
        sort | uniq -c | sort -rn | head -"$TOP_N" |
    while read -r count ip; do
        printf "  %-20s %7d requests\n" "$ip" "$count" >&3
    done
    report ""
}

Step 6 — Top Requested Paths

print_top_paths() {
    report "── Top ${TOP_N} Requested Paths ────────────"
    awk '{print $7}' "$LOGFILE" |
        sort | uniq -c | sort -rn | head -"$TOP_N" |
    while read -r count path; do
        printf "  %-35s %7d requests\n" "$path" "$count" >&3
    done
    report ""
}

Step 7 — Error Listing

print_errors() {
    local error_count
    error_count=$(awk '$9 ~ /^5/' "$LOGFILE" | wc -l)
    [[ "$error_count" -eq 0 ]] && return

    report "── Server Errors (5xx) — ${error_count} total ────────"
    awk '$9 ~ /^5/ {
        gsub(/[\[\]]/, "", $4)
        printf "  [%s] %s \"%s %s\" %s\n", $4, $1, $6, $7, $9
    }' "$LOGFILE" | head -20 >&3
    (( error_count > 20 )) && report "  ... and $(( error_count - 20 )) more"
    report ""
}

Step 8 — Wire Everything in main()

main() {
    local filename
    filename="$(basename "$LOGFILE")"
    local line_count
    line_count=$(wc -l < "$LOGFILE")

    report "=== Log Analysis Report ==="
    report "File:    $LOGFILE"
    report "Lines:   $(printf "%'d" "$line_count")"
    report "Analyzed: $(date '+%Y-%m-%d %H:%M:%S')"
    report ""

    print_traffic_summary
    print_status_codes
    print_top_ips
    print_top_paths
    print_errors

    report "=== End of Report ==="

    [[ -n "$OUTPUT" ]] && echo "Report written to: $OUTPUT"
}

[[ "${BASH_SOURCE[0]}" == "$0" ]] && main "$@"

Why read the log multiple times

Each awk pass reads the log sequentially. For most log files (< 500 MB) this is fast enough. If the file is very large, consider sorting and preprocessing once into a temp file, then running all analysis passes against that.


setup | testing