Shell Lesson 15 of 42

Logging Frameworks: syslog/journald, Structured Logs, Log Levels & Rotation — Making Your Scripts Observable

In a nutshell

Logging is your script talking out loud while it works — and log levels are the volume knob for how much it says. DEBUG is muttering every thought; FATAL is shouting “the building is on fire” on its way out the door. The reusable log() function you’ll build in this lesson is a switchboard: it takes each message, checks whether it’s loud enough to matter right now, stamps the time, and routes it to whoever is listening — your terminal, a file on disk, the system journal — in whatever format that listener understands.

Four ideas carry the whole lesson, and they map exactly onto the diagram below:

If you take one habit from this lesson, take this: route levelled, structured logs to stderr through one reusable function, and never let a secret reach a log destination. Everything below is the “why” and the production-grade “how” behind those habits.

Level: Advanced · Time: ~30–35 min

Prerequisites

After this lesson you can

Concept diagram of shell logging: a script calls info()/warn()/error() with a severity; a LOG_LEVEL threshold gate drops anything below the bar; one reusable _log() function stamps an ISO-8601 UTC timestamp and renders the line as human text or structured logfmt/JSON; the same line fans out to stderr (fd 2, always on), an optional rotated file, and on Linux the journald/syslog store via logger or systemd-cat; and you retrieve with journalctl -t and ship structured fields to Loki, Splunk, and Elastic — with numbered badges on the level threshold, the single stderr-writing log() function, structured formats, multi-destination fan-out, the Linux-only journald path, and retrieval plus secret redaction

Read the diagram left → right: a severity comes in and is filtered by the LOG_LEVEL gate, one _log() function timestamps and renders it, and the same line then fans out to stderr, a rotated file, and (on Linux) the journal — from which you retrieve with journalctl and ship structured fields to an aggregator. The six badges walk you from the level threshold through the multi-destination fan-out to the Linux-only journald caveat and the redaction rule.


In Wave 1 we used printf '...' >&2 for messages and called it logging. That works for scripts that run in a terminal where you see the output. It does not work for scripts that:

Real logging answers all of those. This lesson covers:

By the end you’ll have a 100-line library that gives shell scripts logging features competitive with Python’s logging module.


1. The minimum-viable logger (recap from L13)

The simplest useful logger:

SCRIPT="${0##*/}"
info() { printf '[%s] INFO:  %s\n' "$SCRIPT" "$*" >&2; }
warn() { printf '[%s] WARN:  %s\n' "$SCRIPT" "$*" >&2; }
err()  { printf '[%s] ERROR: %s\n' "$SCRIPT" "$*" >&2; }
die()  { err "$@"; exit 1; }

Good for hour-old scripts. Production needs more.


2. Log levels with a threshold

Standard levels (from Python, syslog, most languages):

Level Numeric Use
DEBUG 10 Verbose, “what is happening” detail
INFO 20 Normal status messages
WARN 30 Something unexpected, but recoverable
ERROR 40 Failure, but script may continue
FATAL 50 Failure that exits the script

The script defines a threshold (LOG_LEVEL), and only messages at that level or higher actually print. Below threshold, they are silently discarded.

# default to INFO
LOG_LEVEL="${LOG_LEVEL:-INFO}"

declare -A _LEVELS=(
  [DEBUG]=10
  [INFO]=20
  [WARN]=30
  [ERROR]=40
  [FATAL]=50
)

_log() {
  local lvl=$1; shift
  local lvl_n=${_LEVELS[$lvl]:-20}
  local thr_n=${_LEVELS[$LOG_LEVEL]:-20}
  (( lvl_n >= thr_n )) || return 0
  printf '[%s] %-5s: %s\n' "$SCRIPT" "$lvl" "$*" >&2
}

debug() { _log DEBUG "$@"; }
info()  { _log INFO  "$@"; }
warn()  { _log WARN  "$@"; }
error() { _log ERROR "$@"; }
fatal() { _log FATAL "$@"; exit 1; }

Usage:

debug "starting parse phase"     # only shown if LOG_LEVEL=DEBUG
info  "parsed 100 records"
warn  "skipping malformed line 42"
error "failed to upload to s3"
fatal "out of disk; aborting"

# Run with DEBUG visibility:
LOG_LEVEL=DEBUG ./script

This pattern alone gets you 80% of “real logging.” The rest is destinations, format, rotation.

Why associative array for levels?

_LEVELS[$lvl] is O(1) lookup. We could also use a case statement:

case "$lvl" in
  DEBUG) lvl_n=10 ;;
  INFO)  lvl_n=20 ;;
  WARN)  lvl_n=30 ;;
  ERROR) lvl_n=40 ;;
  FATAL) lvl_n=50 ;;
esac

Either works. Associative-array is cleaner but requires bash 4+. For scripts that must run on macOS stock bash 3.2, use the case form.

Numeric level threshold

If your CI sets LOG_LEVEL=2 instead of LOG_LEVEL=INFO, support both:

