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.
Table of Contents
- 1. Why free-text logs hit their limits for automation
- 2. Using jq to safely build log objects
- 3. Defining standard fields and log levels consistently
- 4. Enriching context fields: request ID, host, and script name
- 5. Integrating with ELK and Loki: why structured logs simplify evaluation
- 6. Performance cost: jq process startup at high log volume
- 7. Structuring error output: JSON on stderr, separate from stdout
- 8. Pitfalls: broken JSON, special characters, and nested-value mistakes
- 9. JSON logging compared to free-text logs
- 10. Summary
- 11. FAQ
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.