#!/usr/bin/env bash ######################################################################################### #### log-disk-analyzer.sh — Analyze log directory disk usage and report problems #### #### Reports largest files, growth rates, unrotated logs, broken symlinks, #### #### empty files, and subdirectory breakdown #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./log-disk-analyzer.sh #### #### ./log-disk-analyzer.sh --path /opt/app/logs --top 10 #### #### ./log-disk-analyzer.sh --json #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── LOG_PATH="${LOG_PATH:-/var/log}" TOP_COUNT="${TOP_COUNT:-20}" COLOR="${COLOR:-auto}" TEXTFILE_DIR="/var/lib/node_exporter" PROM_FILE="" JSON_OUTPUT="false" VERBOSE="false" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME TOTAL_SIZE=0 TOTAL_FILES=0 EMPTY_COUNT=0 BROKEN_COUNT=0 UNROTATED_COUNT=0 FILE_LIST=() SIZE_LIST=() RECOMMENDATIONS=() # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BOLD="" DIM="" RESET=""; return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } # ── Helpers ─────────────────────────────────────────────────────────── human_size() { local b="$1" if [[ "$b" -ge 1073741824 ]]; then printf "%.1f GB" "$(echo "scale=1; $b/1073741824" | bc)" elif [[ "$b" -ge 1048576 ]]; then printf "%.1f MB" "$(echo "scale=1; $b/1048576" | bc)" elif [[ "$b" -ge 1024 ]]; then printf "%.1f KB" "$(echo "scale=1; $b/1024" | bc)" else printf "%d B" "$b"; fi } file_age_days() { local mtime now age mtime=$(stat -c %Y "$1" 2>/dev/null) || return 1 now=$(date +%s); age=$(( (now - mtime) / 86400 )) [[ "$age" -lt 1 ]] && age=1 echo "$age" } separator() { printf " %s\n" "$(printf '%.0s─' {1..62})"; } json_escape() { local s="$1"; s="${s//\\/\\\\}"; s="${s//\"/\\\"}"; printf '%s' "$s"; } # ── Collect all files ───────────────────────────────────────────────── collect_files() { while IFS= read -r line; do local size="${line%% *}" file="${line#* }" [[ -z "$size" || -z "$file" ]] && continue FILE_LIST+=("$file"); SIZE_LIST+=("$size") TOTAL_SIZE=$((TOTAL_SIZE + size)); TOTAL_FILES=$((TOTAL_FILES + 1)) done < <(find "$LOG_PATH" -type f -printf '%s\t%p\n' 2>/dev/null | sort -t$'\t' -k1 -rn) } # ── Top files by size ───────────────────────────────────────────────── print_top_files() { local limit="$TOP_COUNT" [[ "$VERBOSE" == "true" ]] && limit="${#FILE_LIST[@]}" [[ "$limit" -gt "${#FILE_LIST[@]}" ]] && limit="${#FILE_LIST[@]}" echo ""; echo -e " ${BOLD}TOP ${limit} FILES BY SIZE${RESET}"; separator for (( i = 0; i < limit; i++ )); do local size_h color="" size_h="$(human_size "${SIZE_LIST[$i]}")" [[ "${SIZE_LIST[$i]}" -ge 104857600 ]] && color="$RED" [[ "${SIZE_LIST[$i]}" -lt 104857600 && "${SIZE_LIST[$i]}" -ge 52428800 ]] && color="$YELLOW" printf " %b%-10s%b %s\n" "$color" "$size_h" "$RESET" "${FILE_LIST[$i]}" done } # ── Growth rates ────────────────────────────────────────────────────── print_growth_rates() { local limit="$TOP_COUNT" [[ "$VERBOSE" == "true" ]] && limit="${#FILE_LIST[@]}" [[ "$limit" -gt "${#FILE_LIST[@]}" ]] && limit="${#FILE_LIST[@]}" local -a rates=() rate_files=() rate_ages=() for (( i = 0; i < ${#FILE_LIST[@]}; i++ )); do local age; age=$(file_age_days "${FILE_LIST[$i]}") || continue rates+=("$(( SIZE_LIST[i] / age ))"); rate_files+=("${FILE_LIST[$i]}"); rate_ages+=("$age") done [[ ${#rates[@]} -eq 0 ]] && return local -a sorted_idx=() mapfile -t sorted_idx < <(for i in "${!rates[@]}"; do echo "$i ${rates[$i]}"; done | sort -k2 -rn | head -n "$limit" | awk '{print $1}') echo ""; echo -e " ${BOLD}GROWTH RATE (SIZE / AGE)${RESET}"; separator for idx in "${sorted_idx[@]}"; do printf " %-16s %s (%d days old)\n" "$(human_size "${rates[$idx]}")/day" "${rate_files[$idx]}" "${rate_ages[$idx]}" done } # ── Unrotated logs ──────────────────────────────────────────────────── print_unrotated() { echo ""; echo -e " ${BOLD}UNROTATED LOGS (>100 MB, >7 days old)${RESET}"; separator local found=0 while IFS= read -r file; do [[ -z "$file" ]] && continue local size age; size=$(stat -c %s "$file" 2>/dev/null) || continue [[ "$size" -lt 104857600 ]] && continue age=$(file_age_days "$file") || continue; [[ "$age" -lt 7 ]] && continue found=1; UNROTATED_COUNT=$((UNROTATED_COUNT + 1)) local size_h; size_h="$(human_size "$size")" printf " %b%-10s%b %3d days %s\n" "$RED" "$size_h" "$RESET" "$age" "$file" RECOMMENDATIONS+=("Rotate $(basename "$file") — ${size_h} and ${age} days old") done < <(find "$LOG_PATH" -type f -name '*.log' 2>/dev/null) [[ "$found" -eq 0 ]] && echo -e " ${GREEN}None found${RESET}" } # ── Empty files ─────────────────────────────────────────────────────── print_empty_files() { echo ""; echo -e " ${BOLD}EMPTY FILES${RESET}"; separator local found=0 while IFS= read -r file; do [[ -z "$file" ]] && continue; found=1; EMPTY_COUNT=$((EMPTY_COUNT + 1)) echo " $file" done < <(find "$LOG_PATH" -type f -empty 2>/dev/null) [[ "$found" -eq 0 ]] && echo -e " ${GREEN}None found${RESET}" if [[ "$EMPTY_COUNT" -gt 0 ]]; then RECOMMENDATIONS+=("Remove ${EMPTY_COUNT} empty log files to reclaim inodes") fi } # ── Broken symlinks ────────────────────────────────────────────────── print_broken_symlinks() { echo ""; echo -e " ${BOLD}BROKEN SYMLINKS${RESET}"; separator local found=0 while IFS= read -r link; do [[ -z "$link" ]] && continue; found=1; BROKEN_COUNT=$((BROKEN_COUNT + 1)) local target; target=$(readlink "$link" 2>/dev/null || echo "unknown") printf " %b%s%b -> %s\n" "$YELLOW" "$link" "$RESET" "$target" done < <(find "$LOG_PATH" -xtype l 2>/dev/null) [[ "$found" -eq 0 ]] && echo -e " ${GREEN}None found${RESET}" if [[ "$BROKEN_COUNT" -gt 0 ]]; then RECOMMENDATIONS+=("Fix ${BROKEN_COUNT} broken symlinks") fi } # ── Subdirectory breakdown ──────────────────────────────────────────── print_subdir_breakdown() { echo ""; echo -e " ${BOLD}DISK USAGE BY SUBDIRECTORY${RESET}"; separator du -d 1 "$LOG_PATH" 2>/dev/null | sort -rn | while IFS=$'\t' read -r kb path; do [[ "$path" == "$LOG_PATH" ]] && continue printf " %-10s %s\n" "$(human_size $((kb * 1024)))" "$path" done || true } # ── Summary ─────────────────────────────────────────────────────────── print_summary() { echo ""; echo -e " ${BOLD}Summary${RESET}" printf " %-20s %s\n" "Total size:" "$(human_size "$TOTAL_SIZE")" printf " %-20s %d\n" "Total files:" "$TOTAL_FILES" printf " %-20s %d\n" "Empty files:" "$EMPTY_COUNT" printf " %-20s %d\n" "Broken symlinks:" "$BROKEN_COUNT" printf " %-20s %d\n" "Unrotated logs:" "$UNROTATED_COUNT" if [[ ${#RECOMMENDATIONS[@]} -gt 0 ]]; then echo ""; echo -e " ${BOLD}Recommendations:${RESET}" for rec in "${RECOMMENDATIONS[@]}"; do echo -e " ${YELLOW}•${RESET} $rec"; done fi echo "" } # ── JSON output ─────────────────────────────────────────────────────── print_json() { local limit="$TOP_COUNT" i first [[ "$VERBOSE" == "true" ]] && limit="${#FILE_LIST[@]}" [[ "$limit" -gt "${#FILE_LIST[@]}" ]] && limit="${#FILE_LIST[@]}" printf '{"path":"%s","top_files":[' "$(json_escape "$LOG_PATH")" for (( i = 0; i < limit; i++ )); do [[ "$i" -gt 0 ]] && printf ',' printf '{"file":"%s","size":%s}' "$(json_escape "${FILE_LIST[$i]}")" "${SIZE_LIST[$i]}" done printf '],"growth_rates":[' first=1 for (( i = 0; i < limit && i < ${#FILE_LIST[@]}; i++ )); do local age; age=$(file_age_days "${FILE_LIST[$i]}") || continue [[ "$first" -eq 0 ]] && printf ','; first=0 printf '{"file":"%s","bytes_per_day":%d,"age_days":%d}' \ "$(json_escape "${FILE_LIST[$i]}")" "$(( SIZE_LIST[i] / age ))" "$age" done printf '],"unrotated_logs":[' first=1 while IFS= read -r file; do [[ -z "$file" ]] && continue local size age; size=$(stat -c %s "$file" 2>/dev/null) || continue [[ "$size" -lt 104857600 ]] && continue age=$(file_age_days "$file") || continue; [[ "$age" -lt 7 ]] && continue [[ "$first" -eq 0 ]] && printf ','; first=0 printf '{"file":"%s","size":%d,"age_days":%d}' "$(json_escape "$file")" "$size" "$age" done < <(find "$LOG_PATH" -type f -name '*.log' 2>/dev/null) printf '],"empty_files":[' first=1 while IFS= read -r file; do [[ -z "$file" ]] && continue; [[ "$first" -eq 0 ]] && printf ','; first=0 printf '"%s"' "$(json_escape "$file")" done < <(find "$LOG_PATH" -type f -empty 2>/dev/null) printf '],"broken_symlinks":[' first=1 while IFS= read -r link; do [[ -z "$link" ]] && continue; [[ "$first" -eq 0 ]] && printf ','; first=0 printf '{"link":"%s","target":"%s"}' "$(json_escape "$link")" "$(json_escape "$(readlink "$link" 2>/dev/null || echo unknown)")" done < <(find "$LOG_PATH" -xtype l 2>/dev/null) printf '],"summary":{"total_size":%d,"total_files":%d,"empty_files":%d,"broken_symlinks":%d,"unrotated_logs":%d}}\n' \ "$TOTAL_SIZE" "$TOTAL_FILES" "$EMPTY_COUNT" "$BROKEN_COUNT" "$UNROTATED_COUNT" } # ── Prometheus output ───────────────────────────────────────────────── write_prometheus() { local file="$1" local output_dir output_dir="$(dirname "$file")" mkdir -p "$output_dir" local tmp tmp=$(mktemp "${output_dir}/.log_disk.XXXXXX") { echo "# HELP log_disk_total_bytes Total size of log directory in bytes" echo "# TYPE log_disk_total_bytes gauge" printf 'log_disk_total_bytes{path="%s"} %d\n' "$LOG_PATH" "$TOTAL_SIZE" echo "# HELP log_disk_total_files Total number of files in log directory" echo "# TYPE log_disk_total_files gauge" printf 'log_disk_total_files{path="%s"} %d\n' "$LOG_PATH" "$TOTAL_FILES" echo "# HELP log_disk_empty_files Number of empty files in log directory" echo "# TYPE log_disk_empty_files gauge" printf 'log_disk_empty_files{path="%s"} %d\n' "$LOG_PATH" "$EMPTY_COUNT" echo "# HELP log_disk_broken_symlinks Number of broken symlinks in log directory" echo "# TYPE log_disk_broken_symlinks gauge" printf 'log_disk_broken_symlinks{path="%s"} %d\n' "$LOG_PATH" "$BROKEN_COUNT" echo "# HELP log_disk_unrotated_logs Number of unrotated log files" echo "# TYPE log_disk_unrotated_logs gauge" printf 'log_disk_unrotated_logs{path="%s"} %d\n' "$LOG_PATH" "$UNROTATED_COUNT" } > "$tmp" chmod 644 "$tmp" mv -f "$tmp" "$file" verbose "Metrics written to ${file}" } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ usage() { cat <&2; exit 1 ;; *) err "Unexpected argument: $1"; echo "Run ${SCRIPT_NAME} --help for usage" >&2; exit 1 ;; esac done } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors if [[ ! -d "$LOG_PATH" ]]; then err "Directory not found: $LOG_PATH" exit 1 fi collect_files if [[ "$JSON_OUTPUT" == "true" ]]; then print_json else echo "" echo -e "${BOLD}Log Disk Analyzer${RESET}" echo -e "${DIM}Path: ${LOG_PATH}${RESET}" print_top_files print_growth_rates print_unrotated print_empty_files print_broken_symlinks print_subdir_breakdown print_summary fi if [[ -n "$PROM_FILE" ]]; then write_prometheus "$PROM_FILE" fi } main "$@"