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.