_log() {
  local lvl=$1; shift
  local lvl_n=${_LEVELS[$lvl]:-20}
  local thr_n
  if [[ "$LOG_LEVEL" =~ ^[0-9]+$ ]]; then
    thr_n="$LOG_LEVEL"
  else
    thr_n=${_LEVELS[$LOG_LEVEL]:-20}
  fi
  (( lvl_n >= thr_n )) || return 0
  printf '[%s] %-5s: %s\n' "$SCRIPT" "$lvl" "$*" >&2
}

3. Timestamp every line

Almost always you want timestamps. ISO 8601 is the standard:

ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }

_log() {
  local lvl=$1; shift
  ...
  printf '%s [%s] %-5s: %s\n' "$(ts)" "$SCRIPT" "$lvl" "$*" >&2
}

Output:

2026-06-22T14:35:12+0000 [deploy.sh] INFO : starting deploy
2026-06-22T14:35:13+0000 [deploy.sh] WARN : skipping malformed entry

Use UTC timestamps in production (date -u):

ts() { date -u '+%Y-%m-%dT%H:%M:%SZ'; }

UTC avoids the daylight-saving-time mess and matches what every log aggregator wants. The trailing Z literally means UTC.

Microsecond precision

For high-frequency logging:

ts() { date -u '+%Y-%m-%dT%H:%M:%S.%6NZ'; }

%6N is microseconds (BSD date uses %N for nanoseconds; truncate to 6 digits for microseconds). On macOS stock date, this might not work — install coreutils (brew install coreutils) and use gdate.


4. Multiple destinations: stderr + file + journald

Real scripts log to multiple places. The clean way: a single _emit function that writes to all configured handlers.

LOG_FILE="${LOG_FILE:-}"             # optional path
LOG_TO_SYSLOG="${LOG_TO_SYSLOG:-0}"  # 1 to also log to journald/syslog
LOG_TO_STDERR="${LOG_TO_STDERR:-1}"  # 1 to log to stderr (default on)

_emit() {
  local line=$1
  [[ "$LOG_TO_STDERR" == 1 ]] && printf '%s\n' "$line" >&2
  [[ -n "$LOG_FILE" ]] && printf '%s\n' "$line" >> "$LOG_FILE"
  if [[ "$LOG_TO_SYSLOG" == 1 ]]; then
    logger -t "$SCRIPT" -- "$line"      # POSIX syslog client
  fi
}

_log() {
  local lvl=$1; shift
  local lvl_n=${_LEVELS[$lvl]:-20}
  local thr_n=${_LEVELS[$LOG_LEVEL]:-20}
  (( lvl_n >= thr_n )) || return 0
  _emit "$(printf '%s [%s] %-5s: %s' "$(ts)" "$SCRIPT" "$lvl" "$*")"
}

Now:

# Just stderr (default)
./script

# Also to file
LOG_FILE=/var/log/myscript.log ./script

# Also to syslog
LOG_TO_SYSLOG=1 ./script

# Quiet stderr, only file
LOG_TO_STDERR=0 LOG_FILE=/var/log/x.log ./script

logger is the POSIX syslog client, present on every Linux system and macOS. By default it sends to local syslog (which on modern Linux is journald). View with:

journalctl -t myscript        # filter by tag (-t)
sudo tail -f /var/log/syslog  # raw

Portability note. logger exists on macOS too, but there is no journald there — the line goes into Apple’s unified logging system, which you read with log show --predicate 'process == "logger"' --last 5m or Console.app, not journalctl. journalctl, systemd-cat, and systemctl are systemd-Linux-only. The portability matrix in Going deeper has the full breakdown.

systemd-cat — alternative to logger

On systemd systems:

echo "this is a log line" | systemd-cat -t myscript -p info

systemd-cat writes directly to journald with full priority awareness:

_emit() {
  local line=$1 lvl=$2
  [[ "$LOG_TO_STDERR" == 1 ]] && printf '%s\n' "$line" >&2
  [[ -n "$LOG_FILE" ]] && printf '%s\n' "$line" >> "$LOG_FILE"
  if [[ "$LOG_TO_JOURNAL" == 1 ]] && command -v systemd-cat >/dev/null; then
    printf '%s\n' "$line" | systemd-cat -t "$SCRIPT" -p "$(_priority "$lvl")"
  fi
}

_priority() {
  case "$1" in
    DEBUG) echo "debug" ;;
    INFO)  echo "info" ;;
    WARN)  echo "warning" ;;
    ERROR) echo "err" ;;
    FATAL) echo "crit" ;;
    *)     echo "info" ;;
  esac
}

The journald priorities follow syslog conventions (emerg, alert, crit, err, warning, notice, info, debug).

logger -p — set syslog facility/priority

logger -p user.info "$line"      # facility=user, priority=info
logger -p user.err "$line"       # priority=err
logger -p local0.warn "$line"    # facility=local0 (1-7 reserved for app use)

Map your script’s level to a syslog priority:

_syslog_pri() {
  case "$1" in
    DEBUG) echo "debug" ;;
    INFO)  echo "info" ;;
    WARN)  echo "warning" ;;
    ERROR) echo "err" ;;
    FATAL) echo "crit" ;;
    *)     echo "info" ;;
  esac
}

