Web Analytics

Logging

Advanced ~25 min read

Good logging transforms debugging from guesswork into detective work. Scripts that silently do their job are greatβ€”until something goes wrong. This lesson teaches you to implement professional logging that helps you understand exactly what your script did, when, and why it failed!

Basic Logging Functions

Start with simple logging functions that add timestamps and identify message types.

Output
Click Run to execute your code

Essential Logging Functions

# Timestamp function
timestamp() {
    date '+%Y-%m-%d %H:%M:%S'
}

# Basic log function
log() {
    echo "[$(timestamp)] $*"
}

# Error logging (to stderr)
log_error() {
    echo "[$(timestamp)] ERROR: $*" >&2
}

# Warning logging
log_warn() {
    echo "[$(timestamp)] WARN: $*" >&2
}

# Info logging
log_info() {
    echo "[$(timestamp)] INFO: $*"
}

# Debug logging (controlled by variable)
log_debug() {
    [[ "${DEBUG:-false}" == "true" ]] && echo "[$(timestamp)] DEBUG: $*"
}
stderr vs stdout: Write errors and warnings to stderr (>&2) so they appear even when stdout is redirected. This keeps error messages visible: ./script.sh > output.log still shows errors.

Implementing Log Levels

Log levels let you control verbosity without changing code.

Output
Click Run to execute your code

Standard Log Levels

Level Value When to Use
DEBUG 0 Detailed diagnostic information
INFO 1 General progress information
WARN 2 Warning conditions, recoverable issues
ERROR 3 Error conditions, but script continues
FATAL 4 Critical errors, script will exit
Default Level: Set INFO as default. Use DEBUG only when troubleshooting. In production, WARN or ERROR reduces noise.

Logging to Files

For long-running scripts and services, log to files for later analysis.

# Setup log file
LOG_FILE="${LOG_FILE:-/var/log/myapp/app.log}"
LOG_DIR="$(dirname "$LOG_FILE")"

# Ensure log directory exists
setup_logging() {
    mkdir -p "$LOG_DIR" || {
        echo "Cannot create log directory: $LOG_DIR" >&2
        exit 1
    }

    # Test write access
    touch "$LOG_FILE" || {
        echo "Cannot write to log file: $LOG_FILE" >&2
        exit 1
    }
}

# Log to both file and stdout
log() {
    local message="[$(date '+%Y-%m-%d %H:%M:%S')] [$$] $*"
    echo "$message" | tee -a "$LOG_FILE"
}

# Log only to file (silent mode)
log_quiet() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$$] $*" >> "$LOG_FILE"
}

# Redirect all output to log file
exec_with_logging() {
    exec > >(tee -a "$LOG_FILE") 2>&1
}

# Log with rotation (simple)
rotate_log() {
    local max_size=${1:-10485760}  # 10MB default

    if [[ -f "$LOG_FILE" ]]; then
        local size=$(stat -f%z "$LOG_FILE" 2>/dev/null || stat -c%s "$LOG_FILE")
        if [[ $size -gt $max_size ]]; then
            mv "$LOG_FILE" "$LOG_FILE.$(date +%Y%m%d%H%M%S)"
            touch "$LOG_FILE"
            log "Log rotated"
        fi
    fi
}
Log File Permissions: Be careful with log file permissions. Sensitive data in logs should not be world-readable. Use chmod 640 for log files.

Structured Logging

For scripts that integrate with log aggregators, use structured formats like JSON.

