Skip to content

08 · Logging & Observability for Scripts

A script that fails silently at 3am is worse than one that fails loudly. This module covers structured (JSON) logging, log levels and correlation IDs, and wiring scripts into the monitoring, metrics, and alerting systems that actually watch production.

Why structured logging

Plain-text logs ("started backup at 3am") are fine for a human tailing a file, but painful for a machine to parse reliably. Structured logs emit one JSON object per line instead — every field is queryable without fragile regexes.

# plain text — easy to read, hard to query
2026-07-18 09:00:00 backup failed: disk full

# structured — one JSON object per line, trivially queryable with jq
{"time":"2026-07-18T09:00:00Z","level":"error","msg":"backup failed: disk full"}

A structured logging function

LOG_FILE="${LOG_FILE:-/var/log/myscript.log}"

log() {
    local level="$1"; shift
    local message="$*"
    local timestamp
    timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    printf '{"time":"%s","level":"%s","script":"%s","pid":%d,"msg":"%s"}\n' \
        "$timestamp" "$level" "$(basename "$0")" "$$" "$message" \
        | tee -a "$LOG_FILE"
}

log_info()  { log "info"  "$@"; }
log_warn()  { log "warn"  "$@"; }
log_error() { log "error" "$@"; }

log_info "starting job"
log_error "failed to connect to database"
{"time":"2026-07-18T09:00:00Z","level":"info","script":"myscript.sh","pid":4210,"msg":"starting job"}
{"time":"2026-07-18T09:00:01Z","level":"error","script":"myscript.sh","pid":4210,"msg":"failed to connect to database"}

Log levels and filtering verbosity

LOG_LEVEL="${LOG_LEVEL:-info}"     # debug < info < warn < error

should_log() {
    local level="$1"
    case "$LOG_LEVEL" in
        debug) return 0 ;;
        info)  [[ "$level" != "debug" ]] ;;
        warn)  [[ "$level" == "warn" || "$level" == "error" ]] ;;
        error) [[ "$level" == "error" ]] ;;
    esac
}

log() {
    local level="$1"; shift
    should_log "$level" || return 0
    printf '{"time":"%s","level":"%s","msg":"%s"}\n' \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$level" "$*" | tee -a "$LOG_FILE"
}

Correlation IDs for tracing one run across log lines

RUN_ID="${RUN_ID:-$(uuidgen 2>/dev/null || date +%s%N)}"

log() {
    local level="$1"; shift
    printf '{"time":"%s","level":"%s","run_id":"%s","msg":"%s"}\n' \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$level" "$RUN_ID" "$*" | tee -a "$LOG_FILE"
}

Exporting RUN_ID before calling downstream scripts (export RUN_ID; ./step2.sh) lets every step of a multi-script job share the same ID, so a log aggregator can join all of them into one trace by filtering on run_id.

Querying structured logs with jq

jq 'select(.level == "error")' /var/log/myscript.log
jq -r 'select(.run_id == "abc123") | .msg' /var/log/myscript.log

Sending logs to syslog

logger -t myscript "job started"                          # tags each line with "myscript" in syslog
logger -t myscript -p user.err "job failed: $reason"       # set a syslog priority

# view it
journalctl -t myscript -f

Emitting metrics for a monitoring system

# Prometheus node_exporter "textfile collector" pattern — no long-running exporter needed
METRICS_FILE="/var/lib/node_exporter/textfile_collector/myscript.prom"

write_metric() {
    local name="$1" value="$2"
    echo "${name} ${value}" >> "${METRICS_FILE}.$$"
    mv "${METRICS_FILE}.$$" "$METRICS_FILE"    # atomic — never a half-written file
}

start_time=$(date +%s)
# ... do the actual work ...
end_time=$(date +%s)

write_metric "myscript_last_run_timestamp_seconds" "$end_time"
write_metric "myscript_duration_seconds" "$(( end_time - start_time ))"
write_metric "myscript_last_run_success" "1"

Heartbeats and dead man's switches

# push a heartbeat — an external monitor alerts if this stops arriving on schedule
curl -fsS -m 10 "https://hc-ping.com/your-check-id" >/dev/null || true

# or simpler: touch a file a monitoring agent watches for staleness
touch /var/run/myscript.heartbeat

A "dead man's switch" flips the usual alerting logic: instead of alerting when something goes wrong, it alerts when the expected success signal stops showing up — the only reliable way to catch a cron job that silently stopped running altogether.

Alerting on failure automatically

notify_failure() {
    curl -fsS -X POST -H 'Content-Type: application/json' \
        -d "{\"text\":\"myscript failed — check logs (run_id=$RUN_ID)\"}" \
        "$ALERT_WEBHOOK_URL" >/dev/null || true
}

trap 'log_error "script failed at line $LINENO"; notify_failure' ERR

How It Actually Works

Structured logging (emitting JSON lines instead of free text) doesn't change anything about how the shell delivers output — it's still just bytes written to fd 1/2 and possibly piped or redirected — the entire value is in making the log line parseable by downstream tools like jq, turning what would otherwise require fragile regex-based grep/awk extraction into a structural query, because now a proper JSON parser (not just line-oriented pattern matching) can walk each entry.

Buffering behavior directly affects how "real-time" observability actually is: as noted earlier, a process writing to a pipe (rather than an interactive terminal) typically switches its C library stdio layer from line-buffered to fully block-buffered — meaning log lines can sit in a user-space buffer for seconds before actually reaching the write(2) syscall, which is precisely why log-forwarding pipelines can appear to "lag" behind real events even though nothing is actually stuck at the kernel or network layer; the delay lives in unflushed userspace buffers inside the logging process itself.

Alerting scripts that watch exit codes or journalctl/log output for failure patterns and then fire a notification are, mechanically, just another instance of the same signal-and-exit-status vocabulary from this whole course — a monitoring script forks a check, inspects $? or greps recent log output, and its "alert" action is itself just another forked process (curl to a webhook, mail, etc.), chained together with the exact same &&/if control-flow primitives used everywhere else in bash.

Cheat sheet

Technique Purpose
JSON log lines machine-parseable structured logging
log levels (debug/info/warn/error) control verbosity
a run_id/correlation ID trace one execution across log lines/systems
logger -t tag send lines to syslog/journald
textfile-collector .prom file expose metrics to Prometheus without a daemon
heartbeat file/ping detect when a scheduled job silently stopped running
trap ... ERR + webhook alert immediately on failure

Exercise

Add structured JSON logging with debug/info/warn/error levels and a run_id to Level 1's backup.sh. Then add a Prometheus textfile-collector metric recording the backup's duration and success/failure, and a trap ... ERR that posts a failure notification to a webhook URL read from an environment variable.