This lets journalctl --priority=err correctly filter your script’s errors.


5. Structured logging — key=value and JSON

Plain text is fine for humans but bad for machines. Log aggregators (Loki, Splunk, ES, Datadog) parse logs into fields. Two common structured formats:

Logfmt (key=value)

Heroku-style. Easy to read for humans, parseable by machines.

2026-06-22T14:35:12Z level=info script=deploy.sh msg="starting deploy" env=prod tag=v1.2.3

Generate with:

log_kv() {
  local lvl=$1; shift
  local kvs=""
  while [[ $# -gt 0 ]]; do
    local key=${1%%=*}            # everything before first =
    local val=${1#*=}             # everything after first =
    # Quote if value contains space or special char
    if [[ "$val" =~ [[:space:]\"\\] ]]; then
      val=\"${val//\"/\\\"}\"
    fi
    kvs+=" $key=$val"
    shift
  done
  printf '%s level=%s script=%s%s\n' "$(ts)" "${lvl,,}" "$SCRIPT" "$kvs" >&2
}

log_kv INFO msg="starting deploy" env=prod tag=v1.2.3 user="$USER"

${lvl,,} is “lowercase the value” (bash 4+). For older bash use tr:

"$(printf '%s' "$lvl" | tr '[:upper:]' '[:lower:]')"

JSON

For structured-by-default output:

2026-06-22T14:35:12Z {"level":"info","script":"deploy.sh","msg":"starting deploy","env":"prod","tag":"v1.2.3"}

Build with jq:

log_json() {
  local lvl=$1; shift
  jq -cn --arg ts "$(ts)" --arg lvl "${lvl,,}" --arg script "$SCRIPT" \
        --arg msg "$1" --argjson kv "$(_make_kv_obj "${@:2}")" \
        '{ts: $ts, level: $lvl, script: $script, msg: $msg} + $kv' >&2
}

_make_kv_obj() {
  if [[ $# -eq 0 ]]; then echo "{}"; return; fi
  local out="{"
  while [[ $# -gt 0 ]]; do
    local key=${1%%=*} val=${1#*=}
    out+="\"$key\": $(jq -Rn --arg v "$val" '$v'), "
    shift
  done
  out=${out%, }      # strip trailing comma+space
  out+="}"
  echo "$out"
}

log_json INFO "starting deploy" env=prod tag=v1.2.3 user="$USER"

This is more involved. For most scripts, logfmt is plenty.

Picking a format at runtime

LOG_FORMAT="${LOG_FORMAT:-text}"   # text|kv|json

_render() {
  local lvl=$1 msg=$2
  shift 2
  case "$LOG_FORMAT" in
    text) printf '%s [%s] %-5s: %s\n' "$(ts)" "$SCRIPT" "$lvl" "$msg" ;;
    kv)   _logfmt_render "$lvl" "$msg" "$@" ;;
    json) _json_render "$lvl" "$msg" "$@" ;;
  esac
}

Now CI sets LOG_FORMAT=json and the platform team’s collector parses it; humans run with default text. Same script, two consumers.

Why quoting matters. logfmt splits on spaces, so a value with a space in it — msg="disk almost full"must be quoted or the parser reads full" as a bogus key. The log_kv above quotes any value containing a space, quote, or backslash and escapes inner quotes. JSON has the same rule enforced for you by jq, which is exactly why jq (not string concatenation) is the safe way to build it. This is verified: feeding path="/var with space" through log_kv renders path="/var with space" — one field, correctly quoted.


6. Log rotation

If your script writes to a file, that file grows forever. Use logrotate:

/etc/logrotate.d/myscript

/var/log/myscript.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    create 0644 myscript myscript
    postrotate
        # if your script keeps a long-running file handle, signal it
        # systemctl kill -s USR1 myscript.service 2>/dev/null || true
    endscript
}

Drop this file into /etc/logrotate.d/. The cron job /etc/cron.daily/logrotate (or systemd timer) runs logrotate /etc/logrotate.conf daily, processing every file in /etc/logrotate.d/.

Fields:

For shell scripts that just append-and-close every line, no postrotate is needed.

Manual rotation in-script (without logrotate)

Sometimes you can’t install logrotate (containers, CI). Roll your own:

LOG_FILE="/var/log/myscript.log"
MAX_SIZE=$((10 * 1024 * 1024))    # 10 MB

rotate_if_big() {
  [[ -f "$LOG_FILE" ]] || return 0
  local size
  size=$(wc -c < "$LOG_FILE")
  if (( size > MAX_SIZE )); then
    mv -- "$LOG_FILE" "${LOG_FILE}.$(date -u +%Y%m%dT%H%M%SZ)"
    : > "$LOG_FILE"
  fi
}

Call rotate_if_big periodically (or before logging). For more complex schemes (keep N rolled files, compress old ones), use logrotate proper.


7. Reading journald logs

For scripts that log to journald via logger or systemd-cat, retrieval is via journalctl:

# All entries from "myscript" tag
journalctl -t myscript

# Last 100 lines
journalctl -t myscript -n 100

# Follow live
journalctl -t myscript -f

# Filter by priority (errors and worse)
journalctl -t myscript -p err

# Time range
journalctl -t myscript --since="1 hour ago"
journalctl -t myscript --since="2026-06-22 09:00:00" --until="2026-06-22 10:00:00"

# As JSON (for piping into jq)
journalctl -t myscript -o json | jq '.MESSAGE'

# Just the message field, plain text
journalctl -t myscript -o cat

# Specific systemd unit (if your script runs as one)
journalctl -u myscript.service

Combined:

# Show errors from the last hour, just the messages
journalctl -t myscript -p err --since="1 hour ago" -o cat

8. The canonical lib/log.sh

Putting everything together:

# lib/log.sh — production-grade shell logging
# Source from any script: source "$(dirname "${BASH_SOURCE[0]}")/lib/log.sh"
#
# Configurable via environment variables:
#   LOG_LEVEL      DEBUG|INFO|WARN|ERROR|FATAL  (default: INFO)
#   LOG_FILE       path or empty                (default: empty = no file)
#   LOG_FORMAT     text|kv|json                 (default: text)
#   LOG_TO_STDERR  0 or 1                       (default: 1)
#   LOG_TO_SYSLOG  0 or 1                       (default: 0)
#
# Use: debug, info, warn, error, fatal — they take a message and optional key=value pairs
#   info "starting deploy" env=prod tag=v1.2.3

readonly LOG_SCRIPT_NAME="${0##*/}"
readonly LOG_HOSTNAME="$(hostname -s 2>/dev/null || echo unknown)"
declare -A _LOG_LEVELS=( [DEBUG]=10 [INFO]=20 [WARN]=30 [ERROR]=40 [FATAL]=50 )

LOG_LEVEL="${LOG_LEVEL:-INFO}"
LOG_FILE="${LOG_FILE:-}"
LOG_FORMAT="${LOG_FORMAT:-text}"
LOG_TO_STDERR="${LOG_TO_STDERR:-1}"
LOG_TO_SYSLOG="${LOG_TO_SYSLOG:-0}"

_log_ts() { date -u '+%Y-%m-%dT%H:%M:%SZ'; }

_log_threshold_n() {
  local v=${LOG_LEVEL^^}
  echo "${_LOG_LEVELS[$v]:-20}"
}

_log_pri() {
  case "$1" in
    DEBUG) echo debug ;;
    INFO) echo info ;;
    WARN) echo warning ;;
    ERROR) echo err ;;
    FATAL) echo crit ;;
    *) echo info ;;
  esac
}

_log_render_text() {
  local lvl=$1 msg=$2
  shift 2
  local extras=""
  if [[ $# -gt 0 ]]; then extras=" $*"; fi
  printf '%s [%s] %-5s: %s%s' "$(_log_ts)" "$LOG_SCRIPT_NAME" "$lvl" "$msg" "$extras"
}

_log_render_kv() {
  local lvl=$1 msg=$2
  shift 2
  local extras=""
  while [[ $# -gt 0 ]]; do
    local k=${1%%=*} v=${1#*=}
    if [[ "$v" =~ [[:space:]\"\\] ]]; then
      v="\"${v//\"/\\\"}\""
    fi
    extras+=" $k=$v"
    shift
  done
  printf '%s level=%s script=%s host=%s msg=%q%s' "$(_log_ts)" "${lvl,,}" "$LOG_SCRIPT_NAME" "$LOG_HOSTNAME" "$msg" "$extras"
}

_log_emit() {
  local lvl=$1; shift
  local rendered
  case "$LOG_FORMAT" in
    kv)   rendered=$(_log_render_kv "$lvl" "$@") ;;
    json) rendered=$(_log_render_kv "$lvl" "$@") ;;   # keep simple; full JSON omitted in this lib
    text|*) rendered=$(_log_render_text "$lvl" "$@") ;;
  esac
  [[ "$LOG_TO_STDERR" == 1 ]] && printf '%s\n' "$rendered" >&2
  if [[ -n "$LOG_FILE" ]]; then
    printf '%s\n' "$rendered" >> "$LOG_FILE"
  fi
  if [[ "$LOG_TO_SYSLOG" == 1 ]]; then
    logger -t "$LOG_SCRIPT_NAME" -p "user.$(_log_pri "$lvl")" -- "$rendered" 2>/dev/null || true
  fi
}

_log() {
  local lvl=$1; shift
  local lvl_n=${_LOG_LEVELS[${lvl^^}]:-20}
  local thr_n
  thr_n=$(_log_threshold_n)
  (( lvl_n >= thr_n )) || return 0
  _log_emit "$lvl" "$@"
}

debug() { _log DEBUG "$@"; }
info()  { _log INFO  "$@"; }
warn()  { _log WARN  "$@"; }
error() { _log ERROR "$@"; }
fatal() { _log FATAL "$@"; exit 1; }

Usage:

#!/usr/bin/env bash
set -Eeuo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/lib/log.sh"

info "starting deploy" env=prod tag=v1.2.3
debug "kubeconfig at $KUBECONFIG"
warn "no rollback plan defined; proceeding anyway"
error "rollout failed"
fatal "out of disk; aborting"

Run:

./script                                          # default INFO+ to stderr
LOG_LEVEL=DEBUG ./script                          # everything visible
LOG_FORMAT=kv ./script                            # logfmt-style
LOG_FILE=/var/log/myscript.log ./script           # also to file
LOG_TO_SYSLOG=1 ./script                          # also to syslog
LOG_LEVEL=ERROR ./script                          # only errors and fatals

This is competitive with what serious shell projects ship.

Heads-up: this library needs bash 4+. It uses associative arrays (declare -A), ${LOG_LEVEL^^} (uppercase), and ${lvl,,} (lowercase) — none of which exist in the bash 3.2 that ships on macOS. On a modern Linux server that’s a non-issue. If you must support macOS stock bash or a minimal ash/dash, swap the associative array for the case form from §2 and the ^^/,, for tr. The portability matrix below lists every such feature.


9. Patterns for production usage

Capture command output and log it

run() {
  local lvl=${1:-INFO}; shift
  info "running: $*"
  local out
  if ! out=$("$@" 2>&1); then
    error "command failed (exit $?): $*"
    error "output: $out"
    return 1
  fi
  debug "output: $out"
}

# Use:
run INFO kubectl apply -f deploy.yaml
run INFO docker pull ghcr.io/myorg/api:v1.2.3

This wraps every command with logging — entry, exit code, full output.

Time how long a step took

time_step() {
  local label=$1; shift
  local start
  start=$(date +%s)
  "$@"
  local rc=$?
  local end
  end=$(date +%s)
  local elapsed=$((end - start))
  if [[ $rc -eq 0 ]]; then
    info "step done: $label" duration_s=$elapsed
  else
    error "step failed: $label" duration_s=$elapsed exit_code=$rc
  fi
  return $rc
}

# Use:
time_step "image pull" docker pull ghcr.io/myorg/api:v1.2.3
time_step "kubectl apply" kubectl apply -f deploy.yaml

Correlation IDs

When a script kicks off other commands, attach a correlation ID:

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

info "starting deploy" cid=$CORRELATION_ID env=$ENV tag=$TAG

Now grep across logs by cid=... and you have the full trace of one script invocation across multiple processes.

Mute external command stderr but log on failure

run_quiet() {
  local out
  if ! out=$("$@" 2>&1); then
    error "command failed: $*" exit_code=$?
    error "$out"
    return 1
  fi
}

Don’t pollute stderr with chatty commands; only log if they actually fail.


10. Common pitfalls

Buffered stderr in CI

If your CI runs your script with stdout/stderr captured to a log, you may see no output until the script exits — because libc buffers stderr for non-tty output. Fix:

exec 2> >(stdbuf -oL cat >&2)             # line-buffered stderr

Or simply stdbuf -oL ./script from the CI runner.

Logging from background subshells

When you cmd & for parallelism, those subshells inherit LOG_FILE. If two write to the same file simultaneously, lines may interleave at the byte level. Either:

Locale-sensitive date

date formats may differ by locale. For consistency in logs, always set:

TZ=UTC LC_ALL=C date '+%Y-%m-%dT%H:%M:%SZ'

Or stamp via printf '%(...)T' (bash 4.2+):

ts() { printf '%(%Y-%m-%dT%H:%M:%SZ)T\n' -1 ; }   # -1 = current time, no fork

The bash builtin avoids the date fork entirely — much faster in tight loops.

logger not present

On minimal containers (scratch, busybox-without-syslogtools), logger isn’t there. Detect:

if command -v logger >/dev/null 2>&1; then
  logger -t "$SCRIPT" -- "$line"
fi

Fall back to file or stderr if absent.

Forgetting to flush

echo "important message" > "$LOG_FILE"   # bash always flushes after a builtin

Bash’s echo/printf flush after each invocation. But if you redirect a long-running tail -f style command, watch for buffering. stdbuf -oL and unbuffer are your friends.

Logging passwords / secrets

A FATAL bug: logging kubectl output that contains tokens, or curl -v output that contains Authorization headers. Always sanitise:

sanitize() {
  sed -E 's/(Authorization|password|token)[:=][^[:space:]]*/\1=REDACTED/g'
}

run kubectl get secrets -o yaml | sanitize | tee >(info)

Or capture and grep:

out=$(curl -v "$URL" 2>&1)
out=$(sed -E 's/(Bearer )[A-Za-z0-9._-]+/\1REDACTED/g' <<<"$out")
debug "$out"

The single highest-impact thing you can do for log hygiene.


Going deeper

The sections above give you a working, production-grade logger. This section is for when you own the platform: what journald actually stores, why it silently drops your logs, when concurrent appends really interleave, what a log line costs, the exact portability cliff-edges, and the security model. None of it is required to use logging — all of it is required to operate it at scale.

journald doesn’t store text — it stores fields

A journald entry is not a line of text with a timestamp glued on. It’s a set of key–value fields, and MESSAGE is just one of them. journalctl -o verbose (or -o json) reveals the rest:

Field Meaning
MESSAGE the human-readable text you logged
PRIORITY syslog severity 0–7 (see next table)
SYSLOG_IDENTIFIER the -t tag (myscript)
_PID / _COMM PID and command name of the sender (trusted fields)
_SYSTEMD_UNIT the unit, if the sender ran under systemd
_BOOT_ID which boot the entry belongs to

Fields prefixed with _ are trusted — journald sets them itself from the sender’s credentials, so a process can’t forge its own PID or unit. That’s why journalctl _SYSTEMD_UNIT=myscript.service is trustworthy in a way a grep of a text file never is.

You can attach your own fields natively. systemd-cat only sets MESSAGE, but systemd’s logger --journald (or logger with newline-separated KEY=value pairs on some versions) sends structured fields:

printf 'MESSAGE=deploy finished\nTAG=v1.2.3\nPRIORITY=6\n' | logger --journald
# then query on your custom field (representative — Linux/systemd):
journalctl TAG=v1.2.3 -o cat

This is journald’s answer to structured logging — no JSON string to parse later, the fields are first-class and indexed. On a systemd host, prefer it over hand-rolled logfmt when the logs stay on the box.

The syslog severity/facility model, in full

logger -p facility.severity and journald’s PRIORITY both use the RFC 5424 numbers. Worth memorising the severities because journalctl -p N and every syslog filter use them:

Severity Number Your level maps to
emerg 0 (reserve for real “system unusable”)
alert 1
crit 2 FATAL
err 3 ERROR
warning 4 WARN
notice 5
info 6 INFO
debug 7 DEBUG

Facilities route messages to different sinks. Codes 0–15 are reserved (kern, mail, cron, auth, …); local0local7 (16–23) are yours to use for application logs. Tag your scripts with a localN facility and the syslog daemon (rsyslog/syslog-ng) or a journald forward can file them into their own stream: logger -p local3.info.

journald will silently drop your logs (rate limiting)

The surprise that bites everyone eventually: journald rate-limits per service. If a script floods the journal it starts dropping messages and emits a single Suppressed N messages from … marker instead. The knobs live in journald.conf (and can be overridden per unit):

For a legitimately chatty job, set RateLimitBurst=0 (unlimited) on its unit, or log high-volume data to a file instead of the journal. Two more journald realities that catch people:

When do concurrent appends actually interleave?

§10 warns that parallel writers to one LOG_FILE “may interleave.” The precise rule is worth knowing because it decides whether you need flock at all. A file opened with >> gets the kernel’s O_APPEND flag, which makes “seek to end, then write” a single atomic step — so two writers never overwrite each other’s offset. And on Linux a single write() of up to PIPE_BUF (4096 bytes) lands atomically. Consequences:

So: don’t reach for flock on every log write — reach for it when lines are large or assembled incrementally. (One nuance on the §10 “buffered stderr” note: C’s stdio leaves stderr unbuffered by default; the delayed output you see in CI is usually the program’s stdout block-buffering, or a language runtime’s own buffer — stdbuf -oL/-eL addresses both.)

What a log line costs (performance)

A naive info() that calls $(date) forks a whole process per line. Add $(...) command substitution and an optional logger and you’re at three forks a line; 100 000 lines is 300 000 forks — seconds of pure fork/exec overhead in a tight loop.

Technique Forks per line Notes
ts=$(date -u +%FT%TZ) 1 (per call) a process every line
printf '%(%FT%TZ)T' -1 0 bash 4.2+ builtin, no fork
logger … per line +1 fine occasionally, costly in a hot loop
batch: accumulate, one >> at the end ~0 for bulk emit, write once

Rules of thumb for hot paths: stamp time with the printf '%(...)T' builtin, avoid $(...) where a builtin will do, and if you’re emitting thousands of lines, buffer them in a variable and flush with a single redirect rather than a logger call per line.

Portability matrix (the cliff-edges)

The library in §8 targets Linux + bash 4/5 + GNU coreutils. Here’s exactly what breaks elsewhere — the build host for this course is macOS (bash 3.2 + BSD userland), so these are tested caveats, not guesses:

Feature Linux + systemd (bash 4/5) macOS (bash 3.2 / BSD) Alpine / busybox (ash)
logger(1) yes → journald yes → unified log (log show) yes (busybox, minimal flags)
journalctl / systemd-cat yes no no (unless systemd present)
logrotate usually installed no (brew install) via package
printf '%(...)T' (no-fork ts) bash 4.2+ no (bash 3.2) no (ash) — use date
${v^^} / ${v,,} case ops bash 4+ no — use tr no — use tr
declare -A (assoc array) bash 4+ no — use case no — use case
date -u '+…%N' nanoseconds GNU: yes BSD: no %N busybox: limited

The engineering answer is capability detection, not assumption: command -v journalctl >/dev/null && … || fall back to file/stderr, gate printf '%(...)T' behind a ((BASH_VERSINFO[0] >= 4)) check, and keep a tr/case path for the level table. A logger that degrades to “stderr + file” everywhere and adds journald where it exists is portable; one that hard-requires systemd is not.

Security: log injection and access control

Two threats beginners never think about:

Mapping to the OpenTelemetry log model (future-proofing your fields)

If your logs will eventually flow into an OTel pipeline (Vector, Fluent Bit, or the OpenTelemetry Collector tailing your file or journal), name your fields with the OTel logs data model in mind. OTel uses a numeric SeverityNumber that your five levels slot straight into:

Your level OTel severity text OTel SeverityNumber
DEBUG DEBUG 5–8
INFO INFO 9–12
WARN WARN 13–16
ERROR ERROR 17–20
FATAL FATAL 21–24

Keep the message in a msg/body field and everything else as attributes (env, tag, cid, duration_s), and a collector can forward your shell logs into the same backend as your Go and Python services with no reshaping. Emitting logfmt or JSON from §5 is already 90% of the way there — the field names are the part worth standardising early.


11. Twelve idioms for daily use

# 1. Minimum logger
info() { printf '[%s] INFO: %s\n' "${0##*/}" "$*" >&2; }

# 2. Source canonical lib/log.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib/log.sh"

# 3. ISO 8601 UTC timestamp
ts() { date -u '+%Y-%m-%dT%H:%M:%SZ'; }

# 4. Bash-builtin timestamp (no fork)
ts() { printf '%(%Y-%m-%dT%H:%M:%SZ)T\n' -1; }

# 5. Level threshold at runtime
LOG_LEVEL=DEBUG ./script

# 6. Log to syslog/journald
LOG_TO_SYSLOG=1 ./script
journalctl -t myscript -f

# 7. Logger one-liner
logger -t myscript "hello world"

# 8. JSON output via jq (one-line)
jq -cn --arg ts "$(ts)" --arg msg "$msg" '{ts: $ts, msg: $msg}'

# 9. logfmt key=value pair
printf 'level=%s msg=%q env=%s tag=%s\n' "$lvl" "$msg" "$ENV" "$TAG"

# 10. Sanitize secrets before logging
sed -E 's/(token|password|Authorization)[:=][^[:space:]]+/\1=REDACTED/g'

# 11. Logrotate config (one-liner test)
logrotate -d /etc/logrotate.d/myscript    # dry-run

# 12. Atomic file rotation
mv "$LOG_FILE" "$LOG_FILE.$(date -u +%Y%m%dT%H%M%SZ)" && : > "$LOG_FILE"

12. What you must internalise before lesson 16


Practice challenges

Work these in order — they escalate from “print at the right level” to “route to the journal with a correct priority and redact a secret.” Try each before opening the solution. The canonical target is Linux + bash 4/5 + GNU coreutils; where the build host (bash 3.2 / BSD) differs, the solution says so. Every snippet below was checked on that host except where labelled representative (Linux).

Challenge 1 — A levelled logger with a threshold (beginner)

Write a log LEVEL message… function that prints only messages at or above LOG_LEVEL (default INFO). Prove that DEBUG is suppressed by default but appears when you set LOG_LEVEL=DEBUG. Make it work on bash 3.2 (no associative array).

<details> <summary>Solution</summary>

lvl_n() { case "$1" in DEBUG) echo 10;; INFO) echo 20;; WARN) echo 30;; ERROR) echo 40;; FATAL) echo 50;; *) echo 20;; esac; }
LOG_LEVEL="${LOG_LEVEL:-INFO}"
log() {
  local lvl=$1; shift
  [ "$(lvl_n "$lvl")" -ge "$(lvl_n "$LOG_LEVEL")" ] || return 0
  printf '[%s] %-5s %s\n' "${0##*/}" "$lvl" "$*" >&2
}

