Learn BASH Scripting - Logging, Colorizing Output & Terminal UX
Episode 24 of 27

Learn BASH Scripting - Logging, Colorizing Output & Terminal UX

Make your scripts professional: logging functions with timestamps and levels, colored output with ANSI escape codes that are safe for non-TTY, and spinners and progress bars. Includes a complete script practice and the color pitfalls that pollute logs.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

In episode 23 we covered interaction with system tools & external APIs — how scripts call HTTP APIs with curl, process JSON with jq, and send notifications — and in this episode we discuss something that may sound trivial but is exactly what separates an amateur script from a production-grade one: logging, colorizing output, and terminal UX.

Imagine inheriting a script from a colleague who's left the company. The script runs, but inside there are only echo "done" and echo "error" with no context whatsoever. When the script fails at midnight, there's no trace to follow: when it failed, at which step, and what happened before. It's like a plane without a black box — everyone knows there's a problem, nobody knows the cause. Logging is your script's black box.

On the other hand, imagine running a script whose output is a wall of gray text: nothing can be distinguished, errors drown among a hundred INFO lines. Compare that with a script that prints success messages in green, warnings in yellow, and bold errors in red. Your eyes go straight to the problem. Color isn't decoration — it's a navigation tool for whoever reads the output, including yourself six months from now.

And there's one last element often forgotten: the experience of waiting. A script that sits silent for two minutes after you press Enter makes people wonder whether it's still alive. Spinners and progress bars answer that question. In this episode, we'll build all three: reliable logging functions, safe colors, and humane terminal UX.

Main Discussion

Logging: A Trace That Makes Scripts Accountable

A good script records what happened, when, and with what result. The three mandatory elements of a log line are timestamp, level, and message. The timestamp answers "when", the level answers "how important", and the message answers "what".

The common log levels:

LevelColorMeaning
INFOGreen/BlueNormal process running, general information
WARNYellowThere's an anomaly, but the script can continue
ERRORRedThere's a failure, needs attention
DEBUGGrayDetails for tracking down problems

Let's build a logging function shared by all your scripts. The function reads the level via a parameter, and prints the timestamp in sortable ISO-8601 format — important because log formats must be lexically sortable (remember the timestamp-with-spaces pitfall at the end of this episode):

Fungsi log_info, log_warn, log_error
log_info() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO]  $*"
}
 
log_warn() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN]  $*" >&2
}
 
log_error() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2
}

Note two important details:

  1. log_warn and log_error print to stderr (>&2), not stdout. This isn't a habit — it's Unix discipline: data goes through stdout, diagnostics through stderr. When the script's output is redirected to a file (script.sh > result.txt), error messages don't corrupt the data file. Info log messages stay on stdout.
  2. The timestamp is printed when the message is created, not when the script started — so the log order reflects the real order of events.

With these functions, your script changes from echo "gagal" into a traceable record: [2026-08-02 14:31:08] [ERROR] Backup gagal: disk penuh.

Writing Logs to File and Screen at the Same Time

A script running in cron (remember episode 22 of the Linux series) has no terminal to read its output. The solution: write the log to a file, while still displaying it to the screen when run manually. The magic command is tee -a:

Log ke file dan layar sekaligus
LOG_FILE="/var/log/myapp/deploy.log"
mkdir -p "$(dirname "$LOG_FILE")"
 
log_info() {
    local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [INFO]  $*"
    echo "$msg" | tee -a "$LOG_FILE"
}

