Building Structured JSON Logging in Bash Scripts
AI generated
$_
#!/
Bash · Logging · jq · Observability
Structured JSON Logging in Bash
Emit log lines as evaluable objects instead of free text

Free-text logs are pleasant for humans reading a terminal, but a poor contract for automated evaluation: any wording change breaks existing parsers. Structured JSON logging writes every log line as a clearly defined object with fixed fields instead, one that forwards losslessly to ELK, Loki, or any other aggregation system, with jq as the right tool for building those objects correctly and safely.

17 min read jq · log levels · context fields Bash 4.x · 5.x · ELK · Loki

1. Why free-text logs hit their limits for automation

A classic Bash log line like echo "$(date) [INFO] Deployment started" reads well for a human watching a terminal live, but it is a brittle contract for any automated evaluation. The moment wording, field order, or the date format shifts even slightly, regular expressions that used to parse those lines reliably tend to break unnoticed and silently, until someone notices a dashboard has shown no new data for days.

Structured JSON logging solves that problem by making every log line a complete, self-contained JSON object with fixed field names like timestamp, level, and message. A log aggregator no longer has to parse brittle text but reads structured data directly, which makes queries, filters, and alerting on individual fields far more reliable than any regex-based evaluation of free text.

2. Using jq to safely build log objects

The obvious but dangerous approach would be assembling a JSON object through string interpolation, for example echo "{\"message\": \"$msg\"}". If $msg contains a quote or a backslash, that instantly produces invalid JSON, which the aggregator either discards or misinterprets. jq solves that problem because it correctly escapes values passed via the --arg option into an object, regardless of the variable's actual content.

The central building block is a logging function that takes all fields as jq arguments and produces a single-line, compact JSON object from them. The -c option for compact output without line breaks matters, because most log aggregators interpret one log line as exactly one event, and a multi-line JSON object would otherwise arrive as several separate, incomplete lines.


#!/usr/bin/env bash
set -euo pipefail

log_json() {
  local level="$1" message="$2"
  jq -nc \
    --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
    --arg level "$level" \
    --arg msg "$message" \
    --arg service "deploy-runner" \
    '{timestamp: $ts, level: $level, message: $msg, service: $service}'
}

log_json "info" "Deployment started"
log_json "error" "Health check on port 8080 failed"

3. Defining standard fields and log levels consistently

For different scripts across an organization to be searchable together in the same log aggregator, all scripts need the same basic field structure. A proven set is timestamp in ISO 8601 format with a UTC timezone, level with the usual grades debug, info, warn, and error, plus message as the human-readable core text and service as the name of the script or service producing the line.

The log level should consistently be stored as a string rather than a number, because string levels are directly filterable in most aggregation systems without an extra mapping table and stay readable in dashboards. An additional level_num field with a numeric value is optionally useful if alerting rules need to work off thresholds, for example 'alert on anything at level 40 or above'.

4. Enriching context fields: request ID, host, and script name

A single log event only becomes truly useful once it can be tied to other events from the same operation. Every log line should therefore carry a context field like request_id or run_id that stays constant for the entire lifetime of a script invocation and, ideally, gets propagated to any called subprocesses too, so a search for that ID retrieves all related lines across multiple scripts.

A host field with the executing machine's hostname also pays off, especially in environments with several parallel workers or containers, because otherwise errors on a single broken host are hard to distinguish from a system-wide problem. These context fields should be defined centrally in a shared logging library that all scripts include, rather than being named freshly and potentially inconsistently in each script.


#!/usr/bin/env bash
set -euo pipefail

readonly RUN_ID="${RUN_ID:-$(uuidgen)}"
export RUN_ID  # propagate to any child process this script calls

log_json() {
  local level="$1" message="$2"
  jq -nc \
    --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
    --arg level "$level" \
    --arg msg "$message" \
    --arg service "deploy-runner" \
    --arg run_id "$RUN_ID" \
    --arg host "$(hostname)" \
    '{timestamp: $ts, level: $level, message: $msg, service: $service, run_id: $run_id, host: $host}'
}

log_json "info" "Deployment started"
./bin/run-migrations.sh   # inherits RUN_ID via environment

5. Integrating with ELK and Loki: why structured logs simplify evaluation

Log aggregation systems like the ELK stack (Elasticsearch, Logstash, Kibana) or Grafana Loki expect structured input to index fields without brittle Grok or regex patterns. If a Bash script already writes valid, single-line JSON to stdout, a log shipper like Filebeat or Promtail can ingest those lines directly without custom parsing rules and automatically index the fields as searchable attributes.

The practical effect shows up in Kibana or Grafana: instead of a full-text search over unstructured text, queries like 'show all lines with level=error and service=deploy-runner from the last hour' can be formulated directly on the fields, which runs both faster and more precisely than a text search that happens to also match similar words in unrelated contexts.

6. Performance cost: jq process startup at high log volume

The big downside of the naive approach is that every call to log_json spawns a brand new jq process, with measurable overhead for process creation and interpreter startup. In a script that logs a dozen events occasionally, that does not matter. In a tight loop with thousands of iterations that logs on every pass, this overhead can noticeably dominate the script's runtime and slow down the actual program purpose.

For high-frequency logging inside loops, a lighter approach with plain Bash string escaping and no external process call is often the better choice, as long as the logged values are controlled and known, for example plain numbers or predefined strings without special characters. For anything containing user input or uncontrolled external data, jq remains the safer choice despite the overhead, because correct escaping matters more than the last bit of speed.


#!/usr/bin/env bash
set -euo pipefail