log DEBUG "parse phase"          # (silent at default INFO)
log INFO  "parsed 100 records"   # [script] INFO  parsed 100 records
LOG_LEVEL=DEBUG log DEBUG "now visible"

Why: the numeric map plus a >= comparison is the whole idea of a level threshold; the case form keeps it portable to bash 3.2, where declare -A doesn’t exist. </details>

Challenge 2 — Prove logs go to stderr, not stdout (beginner)

Using your log from Challenge 1, show that logging does not pollute a command substitution: out=$(some_function) must capture only the real data, while the log line still appears on the terminal.

<details> <summary>Solution</summary>

produce() { log INFO "computing…"; printf 'RESULT=42'; }   # log→stderr, data→stdout
out=$(produce)                # the [INFO] line prints to the terminal (stderr)
printf 'captured: [%s]\n' "$out"   # captured: [RESULT=42]

Why: because log writes to fd 2, only fd 1 (RESULT=42) is captured by $(...). If log had used stdout, $out would be [script] INFO computing…RESULT=42 — corrupted. This is the reason logs go to stderr. </details>

Challenge 3 — Timestamp without forking date (intermediate)

Add an ISO-8601 UTC timestamp to each line. First with date -u, then replace it with the fork-free bash builtin. State exactly which bash versions support the builtin and what to do below that.

