What you will learn
Emit properly escaped, single-line JSON log entries from Bash so a log shipper (Fluent Bit, Vector, Filebeat) can parse them directly, instead of grepping free-text output.
Before you begin
Prefer jq when it's available on the host — it handles escaping correctly. This tutorial also shows a jq-free fallback for minimal containers where installing it isn't worth it.
1. The jq-based emitter (preferred)
#!/usr/bin/env bash
set -Eeuo pipefail
log_json() {
local level="$1" msg="$2"
shift 2
jq -nc \
--arg ts "$(date -u +%FT%TZ)" \
--arg level "$level" \
--arg msg "$msg" \
--arg host "$(hostname)" \
'{timestamp: $ts, level: $level, message: $msg, host: $host}'
}
log_json "info" "backup started"
log_json "error" "backup failed: connection timed out after 30s"
jq -nc --arg builds the object from null input with each value passed as a string argument — this guarantees correct escaping of quotes, backslashes, and unicode in $msg, which naive string concatenation gets wrong.
2. A dependency-free fallback
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\t'/\\t}"
printf '%s' "$s"
}
log_json_plain() {
local level="$1" msg="$2"
printf '{"timestamp":"%s","level":"%s","message":"%s","host":"%s"}\n' \
"$(date -u +%FT%TZ)" "$level" "$(json_escape "$msg")" "$(hostname)"
}
The escape order matters: backslashes must be escaped first, or a later substitution doubles them. Getting this wrong is the single most common source of malformed JSON logs from shell scripts.
3. Add structured fields beyond the fixed set
log_json_fields() {
local level="$1" msg="$2"; shift 2
local extra="{}"
while (( "$#" >= 2 )); do
extra="$(jq -c --arg k "$1" --arg v "$2" '. + {($k): $v}' <<< "$extra")"
shift 2
done
jq -c --arg ts "$(date -u +%FT%TZ)" --arg level "$level" --arg msg "$msg" \
--argjson extra "$extra" \
'{timestamp: $ts, level: $level, message: $msg} + $extra'
}
log_json_fields "error" "restore failed" job_id "42" duration_ms "1830"
Example output
{"timestamp":"2026-09-19T03:00:01Z","level":"info","message":"backup started","host":"db01"}
{"timestamp":"2026-09-19T03:00:31Z","level":"error","message":"restore failed","job_id":"42","duration_ms":"1830"}
Verify the result
Pipe the script's output through jq . — every line must parse. Deliberately log a message containing a literal quote and a newline and confirm the JSON stays valid.
Troubleshooting
If lines look truncated in the shipper, confirm nothing upstream is line-wrapping long messages — each jq -c call must emit exactly one line, and a stray echo elsewhere in the script can interleave plain text into the stream.
Next steps
Use this as the logging backend for auto-heal-service-watchdog and staged-pipeline-runner so every automation script in your fleet produces one consistent, ingestible format.