# Lightweight logging for tight loops with known-safe values (no jq call per line)
log_json_fast() {
  local level="$1" message="$2" counter="$3"
  printf '{"timestamp":"%s","level":"%s","message":"%s","counter":%d}\n' \
    "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$level" "$message" "$counter"
}

for i in $(seq 1 10000); do
  log_json_fast "debug" "processing_item" "$i"
done

7. Structuring error output: JSON on stderr, separate from stdout

When a script produces both structured logs and actual payload data, for example a tool that writes JSON results to stdout that another process consumes, log lines must be written to stderr instead. If log events accidentally land in the same stream as the actual result data, the consuming process can no longer parse the combined JSON cleanly, because several independent JSON objects follow each other without a delimiter.

The logging function should therefore consistently redirect with >&2 to the standard error channel, while real result data stays on stdout. This separation lets a script be used in a pipeline, for example ./tool.sh | jq '.result', without log noise polluting the pipeline output, while the logs remain fully available for troubleshooting.

8. Pitfalls: broken JSON, special characters, and nested-value mistakes

The most common mistake when starting with JSON logging is inserting a field via string interpolation instead of jq --arg because it seems 'safe enough', for example a file path. The moment a filename contains a quote or a backslash, the entire JSON object collapses, and the log aggregator discards the whole line as unparsable, which hides the original error instead of documenting it.

A second common pitfall is nested logging: trying to embed an already-JSON error object from a called tool directly as the message field produces doubly escaped JSON that is barely readable for humans. It is better to insert such nested structures via jq arguments of type --argjson instead of --arg, so they appear as a real, unescaped JSON object in the result instead of a string-inside-a-string.


#!/usr/bin/env bash
set -euo pipefail

# --argjson embeds an already-JSON value as real nested JSON, not as a string
error_detail='{"code": "E_TIMEOUT", "retries": 3}'

jq -nc \
  --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --arg level "error" \
  --arg msg "Upstream request failed" \
  --argjson detail "$error_detail" \
  '{timestamp: $ts, level: $level, message: $msg, detail: $detail}' >&2

9. JSON logging compared to free-text logs

The choice between structured JSON and classic free text depends mostly on who reads the logs: a human in a terminal during development benefits from compact, directly readable free text, while an automated aggregation system needs structured fields to filter and alert reliably. Many teams resolve that tension by producing JSON and re-formatting it into readable text locally during development with a tool like jq or similar formatters.

For new scripts in production deployment and automation pipelines, JSON logging almost always pays off, because the later cost of migrating from free text to structure is significantly higher than the upfront effort of setting up a logging function once cleanly with jq and reusing it consistently across every script.

Criterion Free-text log JSON log Recommendation
Terminal readability High, directly readable Low without a formatter Free text for interactive use
Machine evaluation Fragile, regex-based Reliable, field-based JSON for production systems
Performance per line Very low (echo/printf) Higher with jq, low with printf printf variant for loops
Aggregator integration Needs Grok patterns Directly indexable JSON for ELK/Loki pipelines

Mironsoft

Shell automation, DevOps tooling and deployment infrastructure

Shell scripts that hold up in production?

We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.

Code Review

ShellCheck analysis and manual review for critical Bash pattern violations.

Refactoring

Retrofitting error handling, logging and safe file operations.

CI Integration

Wiring ShellCheck and BATS into pipelines and building regression tests.

10. Summary

Structured JSON Logging in Bash: The Essentials at a Glance

Core idea

Every log line is a complete, single-line JSON object with fixed fields instead of free text.

Safe construction

jq -nc with --arg escapes values correctly and prevents broken JSON from special characters.

Context fields

run_id, host, and service tie related log lines together across multiple scripts.

Performance

For very high log volumes inside loops, use printf instead of jq as long as the values are known and safe.

11. FAQ: Structured JSON Logging in Bash: The Essentials at a Glance

1Why should I replace free-text logs with JSON?
Because free-text logs break existing regex parsers on every formatting change. Structured fields, by contrast, can be filtered and indexed reliably without the aggregator depending on the exact wording.
2Why use jq instead of string interpolation for JSON log lines?
jq automatically escapes values correctly via --arg, even when they contain quotes or backslashes. String interpolation produces invalid JSON immediately when such characters appear.
3Which fields belong in every log line?
At minimum timestamp in ISO 8601 format, level as a string, message as the core text, and service as the name of the producing script. Context fields like run_id and host round it out.
4How do I tie log lines from multiple scripts together?
Through a shared context field like run_id, propagated to called subprocesses via an environment variable and kept constant for the entire runtime.
5Is jq too slow for every log line?
Not for occasional log lines. In tight loops with thousands of iterations, the process startup overhead can become noticeable, which is where printf with known, safe values pays off.
6Should logs be written to stdout or stderr?
To stderr, as soon as the script also emits real result data on stdout. Otherwise log noise and payload data mix in the same pipeline and become unparsable for downstream tools.
7How do I embed already-existing JSON as a nested field?
With jq --argjson instead of --arg. --arg would embed the JSON as an escaped string, while --argjson inserts it as a real, unescaped nested object.
8Does JSON logging work equally well with ELK and Loki?
Yes. Both systems can index valid JSON lines directly without writing custom Grok or regex parsers, as long as every line is a complete, single-line object.
9How do I keep JSON logging readable for developers in the terminal?
Through local formatters like jq that turn JSON lines back into indented, readable text, while production systems still receive the compact JSON.
10What is the most common mistake when starting with JSON logging?
Inserting a field via string interpolation instead of jq --arg because it seems safe enough. The moment the value contains a quote, the entire JSON object collapses.