Monitoring scripts fail in one of two directions: they say nothing when something is wrong, or they say so much that everyone mutes them. Both are the same defect — an alert that does not carry a decision.
Topic 1: Extracting Signal From a Log
The pipeline from the awk lesson, applied to real questions:
# Error rate over the last hour, by minute
awk -v since="$(date -d '1 hour ago' '+%H:%M')" '
$3 >= since && /ERROR/ { split($3, t, ":"); count[t[1]":"t[2]]++ }
END { for (m in count) print m, count[m] }
' /var/log/app.log | sort
# Top endpoints returning 5xx
awk '$9 ~ /^5/ { count[$7]++ } END { for (p in count) print count[p], p }' access.log |
sort -rn | head -10
# Slowest requests (nginx $request_time in the last field)
awk '{ print $NF, $7 }' access.log | sort -rn | head -20
# Collapse IDs so paths group into endpoints
awk '{ p=$7; gsub(/[0-9]+/, "N", p); count[p]++ } END { for (k in count) print count[k], k }' access.log |
sort -rn | head
Comparing against a baseline:
A count alone is meaningless. “412 errors” matters only against what is normal.
current=$(grep -c ERROR /var/log/app.log)
baseline_file=/var/lib/monitoring/error_baseline
if [[ -r $baseline_file ]]; then
baseline=$(< "$baseline_file")
# Alert if more than double the baseline AND above an absolute floor
if (( current > baseline * 2 && current > 50 )); then
alert "error count ${current} vs baseline ${baseline}"
fi
fi
printf '%s\n' "$current" > "$baseline_file"
The absolute floor is what stops 1 error becoming 3 and paging someone at 3am for a 200% increase.
Finding the first occurrence, not the last:
# When did this error start?
grep -m1 'connection refused' /var/log/app.log
# What happened in the 30 seconds before?
grep -B50 -m1 'connection refused' /var/log/app.log
Tailing shows consequences. -m1 with -B shows causes.
Topic 2: A Health Check Worth Running
#!/usr/bin/env bash
set -Eeuo pipefail
readonly HOSTNAME=$(hostname -f)
readonly DISK_THRESHOLD=${DISK_THRESHOLD:-85}
readonly MEM_THRESHOLD=${MEM_THRESHOLD:-90}
declare -a problems=()
check_disk() {
while read -r pct mount; do
(( pct >= DISK_THRESHOLD )) || continue
local top
top=$(du -xh --max-depth=2 "$mount" 2>/dev/null | sort -rh | head -3 | tr '\n' '; ')
problems+=("DISK ${mount} at ${pct}% (threshold ${DISK_THRESHOLD}%) — largest: ${top}")
done < <(df -hP -x tmpfs -x devtmpfs | awk 'NR>1 {gsub(/%/,"",$5); print $5, $6}')
}
check_memory() {
local total avail pct
read -r total avail < <(free -m | awk '/^Mem:/ {print $2, $7}')
pct=$(( (total - avail) * 100 / total ))
(( pct >= MEM_THRESHOLD )) || return 0
local top
top=$(ps -eo comm,rss --sort=-rss | awk 'NR>1 && NR<=4 {printf "%s(%dMB) ", $1, $2/1024}')
problems+=("MEMORY at ${pct}% available-based (threshold ${MEM_THRESHOLD}%) — top: ${top}")
}
check_services() {
local svc
for svc in nginx postgresql; do
systemctl is-active --quiet "$svc" && continue
problems+=("SERVICE ${svc} is ${_:-inactive} — last logs: $(journalctl -u "$svc" -n3 --no-pager -o cat | tr '\n' '; ')")
done
}
check_disk
check_memory
check_services
(( ${#problems[@]} == 0 )) && exit 0
printf '%s\n' "${problems[@]}"
exit 1
The design choice that matters: every problem string carries its own evidence. “Disk at 91%” makes someone log in. “Disk at 91% — largest: 42G /var/lib/docker” tells them what to do before they log in.
Note free -m field 7 (available) rather than used — the memory lesson’s point, applied.
Topic 3: Alert Delivery
Slack:
notify_slack() {
local severity=$1 message=$2
local colour
case $severity in
critical) colour="#dc2626" ;;
warning) colour="#f59e0b" ;;
*) colour="#10b981" ;;
esac
local payload
payload=$(jq -n \
--arg colour "$colour" \
--arg title "${severity^^} on $(hostname -f)" \
--arg text "$message" \
'{attachments: [{color: $colour, title: $title, text: $text, ts: now|floor}]}')
curl -fsS --max-time 10 \
-X POST -H 'Content-Type: application/json' \
-d "$payload" \
"${SLACK_WEBHOOK_URL:?SLACK_WEBHOOK_URL is required}" >/dev/null
}
Email:
printf '%s\n' "${problems[@]}" |
mail -s "[${severity}] $(hostname -s): ${#problems[@]} issues" ops@example.com
mail needs a configured MTA, which is often absent on a container host — check before relying on it.
PagerDuty / Alertmanager:
curl -fsS --max-time 10 -X POST \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg key "$PD_ROUTING_KEY" --arg summary "$message" \
'{routing_key: $key, event_action: "trigger",
payload: {summary: $summary, severity: "error", source: "'"$(hostname -f)"'"}}')" \
https://events.pagerduty.com/v2/enqueue
Never let the notifier take down the script:
notify_slack critical "$message" || echo "WARN: slack delivery failed" >&2
Under set -e, a failed webhook aborts the run — so the check that found a real problem exits before doing anything about it. Alerting is best-effort by definition.
Topic 4: Rate Limiting and Deduplication
An alert that repeats every five minutes gets muted, and then the real one is missed too.
readonly STATE_DIR=/var/lib/monitoring
readonly ALERT_WINDOW=3600 # one hour
should_alert() {
local key=$1
local marker="${STATE_DIR}/alert-${key//\//_}"
mkdir -p "$STATE_DIR"
if [[ -f $marker ]]; then
local age=$(( $(date +%s) - $(stat -c %Y "$marker") ))
(( age < ALERT_WINDOW )) && return 1 # too soon
fi
touch "$marker"
return 0
}
if should_alert "disk-/var"; then
notify_slack critical "disk /var at ${pct}%"
fi
${key//\//_} replaces slashes so a mount point makes a valid filename — parameter expansion from the quoting lesson.
Recovery notifications:
An alert with no “resolved” message leaves people checking manually.
if (( pct >= DISK_THRESHOLD )); then
should_alert "disk-${mount}" && notify_slack critical "disk ${mount} at ${pct}%"
elif [[ -f "${STATE_DIR}/alert-${mount//\//_}" ]]; then
rm -f "${STATE_DIR}/alert-${mount//\//_}"
notify_slack info "RESOLVED: disk ${mount} back to ${pct}%"
fi
Flap damping:
Require N consecutive failures before alerting, so a single blip stays quiet:
readonly FAIL_COUNT_FILE="${STATE_DIR}/fails-${check_name}"
if check_failed; then
count=$(( $(cat "$FAIL_COUNT_FILE" 2>/dev/null || echo 0) + 1 ))
echo "$count" > "$FAIL_COUNT_FILE"
(( count >= 3 )) && notify_slack critical "${check_name} failed ${count}x"
else
rm -f "$FAIL_COUNT_FILE"
fi
Topic 5: Log Rotation for Your Own Scripts
A monitoring script that logs forever eventually fills the disk it is monitoring.
# /etc/logrotate.d/myapp-monitoring
/var/log/monitoring/*.log {
daily
rotate 14
size 50M
compress
delaycompress
missingok
notifempty
create 0640 monitoring monitoring
}
sudo logrotate -d /etc/logrotate.d/myapp-monitoring # dry run
cat /var/lib/logrotate/status # when it last ran
Self-managed rotation, when logrotate is not available:
readonly LOG=/var/log/monitoring/check.log
readonly MAX_BYTES=$(( 10 * 1024 * 1024 ))
rotate_if_needed() {
[[ -f $LOG ]] || return 0
local size
size=$(stat -c %s "$LOG")
(( size < MAX_BYTES )) && return 0
mv "$LOG" "${LOG}.1"
gzip -f "${LOG}.1"
: > "$LOG"
}
Try it yourself: Write a disk check that alerts with the top three space consumers included, add the one-hour rate limiter, and run it twice in a row. The second run should be silent.
Common mistake: Alerting on a threshold rather than a trend. “Disk at 85%” fires on a host that has been at 84% for two years. “Disk will reach 100% in under 48 hours at the current rate” is the alert that gives you a working day to respond — and it needs only two samples and a subtraction.