<details> <summary>Solution</summary>

ts_fork()    { date -u '+%Y-%m-%dT%H:%M:%SZ'; }          # forks a process each call
ts_builtin() { printf '%(%Y-%m-%dT%H:%M:%SZ)T\n' -1; }   # no fork — bash 4.2+

# Portable pick:
if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 2))); then
  ts() { printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1; }
else
  ts() { date -u '+%Y-%m-%dT%H:%M:%SZ'; }   # bash 3.2 (macOS) fallback
fi

Why: printf '%(...)T' is a bash 4.2+ builtin that reads the clock without a fork/exec, which matters in tight loops. On the bash 3.2 build host it isn’t available, so the version guard falls back to date -u. </details>

Challenge 4 — Emit logfmt with correct quoting (intermediate)

Emit one event as key=value (logfmt) with fields level, msg, and path, where path contains a space (/var with space). The value with a space must come out as one quoted field, not two. Then extract just the level field back out.

<details> <summary>Solution</summary>

log_kv() {
  local lvl=$1; shift; local kvs=""
  while [ $# -gt 0 ]; do
    local k=${1%%=*} v=${1#*=}
    case "$v" in *[\ \"]*) v=\"${v//\"/\\\"}\";; esac   # quote if space/quote
    kvs="$kvs $k=$v"; shift
  done
  printf 'level=%s%s\n' "$(printf %s "$lvl" | tr A-Z a-z)" "$kvs"
}

line=$(log_kv INFO msg="starting deploy" path="/var with space")
printf '%s\n' "$line"
# level=info msg="starting deploy" path="/var with space"

printf '%s\n' "$line" | sed -E 's/.*level=([^ ]+).*/\1/'   # info

Why: logfmt splits on spaces, so an unquoted /var with space would parse as path=/var plus a stray space. Quoting the value keeps it one field — the rule every logfmt/JSON emitter must enforce. </details>

Challenge 5 — Route to the journal with the right priority (advanced)

Send an ERROR to syslog/journald so that journalctl -p err finds it and journalctl -p warning (errors-and-worse only from err) still shows it, using logger. Map your level → syslog priority. Note precisely what changes on macOS.

<details> <summary>Solution</summary>

syslog_pri() { case "$1" in DEBUG) echo debug;; INFO) echo info;; WARN) echo warning;; ERROR) echo err;; FATAL) echo crit;; *) echo info;; esac; }

