Bash Scripting 26 ๐ง Logging & Auditing โ Writing formatted syslog and timestamped logs
A script that runs unattended has no terminal to print to. When it fails at 3 a.m., the only evidence of what happened is what it wrote to a log. Bash provides no built-in logging facility, but it does provide the logger command for writing to the system log and the date command for generating timestamps. Together with printf for formatting and a small wrapper function for consistency, these tools cover the two logging patterns that matter: structured entries in the system journal or syslog, and timestamped lines in a dedicated log file.
This chapter covers both patterns. It explains the logger command and its -p and -t options, the facility and priority selection that determines where entries land, the date format strings that produce ISO 8601 and other standard timestamps, and the wrapper function that combines timestamp, level, and message into a consistent line. It also covers the PIPESTATUS problem that occurs when piping a script’s output through logger, and the rsyslog rule that routes a custom facility to a dedicated file.
Key point: logger writes to the system log; echo or printf writes to a file. Use logger for events that belong in the system journal, and a timestamped wrapper for application-specific log files. Do not pipe a script’s output through logger without checking PIPESTATUS, or a failed script will appear to have succeeded.
Why these two logging paths exist
The system log integration problem. A script that runs as a system service, a cron job, or a systemd timer should write its messages to the same place as the rest of the system’s logs. logger does this by sending messages to the syslog socket or the systemd journal. The entries appear in /var/log/syslog, /var/log/messages, or journalctl, alongside kernel messages, service logs, and authentication events. This integration means an administrator does not have to know where each script writes its logs; everything is in one place .
The structured timestamp problem. A dedicated application log file needs each line to carry enough context to be useful after the fact: when it happened, how severe it was, and what the message is. logger relies on the syslog daemon or journald to add the timestamp, but a plain file written with echo has no timestamp at all. The date command generates the timestamp, and printf formats the line so that the timestamp, level, and message are consistently positioned .
The pipeline exit status problem. A common pattern is to pipe a script’s output through logger so that every line becomes a syslog entry. The problem is that the pipeline’s exit status is the exit status of logger, not the script. If the script fails but logger succeeds, the pipeline returns 0 and the failure is invisible to cron, systemd, or whatever invoked the script. The PIPESTATUS array preserves the exit status of each stage, and a wrapper script can log the real result .
a. The logger command
logger is the shell interface to the system log. It accepts a message, a priority, and a tag, and sends the result to the local syslog socket or journald.
# Basic log entry with default priority (user.notice)
logger "Script started"
# With a tag that identifies the script
logger -t myscript "Script started"
# With a specific facility and priority
logger -p local0.info -t myscript "Processing complete"
# With an error priority
logger -p local0.err -t myscript "Failed to open input file"
The -t option sets the tag, which appears in the log entry as the program name. The -p option sets the facility and priority as a facility.level pair. The default is user.notice . Common facilities for scripts are user (the default) and local0 through local7 (reserved for local use). Common priorities are info, notice, warning, err, and crit.
The -i option adds the PID of the logger process to each line. The -s option also writes the message to standard error, which is useful for debugging because the message appears both in the terminal and in the log .
# Log with PID and also print to stderr
logger -i -s -t myscript "Debug: entering main loop"
For remote logging, -n specifies a remote syslog server, and -P specifies the port. The --udp and --tcp options select the transport .
b. Timestamps with date and printf
When writing to a dedicated log file, the timestamp must be generated by the script. date produces the string, and printf places it at the start of the line.
# ISO 8601 timestamp
timestamp=$(date '+%Y-%m-%dT%H:%M:%S%z')
printf '%s %s\n' "$timestamp" "Processing started" >> /var/log/myscript.log
The date format string is the key. %Y-%m-%d produces 2026-11-05, %H:%M:%S produces 14:30:00, and %z produces the timezone offset like +0100. The combination is an ISO 8601 timestamp that sorts lexicographically and is unambiguous across time zones .
# Human-readable timestamp for console output
printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "Operation complete"
# Syslog-style timestamp (month day time)
printf '%s %s\n' "$(date '+%b %d %H:%M:%S')" "Legacy format"
For a log file that will be parsed by other tools, consistency matters more than the specific format. Pick one format, use it in every logging call, and document it. ISO 8601 is the safest choice because it is sortable and unambiguous.
c. The logging wrapper function
A function that combines the timestamp, a level, and the message produces a consistent log line without repeating the formatting code at every call site.
#!/bin/bash
set -euo pipefail
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp=$(date '+%Y-%m-%dT%H:%M:%S%z')
printf '%s [%s] %s\n' "$timestamp" "$level" "$message" >> /var/log/myscript.log
}
log INFO "Script started"
log WARN "Configuration file not found, using defaults"
log ERROR "Failed to connect to database"
The output is consistent across every call:
2026-11-05T14:30:00+0100 [INFO] Script started
2026-11-05T14:30:01+0100 [WARN] Configuration file not found, using defaults
2026-11-05T14:30:02+0100 [ERROR] Failed to connect to database
The function can be extended to write to both a file and logger. The logger call uses the level to select the priority:
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp=$(date '+%Y-%m-%dT%H:%M:%S%z')
printf '%s [%s] %s\n' "$timestamp" "$level" "$message" >> /var/log/myscript.log
case "$level" in
ERROR) logger -p local0.err -t myscript "$message" ;;
WARN) logger -p local0.warning -t myscript "$message" ;;
*) logger -p local0.info -t myscript "$message" ;;
esac
}
This dual-write pattern gives the script a local log file for detailed review and a syslog entry for centralized monitoring.
d. The PIPESTATUS problem
A common pattern for capturing a script’s output as syslog entries is to pipe it through logger:
# This looks correct but has a hidden problem
/home/appuser/myjob.sh 2>&1 | logger -t myjob
The pipeline’s exit status is the exit status of logger, not of myjob.sh. If myjob.sh fails, logger still succeeds, and the pipeline returns 0. Cron, systemd, or any caller that checks the exit status will believe the job succeeded .
The fix is to capture PIPESTATUS immediately after the pipeline. The first element is the script’s status; the second is logger‘s status.
#!/bin/bash
/home/appuser/myjob.sh 2>&1 | logger -t myjob
results=("${PIPESTATUS[@]}")
logger -t myjob "event=finish status=${results[0]} logger_status=${results[1]}"
exit "${results[0]}"
The wrapper script logs both the script’s real status and the logger’s status, then exits with the script’s status so that the caller sees the failure .
e. Routing a custom facility to a dedicated file
By default, logger -p local0.info sends the message to the general system log. To route it to a dedicated file, add a rule to rsyslog.
# /etc/rsyslog.d/myscript.conf
local0.* /var/log/myscript.log
After adding the rule, validate the configuration and restart rsyslog:
rsyslogd -N1
systemctl restart rsyslog
The local0.* selector matches all priorities from the local0 facility. The action writes them to /var/log/myscript.log instead of the default destination . The script continues to use logger -p local0.info -t myscript, and the messages land in the dedicated file.
The dedicated file needs a log rotation policy. Without one, it grows until the disk fills.
# /etc/logrotate.d/myscript
/var/log/myscript.log {
weekly
rotate 4
compress
missingok
notifempty
}
Complete Example Session
# ============================================
# PART 1: BASIC LOGGER USAGE
# ============================================
logger -t myscript "Script started"
logger -p local0.info -t myscript "Processing complete"
logger -p local0.err -t myscript "Failed to open input file"
# Entries appear in /var/log/syslog or journalctl.
# ============================================
# PART 2: LOGGER WITH PID AND STDERR
# ============================================
logger -i -s -t myscript "Debug: entering main loop"
# -i adds the logger PID.
# -s also writes to stderr for immediate visibility.
# ============================================
# PART 3: TIMESTAMP WITH DATE
# ============================================
timestamp=$(date '+%Y-%m-%dT%H:%M:%S%z')
printf '%s %s\n' "$timestamp" "Processing started"
# Output: 2026-11-05T14:30:00+0100 Processing started
# ============================================
# PART 4: THE LOGGING FUNCTION
# ============================================
log() {
local level="$1"; shift
local message="$*"
local timestamp
timestamp=$(date '+%Y-%m-%dT%H:%M:%S%z')
printf '%s [%s] %s\n' "$timestamp" "$level" "$message" >> /var/log/myscript.log
}
log INFO "Script started"
log ERROR "Database connection failed"
# ============================================
# PART 5: DUAL WRITE (FILE AND SYSLOG)
# ============================================
log() {
local level="$1"; shift
local message="$*"
local timestamp
timestamp=$(date '+%Y-%m-%dT%H:%M:%S%z')
printf '%s [%s] %s\n' "$timestamp" "$level" "$message" >> /var/log/myscript.log
case "$level" in
ERROR) logger -p local0.err -t myscript "$message" ;;
WARN) logger -p local0.warning -t myscript "$message" ;;
*) logger -p local0.info -t myscript "$message" ;;
esac
}
# ============================================
# PART 6: THE PIPESTATUS PROBLEM
# ============================================
# Wrong: pipeline returns logger's status
# /home/appuser/myjob.sh 2>&1 | logger -t myjob
# Correct: capture PIPESTATUS
/home/appuser/myjob.sh 2>&1 | logger -t myjob
results=("${PIPESTATUS[@]}")
logger -t myjob "event=finish status=${results[0]}"
exit "${results[0]}"
# ============================================
# PART 7: ROUTE CUSTOM FACILITY TO FILE
# ============================================
sudo tee /etc/rsyslog.d/myscript.conf <<'EOF'
local0.* /var/log/myscript.log
EOF
sudo rsyslogd -N1
sudo systemctl restart rsyslog
# ============================================
# PART 8: VERIFY THE ROUTING
# ============================================
logger -p local0.info -t myscript "test message"
sleep 1
tail -n 1 /var/log/myscript.log
# Expected: Nov 5 14:30:00 hostname myscript: test message
# ============================================
# PART 9: LOG ROTATION
# ============================================
sudo tee /etc/logrotate.d/myscript <<'EOF'
/var/log/myscript.log {
weekly
rotate 4
compress
missingok
notifempty
}
EOF
# ============================================
# PART 10: COMPLETE SCRIPT
# ============================================
#!/bin/bash
set -euo pipefail
log() {
local level="$1"; shift
local message="$*"
local timestamp
timestamp=$(date '+%Y-%m-%dT%H:%M:%S%z')
printf '%s [%s] %s\n' "$timestamp" "$level" "$message" >> /var/log/myscript.log
case "$level" in
ERROR) logger -p local0.err -t myscript "$message" ;;
WARN) logger -p local0.warning -t myscript "$message" ;;
*) logger -p local0.info -t myscript "$message" ;;
esac
}
log INFO "Script started"
# ... work ...
log INFO "Script finished"
The ten parts covered basic logger, PID and stderr options, timestamp generation, the logging function, dual write, PIPESTATUS, rsyslog routing, verification, log rotation, and the complete script.
Quick Reference
logger options
| Option | Purpose | Example |
|---|---|---|
-t tag | Set the program tag | -t myscript |
-p priority | Set facility and level | -p local0.info |
-i | Include logger PID | -i |
-s | Also write to stderr | -s |
-n server | Remote syslog server | -n logserver |
-P port | Remote port | -P 514 |
Common facilities and priorities
| Facility | Use case |
|---|---|
user | Default, general user messages |
local0โlocal7 | Custom script logging |
daemon | System daemons |
| Priority | Meaning |
|---|---|
info | Informational |
notice | Normal but significant |
warning | Warning |
err | Error |
crit | Critical |
date format strings
| Format | Output |
|---|---|
%Y-%m-%dT%H:%M:%S%z | 2026-11-05T14:30:00+0100 |
%Y-%m-%d %H:%M:%S | 2026-11-05 14:30:00 |
%b %d %H:%M:%S | Nov 05 14:30:00 |
PIPESTATUS indices
| Index | Value |
|---|---|
${PIPESTATUS[0]} | First command’s status (the script) |
${PIPESTATUS[1]} | Second command’s status (logger) |
Best Practices
โ Do This:
# Use a consistent timestamp format
timestamp=$(date '+%Y-%m-%dT%H:%M:%S%z') # โ
# Capture PIPESTATUS when piping through logger
results=("${PIPESTATUS[@]}") # โ
# Use local0 for script-specific logging
logger -p local0.info -t myscript "message" # โ
# Configure log rotation for dedicated files
/etc/logrotate.d/myscript # โ
โ Don’t Do This:
# Pipe a script through logger without checking status
myscript.sh 2>&1 | logger -t myscript # โ (hides failure)
# Use echo for timestamped logs without a format
echo "$(date) something happened" >> log # โ (inconsistent)
# Log to a dedicated file without rotation
local0.* /var/log/myscript.log # no logrotate # โ
# Use the same facility as system services
logger -p daemon.info "my script message" # โ (confusing)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Script failure hidden by logger | Pipeline returns logger’s status | Capture PIPESTATUS |
| Log file grows unbounded | No log rotation configured | Add /etc/logrotate.d/ entry |
| Messages not in expected file | No rsyslog rule for the facility | Add local0.* /var/log/... rule |
| Timestamps inconsistent | Different formats at different call sites | Use a single logging function |
logger message truncated | Line length limit in syslog | Keep messages under 1KB or split |
logger not found | Package not installed | Install util-linux or bsdutils |
Real-World Examples
1. Basic logger call
logger -t myscript "Script started"
2. Error priority
logger -p local0.err -t myscript "Database connection failed"
3. ISO timestamp
date '+%Y-%m-%dT%H:%M:%S%z'
4. Timestamped line
printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "message" >> log
5. Logging function
log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$1" "${*:2}"; }
6. Dual write
logger -p local0.info -t myscript "$message"
7. PIPESTATUS capture
myscript.sh 2>&1 | logger -t myscript
status=${PIPESTATUS[0]}
8. rsyslog route
local0.* /var/log/myscript.log
9. Log rotation
/var/log/myscript.log { weekly rotate 4 compress }
10. Verify routing
logger -p local0.info -t test "hello" && tail /var/log/myscript.log
Visual
Two logging paths
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SCRIPT โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ logger โ โ printf + date โ โ
โ โ โ โ โ โ
โ โผ โ โผ โ โ
โ syslog socket/journaldโ dedicated log file โ โ
โ โ โ โ โ โ
โ โผ โ โผ โ โ
โ /var/log/syslog โ /var/log/myscript.logโ โ
โ journalctl โ (needs rotation) โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
PIPESTATUS
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PIPELINE โ
โ โ
โ myscript.sh 2>&1 | logger -t myscript โ
โ โ โ โ
โ โผ โผ โ
โ PIPESTATUS[0] PIPESTATUS[1] โ
โ (script status) (logger status) โ
โ โ
โ Pipeline exit = PIPESTATUS[1] โ
โ If logger succeeds, pipeline returns 0 โ
โ even if script failed. โ
โ โ
โ Fix: exit "${PIPESTATUS[0]}" โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
rsyslog routing
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ logger -p local0.info -t myscript "message" โ
โ โ โ
โ โผ โ
โ rsyslog โ
โ โ โ
โ โโโ local0.* โโโบ /var/log/myscript.log โ
โ โ โ
โ โโโ *.info โโโบ /var/log/syslog (if no rule) โ
โ โ
โ The first matching rule wins. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Logging function flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ log INFO "message" โ
โ โ โ
โ โผ โ
โ timestamp=$(date ...) โ
โ โ โ
โ โผ โ
โ printf '%s [%s] %s\n' "$timestamp" "$level" "$message" โ
โ โ โ
โ โโโโบ /var/log/myscript.log โ
โ โ โ
โ โผ โ
โ logger -p local0.info -t myscript "$message" โ
โ โ โ
โ โโโโบ syslog/journald โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| System log command | logger |
| Tag option | -t |
| Priority option | -p facility.level |
| Default priority | user.notice |
| Timestamp command | date '+format' |
| ISO 8601 format | %Y-%m-%dT%H:%M:%S%z |
| Pipeline status | PIPESTATUS array |
| Custom facility routing | rsyslog rule in /etc/rsyslog.d/ |
| Log rotation | /etc/logrotate.d/ |
Key takeaways:
loggerwrites to the system log;printfwrites to a file. Useloggerfor events that should appear injournalctlor/var/log/syslog. Use a timestamped wrapper for application-specific log files that need a consistent format .- The timestamp format must be consistent. Pick one
dateformat string, use it in every logging call through a wrapper function, and document it. ISO 8601 is the safest choice because it sorts lexicographically and includes the timezone offset . - Piping through
loggerhides the script’s exit status. The pipeline returnslogger‘s status, not the script’s. Capture${PIPESTATUS[0]}and exit with it, or a failed script will appear to have succeeded . - A custom facility needs a rsyslog rule to land in a dedicated file.
local0.*in/etc/rsyslog.d/routes alllocal0messages to the specified file. Without the rule, they go to the general system log . - Dedicated log files need rotation. A file that grows without bound will eventually fill the disk. A
/etc/logrotate.d/entry keeps the file size bounded and compresses old entries. - A logging function prevents inconsistency. The wrapper combines timestamp, level, message, and optional
loggercall in one place. When the format changes, it changes in one function, not at every call site.
Remember: Logging in Bash is two separate concerns. The first is where the log goes: the system journal via logger, or a dedicated file via redirection. The second is what the line looks like: timestamp, level, and message in a consistent format. The logging wrapper function handles the second concern. The logger command and the rsyslog rule handle the first. The one mistake that causes the most trouble is piping a script through logger without checking PIPESTATUS, because it turns a failure into a success. Check the status, log the real result, and exit with the script’s status, not logger‘s.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!