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 |