emit_journal() {   # emit_journal ERROR "rollout failed"
  local lvl=$1; shift
  command -v logger >/dev/null || { printf '[%s] %s\n' "$lvl" "$*" >&2; return; }
  logger -t myscript -p "user.$(syslog_pri "$lvl")" -- "$*"
}

emit_journal ERROR "rollout failed"
# Retrieve — representative (Linux/systemd):
#   journalctl -t myscript -p err -o cat        →  rollout failed

Why: mapping ERROR→err (severity 3) lets journalctl -p err filter correctly. On macOS, logger accepts the line but sends it to Apple’s unified log — there is no journalctl; read it with log show --predicate 'eventMessage CONTAINS "rollout failed"' --last 5m. The command -v logger guard degrades to stderr on a scratch/busybox image that lacks logger. </details>

Challenge 6 — Redact a secret before it hits any sink (advanced)

You must log the output of a curl -v that contains Authorization: Bearer <token> and a password=… query param. Write a redact filter that replaces both with REDACTED, and prove it on a sample line. Then explain why redaction is still not enough on its own.

<details> <summary>Solution</summary>

redact() {
  sed -E 's/(Bearer )[A-Za-z0-9._-]+/\1REDACTED/g; s/(password|token)=[^[:space:]&]+/\1=REDACTED/g'
}

echo 'Authorization: Bearer abc.SECRET.tok  password=hunter2 token=deadbeef' | redact
# Authorization: Bearer REDACTED  password=REDACTED token=REDACTED

Why: the two sed substitutions catch the header-style Bearer <jwt> and the key=value secrets. But redaction is the last line of defense — a pattern you forget (a new header, base64 in a body) leaks. The real fix is to never capture the secret: fetch it into a variable you never log, and pass --data @file/-H @headerfile so it never appears in curl -v output at all. </details>


Common beginner mistakes

These are misconceptions, not typos — each is a wrong mental model that produces logging that looks fine and fails you in production.


Glossary


What’s next

Lesson 16: Concurrency — Backgrounding, GNU parallel, xargs -P, FIFOs & flock. We’ve used & and wait casually. Now we cover real concurrency: parallel job pools with xargs -P, GNU parallel for declarative parallelism, FIFOs (named pipes) for inter-process communication, and flock for cross-script mutual exclusion. After L16 your scripts will use all your cores — without race conditions.

See you there.

shellbashloggingsyslogjournaldstructured-loggingobservabilitylog-levelslogrotateproduction
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments