Skip to content

Solution — Log Analyzer

Full reference implementation. Study this after attempting the project yourself.

log_analyzer.sh

#!/usr/bin/env bash
# =============================================================================
# log_analyzer.sh — Parse Apache/Nginx access logs and generate a report
#
# Usage:
#   log_analyzer.sh [OPTIONS] LOGFILE
#
# Options:
#   -n, --top N       Top N results per section (default: 10)
#   -o, --output FILE Write report to FILE (default: stdout)
#   -h, --help        Show this help
# =============================================================================
set -euo pipefail

TOP_N=10
OUTPUT=""
LOGFILE=""

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

  -n, --top N       Top N results per section (default: 10)
  -o, --output FILE Write report to FILE (default: stdout)
  -h, --help        Show this help
EOF
}

# ── argument parsing ──────────────────────────────────────────────────────────
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 ;;
        -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; }

# ── output routing ────────────────────────────────────────────────────────────
[[ -n "$OUTPUT" ]] && exec 3>"$OUTPUT" || exec 3>&1

report()      { echo "$*" >&3; }
report_fmt()  { printf "$@" >&3; }

# ── helpers ───────────────────────────────────────────────────────────────────
human_bytes() {
    awk -v b="$1" 'BEGIN {
        if (b >= 1073741824) printf "%.1f GB", b/1073741824
        else if (b >= 1048576) printf "%.1f MB", b/1048576
        else printf "%.1f KB", b/1024
    }'
}

# ── sections ──────────────────────────────────────────────────────────────────
print_traffic() {
    local total unique_ips raw_bytes
    total=$(wc -l < "$LOGFILE")
    unique_ips=$(awk '{print $1}' "$LOGFILE" | sort -u | wc -l)
    raw_bytes=$(awk '{sum += $10} END {print sum+0}' "$LOGFILE")

    report "── Traffic ──────────────────────────────"
    report_fmt "  %-30s %10d\n" "Total requests:"  "$total"
    report_fmt "  %-30s %10d\n" "Unique IPs:"      "$unique_ips"
    report_fmt "  %-30s %10s\n" "Total bytes:"     "$(human_bytes "$raw_bytes")"
    report ""
}

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 }')
        report_fmt "  %-6s %8d  %s\n" "$code" "$count" "$pct"
    done
    report ""
}

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
        report_fmt "  %-22s %7d requests\n" "$ip" "$count"
    done
    report ""
}

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
        report_fmt "  %-38s %7d\n" "$path" "$count"
    done
    report ""
}

print_top_agents() {
    report "── Top ${TOP_N} User Agents ─────────────────"
    awk -F'"' '{print $6}' "$LOGFILE" | sort | uniq -c | sort -rn | head -"$TOP_N" |
    while read -r count agent; do
        report_fmt "  %-50s %7d\n" "${agent:0:50}" "$count"
    done
    report ""
}

print_errors() {
    local error_count
    error_count=$(awk '$9 ~ /^5/' "$LOGFILE" | wc -l)
    [[ "$error_count" -eq 0 ]] && { report "(no 5xx errors found)"; report ""; return; }

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

# ── main ──────────────────────────────────────────────────────────────────────
main() {
    local line_count
    line_count=$(wc -l < "$LOGFILE")

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

    print_traffic
    print_status_codes
    print_top_ips
    print_top_paths
    print_top_agents
    print_errors

    report "=== End of Report ==="
    [[ -n "$OUTPUT" ]] && echo "Report written to: $OUTPUT"
}

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

Sample Output

=== Log Analysis Report === File: sample_access.log Lines: 2,000 Analyzed: 2024-01-15 09:00:00 ── Traffic ────────────────────────────── Total requests: 2,000 Unique IPs: 10 Total bytes: 47.2 MB ── Status Codes ───────────────────────── 200 1,667 (83.4%) 404 200 (10.0%) 500 83 (4.2%) 304 50 (2.5%) ── Top 10 IPs ─────────────────────────── 192.168.1.42 218 requests 10.0.0.5 204 requests ... ── Server Errors (5xx) — 83 total ──────── [15/Jan/2024:03:21:14] 192.168.1.99 "POST /api/upload" 500 ...

Key Design Decisions

Decision Why
Multiple awk passes Each reads sequentially; cleaner than one giant program
exec 3> for output Single descriptor change routes all >&3 writes uniformly
human_bytes() with awk Avoids bc and handles integers/floats uniformly
sort \| uniq -c \| sort -rn The canonical shell frequency analysis idiom
awk -F'"' '{print $6}' User-Agent is the 6th double-quote-delimited field in combined log

enhancements | overview