# JSON logging
log_json() {
    local level="$1"
    local message="$2"
    shift 2

    # Build extra fields
    local extra=""
    while [[ $# -gt 0 ]]; do
        extra="$extra, \"$1\": \"$2\""
        shift 2
    done

    printf '{"timestamp":"%s","level":"%s","pid":%d,"message":"%s"%s}\\n' \\
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\
        "$level" \\
        "$$" \\
        "$message" \\
        "$extra"
}

# Usage
log_json "INFO" "User logged in" "user" "john" "ip" "192.168.1.1"
# Output: {"timestamp":"2024-01-15T10:30:00Z","level":"INFO","pid":1234,"message":"User logged in", "user": "john", "ip": "192.168.1.1"}

# Key-value logging (syslog style)
log_kv() {
    local level="$1"
    local message="$2"
    shift 2

    local kv=""
    while [[ $# -gt 0 ]]; do
        kv="$kv $1=\"$2\""
        shift 2
    done

    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $level: $message$kv"
}

# Usage
log_kv "INFO" "Request completed" "method" "GET" "path" "/api" "status" "200"

Debug Logging Techniques

Advanced techniques for troubleshooting complex scripts.

# Function entry/exit logging
log_function() {
    local func="${FUNCNAME[1]}"
    log_debug "Entering: $func($*)"
}

log_function_exit() {
    local func="${FUNCNAME[1]}"
    local code="${1:-0}"
    log_debug "Exiting: $func (code=$code)"
}

# Wrap function with logging
process_data() {
    log_function "$@"

    # Do work...
    local result="success"

    log_function_exit 0
    return 0
}

# Variable dump
dump_vars() {
    log_debug "=== Variable Dump ==="
    local var
    for var in "$@"; do
        log_debug "  $var=${!var:-}"
    done
}

# Execution trace for specific section
trace_on() {
    set -x
}

trace_off() {
    set +x
}

# Conditional trace
[[ "${TRACE:-false}" == "true" ]] && set -x

# Stack trace on error
print_stack() {
    local i
    echo "Stack trace:" >&2
    for ((i=1; i<${#FUNCNAME[@]}; i++)); do
        echo "  $i: ${FUNCNAME[i]}() at ${BASH_SOURCE[i]}:${BASH_LINENO[i-1]}" >&2
    done
}
trap 'print_stack' ERR

Common Mistakes

1. Logging sensitive data

# Wrong - password in logs!
log "Connecting with password: $DB_PASSWORD"

# Correct - mask sensitive data
log "Connecting with password: ****"
log "Connecting as user: $DB_USER"

2. No timestamps

# Wrong - no context
echo "Starting backup"

# Correct - when did it happen?
log "Starting backup"  # Includes timestamp

3. Debug code left in production

# Wrong - always prints debug
echo "DEBUG: var=$var"

# Correct - controlled by flag
log_debug "var=$var"  # Only shows when DEBUG=true

Exercise: Complete Logging System

Task: Build a reusable logging library!

Requirements:

  • Support DEBUG, INFO, WARN, ERROR levels
  • Include timestamps and PID
  • Output to both console and file
  • Configurable via environment variables
Show Solution
#!/usr/bin/env bash
# logging.sh - Reusable logging library

# Configuration (via environment)
LOG_LEVEL="${LOG_LEVEL:-INFO}"
LOG_FILE="${LOG_FILE:-}"
LOG_FORMAT="${LOG_FORMAT:-text}"  # text or json

# Level values
declare -A LOG_LEVELS=(
    [DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3
)

# Initialize
_log_init() {
    if [[ -n "$LOG_FILE" ]]; then
        mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null
    fi
}

# Core log function
_log() {
    local level="$1"
    local message="$2"

    # Check level threshold
    local level_val=${LOG_LEVELS[$level]:-1}
    local threshold=${LOG_LEVELS[$LOG_LEVEL]:-1}
    [[ $level_val -lt $threshold ]] && return 0

    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    local output

    if [[ "$LOG_FORMAT" == "json" ]]; then
        output=$(printf '{"ts":"%s","level":"%s","pid":%d,"msg":"%s"}' \\
            "$timestamp" "$level" "$$" "$message")
    else
        output="[$timestamp] [$$] $level: $message"
    fi

    # Output to console
    if [[ "$level" == "ERROR" || "$level" == "WARN" ]]; then
        echo "$output" >&2
    else
        echo "$output"
    fi

    # Output to file
    [[ -n "$LOG_FILE" ]] && echo "$output" >> "$LOG_FILE"
}

# Public functions
log_debug() { _log "DEBUG" "$*"; }
log_info()  { _log "INFO" "$*"; }
log_warn()  { _log "WARN" "$*"; }
log_error() { _log "ERROR" "$*"; }

# Initialize on source
_log_init

# Example usage when run directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    log_debug "Debug message"
    log_info "Info message"
    log_warn "Warning message"
    log_error "Error message"
fi

Summary

  • Timestamps: Always include timestamps in log messages
  • Log Levels: Use DEBUG, INFO, WARN, ERROR with configurable threshold
  • stderr: Write errors/warnings to stderr for visibility
  • File Logging: Log to files for long-running processes
  • Structured: Use JSON format for log aggregation systems
  • Security: Never log passwords, tokens, or sensitive data

What's Next?

Scripts often need external Configuration Files. Next, you'll learn to load, parse, and validate configuration from files and environment variables!