tee -a receives stdout and copies it to two destinations at once: screen and file (-a = append, doesn't overwrite the old log). Now one function line yields two benefits: operators running the script see progress immediately, and the log file stores a permanent record for audit.

Important

Always use mkdir -p "$(dirname "$LOG_FILE")" before writing logs to a nested path. tee (and > redirection) will fail if the parent directory doesn't exist. One mkdir -p at the start of the script avoids a confusing error in the middle of execution — and make sure the script user has write permission on that directory.

ANSI Escape Codes: Colors That Guide the Eye

Colors in a Linux terminal are produced by ANSI escape codes — sequences of invisible characters that tell the terminal how to display the following text. The pattern is always \033[<code>m, and it ends with a reset \033[0m. The codes used most often:

CodeEffect
\033[0mReset all styles
\033[1mBold
\033[31mRed (error)
\033[32mGreen (success)
\033[33mYellow (warn)
\033[34mBlue (info)
\033[36mCyan

The simplest example:

Mencetak teks merah dan hijau
echo -e "\033[31mGagal: koneksi ditolak\033[0m"
echo -e "\033[32mSukses: deploy selesai\033[0m"

-e tells echo to interpret escape sequences. Styles can be combined with a semicolon — \033[1;31m is bold red. A fun little trick: put \033[31m before the text and \033[0m after it; forgetting the reset is the classic cause of an entire terminal turning red.

To avoid writing raw codes in every message, wrap them in color functions:

Fungsi warna sederhana
RED='\033[31m'; GREEN='\033[32m'; YELLOW='\033[33m'; BLUE='\033[34m'; RESET='\033[0m'
log_info()    { echo -e "${BLUE}[INFO]${RESET}  $*"; }
log_success() { echo -e "${GREEN}[OK]${RESET}   $*"; }
log_warn()    { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; }
log_error()   { echo -e "${RED}[ERROR]${RESET} $*" >&2; }

Now your script speaks three languages at once: color for the eyes, levels for the reader, and timestamps for the archive.

TTY Detection: Don't Color a World That Can't See Color

This is the most important part of this episode, and the most often ignored. Escape codes aren't text — they're instructions for a terminal. If the script's output is directed to a log file or a pipe (for example when run by cron), those escape codes get written as raw characters: [31m will fill your log files and break parsing.

The solution: only apply colors when stdout is truly an interactive terminal. The test is simple — [ -t 1 ] is true if file descriptor 1 (stdout) is connected to a terminal:

Deteksi TTY sebelum mewarnai output
if [ -t 1 ]; then
    RED='\033[31m'; GREEN='\033[32m'; YELLOW='\033[33m'; RESET='\033[0m'
else
    RED=''; GREEN=''; YELLOW=''; RESET=''
fi

With this pattern, the same script is automatically colored in a terminal and plain in logs/cron. Add a $TERM check to respect color-less environments:

Cek TTY dan TERM secara kombinasi
if [ -t 1 ] && [ "$TERM" != "dumb" ]; then
    color="yes"
fi

Caution

Never color output without a TTY check. A script printing \033[31m into a log file creates two problems at once: the log becomes hard to read/parse, and log-processing tools (like grep or monitoring) get confused because the lines are full of control characters. Color is only for humans at a terminal; machines and files get plain text.

UX: Spinners for Long Tasks

A silent script makes users anxious — especially when the task takes minutes. The classic solution is a spinner: a small rotating animation on a single line that says "still working". The technique: print successive frames with printf '\r' (carriage return), which returns the cursor to the start of the line without moving to a new line.

Spinner sederhana untuk tugas panjang
spinner() {
    local frames='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
    local pid=$1 i=0
    while kill -0 "$pid" 2>/dev/null; do
        printf "\r[%s] bekerja..." "${frames:i++%${#frames}:1}"
        sleep 0.1
    done
    printf "\r[✓] selesai    \n"
}
 
long_task &
spinner $!

Notice the flow: long_task & runs the task in the background and gives you its PID; the spinner rotates as long as that process is alive (kill -0 only checks process existence, doesn't kill it); when it's done, the line is replaced with a completion mark. Because the spinner uses \r, it doesn't leave a trail of a hundred animation lines — just one line that keeps updating.

Progress Bar with printf '\r'

Another, more informative variation: a progress bar with a percentage. For jobs with a known item count (for example processing 100 files), a line showing (42/100) 42% is far more reassuring than a silent screen:

Progress bar sederhana dalam satu baris
total=100
for i in $(seq 1 "$total"); do
    sleep 0.05
    printf "\rMemproses... %3d%%" "$((i * 100 / total))"
done
printf "\rSelesai.          \n"

printf "%3d%%" prints a right-aligned number at least 3 digits wide plus the percent sign — % in printf must be doubled (%%) so it isn't treated as a specifier. Once again, \r returns the cursor, so the screen doesn't fill with dozens of lines.

Tip

Limit animation to interactive terminals only. Like colors, spinners and \r are tools for the screen. If the script runs from cron and its output is captured to a file, \r only creates a pile of weird characters. Wrap spinner/progress in a [ -t 1 ] condition, just like colors — cron users just get a normal log line.

Practice: A Complete Script with Logging and Color

Time to combine everything into one cohesive script — a pattern you can copy into all your future scripts:

Skrip template: strict mode + logging + warna
#!/usr/bin/env bash
set -euo pipefail
 
LOG_FILE="${LOG_FILE:-/var/log/myapp/run.log}"
mkdir -p "$(dirname "$LOG_FILE")"
 
if [ -t 1 ] && [ "$TERM" != "dumb" ]; then
    RED='\033[31m'; GREEN='\033[32m'; YELLOW='\033[33m'; BLUE='\033[34m'; RESET='\033[0m'
else
    RED=''; GREEN=''; YELLOW=''; BLUE=''; RESET=''
fi
 
log() {
    local level=$1 color=$2; shift 2
    echo -e "${color}[$(date '+%Y-%m-%d %H:%M:%S')] [$level]${RESET} $*" | tee -a "$LOG_FILE"
}
info()    { log INFO    "$BLUE"   "$@"; }
success() { log SUCCESS "$GREEN"  "$@"; }
warn()    { log WARN    "$YELLOW" "$@" >&2; }
error()   { log ERROR   "$RED"    "$@" >&2; }
 
info "Skrip dimulai"
if command -v curl >/dev/null; then
    success "curl tersedia"
else
    error "curl tidak terpasang"
    exit 1
fi

Let's dissect what's happening:

  • The color variables are set empty in non-TTY, so the echo -e line stays safe — empty escapes print nothing.
  • log() receives level and color as arguments, then unifies the format. tee -a writes to both file and screen.
  • The error function redirects stderr (>&2) after the tee pipeline, so the error message still reaches the log file but goes out on stderr.

The result: in a terminal, this script is alive with color; in a log file and cron, it's plain and tidy. That's the standard to target for production scripts.

Common Logging & Color Pitfalls

1. Escape codes polluting log files. This is pitfall number one — already discussed at length: always test -t 1 before coloring, because cron and redirection don't display colors, they record the raw characters.

2. Timestamps with spaces breaking parsing. date '+%Y-%m-%d %H:%M:%S' is human-readable, but a log parser that splits columns by spaces will break. If your logs will be processed automatically, use a space-free format (+%Y-%m-%dT%H:%M:%S%z) or make sure the parser treats the timestamp as grouped. Pick a consistent format from the start — changing a log format in production is painful work.

3. Forgetting the color reset (\033[0m). One error message without a reset turns all output after it red — including your terminal prompt. Always close every style you open.

4. echo -e vs printf. The behavior of -e differs between shells (in dash, echo -e literally prints -e). printf is more portable and safer for complex formats — get into the habit of using printf for output that needs precise control.

5. tee -a inside a function called many times. The log file is reopened on every call — that's normal and efficient, but make sure tee isn't wrongly combined with stderr: echo ... | tee -a file sends stdout to tee, so the >&2 at the end only applies to stderr.

Conclusion

In this episode 24, you've turned a BASH script from "text that runs commands" into "a product with a user experience". We built leveled logging functions (INFO, WARN, ERROR) with timestamps, wrote logs to screen and file at once with tee -a, colored output with ANSI escape codes (\033[31m for red, \033[0m for reset), detected TTY with -t 1 so colors don't pollute log files, and added spinners and progress bars with printf '\r'. We assembled it all into a reusable template script, and closed with the pitfalls that most often wreck production logs.

The key takeaways:

  • Logging = timestamp + level + message, and stderr for diagnostics (not data).
  • Colors are only for interactive terminals — test with [ -t 1 ] and $TERM != dumb.
  • Always close escape codes with \033[0m, and avoid echo -e for portability.
  • tee -a gives a permanent record and immediate feedback at once.
  • Spinners and progress bars make scripts feel alive — but only on screen.

Now your scripts are tidy and pleasant to read, but how do we ensure a script is correct? In episode 25 we'll cover static analysis & automated testing: catching bugs before execution with ShellCheck, and building automated safeguards with the Bats-core testing framework. This is the last step before the closing episode that assembles everything. See you there!

Learn BASH Scripting - Logging, Colorizing Output & Terminal UX | Learn BASH Scripting