ObservabilityXXXI · Logging FoundationsLoggingFoundations
Logs vs Structured Logs
What you'll learn
- Distinguish unstructured text logs from structured (JSON / logfmt) records in production terms
- Explain why parsing at the source is preferable to parsing at the destination
- Choose which fields belong as Loki labels and which belong in the JSON payload
- Recognise the canonical minimum shape of a production application log record
- Diagnose the four highest-frequency structured-logging failure modes
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
At 03:12 the on-call engineer needs every log line that belongs to the
checkout request which returned HTTP 500 to user 88472. They open Loki
and write a LogQL query. The query returns nothing. The reason is not
Loki. The reason is that three different services wrote three different
formats, and the request_id is buried inside a free-form English
sentence in two of them. The investigation stalls for forty minutes
while someone writes a regex per format.
This lesson is about preventing that outcome. A log line is either a sentence written for a human to read, or it is a record with named fields written for a parser to read. The cost difference between the two at production scale is not a few percent. It is the difference between a Loki cluster that fits on three nodes and one that needs twelve.
What an unstructured log is
An unstructured log is a free-form string. The classic example is BSD syslog:
Jan 15 03:12:44 web01 nginx: 10.0.0.42 - - [15/Jan/2026:03:12:44 +0000] "GET /checkout HTTP/1.1" 500 137 "-" "curl/8.4.0"
The line is parseable, but the parsing is by convention. The date is in one format, the host in another, the status code is the ninth whitespace-separated token, the user agent is the eleventh quoted field. A change to the format (a new field added in the middle, a quote in the user agent) breaks every regex that consumes the line.
Unstructured logs are cheap to write. They are also cheap at low volume. They become prohibitively expensive past a few hundred gigabytes per day, because every consumer writes and re-runs a parser.
What a structured log is
A structured log is a record with named fields. The two encodings you will meet in production are JSON and logfmt:
{"ts":"2026-01-15T03:12:44.512Z","level":"error","service":"checkout","request_id":"7f4a1c","status":500,"msg":"payment declined","duration_ms":4217,"user_id":"u-88472"}
ts=2026-01-15T03:12:44.512Z level=error service=checkout request_id=7f4a1c status=500 msg="payment declined" duration_ms=4217 user_id=u-88472
The same event. The fields are named. The names are stable across
versions. A parser sees the structure on the first read and never
needs to be rewritten. The query is level="error", not ~ /ERROR/.
Why a sysadmin cares
Three operational payoffs fall out of structured logging:
- Pivot-by-field. LogQL
\{service="checkout"\} |= "error" | json | status="500"returns the exact set of lines you asked for. No regex. - Cardinality budgeting. Loki stores some fields as indexed labels (cheap, bounded) and the rest as parsed-but-not-indexed payload (cheap, unbounded). You decide where the budget goes.
- Schema review. A structured field set is reviewable in code review. A free-form sentence is not. Schema drift becomes a pull-request conversation, not a 03:00 incident.
The cost is real but smaller than it looks: a JSON line is roughly 60 percent larger than the same event as English. At 200 bytes per line and ten thousand lines per second, that is an extra 600 KiB/s of ingest — manageable at every scale a single team operates.
How it works — the mental model
Unstructured path
application --> "free-form string" --> stdout
--> Promtail/Alloy tail
--> Loki stores the raw body
--> at query time, the analyst writes a regex
Each consumer re-parses the same line.
Structured path
application --> "JSON object" --> stdout
--> Promtail/Alloy pipeline_stages json
--> Loki stores extracted labels + the parsed payload
--> at query time, LogQL filters by field name
The application embeds the meaning once.
The single most important sentence in this lesson: parse at the source. The application that emits the event knows the field names. The parser downstream has to guess.
How to configure it
The application side is one line in code: use a structured logger. The pipeline side is the configuration below. The example is for Grafana Alloy in its River syntax, which is the current production-default:
loki.source.file "checkout" {
targets = [
{__path__ = "/var/log/app/checkout.log", job = "application",
service = "checkout"},
]
forward_to = [loki.process.checkout.receiver]
}
loki.process "checkout" {
stage.json {
expressions = {
"level" = "level",
"request_id" = "request_id",
"status" = "status",
"duration" = "duration_ms",
}
}
// Promote only bounded-cardinality fields to Loki stream labels.
// request_id and duration are kept as parsed payload, not labels.
stage.labels {
values = {
level = "level",
service = "",
}
}
// Override the ingest timestamp from the structured field.
stage.timestamp {
source = "ts"
format = "RFC3339Nano"
}
forward_to = [loki.write.default.receiver]
}
Three configuration decisions worth calling out:
serviceis set at the source target, not parsed from JSON. Setting it as a static label keeps it immutable. The pipeline cannot accidentally parse the wrong service name out of a log message.levelis promoted to a label. It is bounded (five to seven values), it is filtered on in nearly every Grafana panel, and the cost is negligible.request_idanddurationare extracted but not labelled. Promoting them would create one Loki stream per request per duration bucket. The ingest path becomes a denial-of-service against your own storage.
How to validate it
The validation ladder, in order of what to check first:
# 1. The application is actually writing JSON.
tail -n 1 /var/log/app/checkout.log | jq -e '.ts and .level and .service'
# {"ts":"2026-01-15T03:12:44.512Z","level":"error",...}
# 2. The pipeline parses it.
logcli query --since=1h '{job="application"} | json | level="error"'
# 2026-01-15T03:12:44.512Z {} request_id=7f4a1c status=500 ...
# 3. The label set is what you expect.
logcli series --since=15m '{job="application"}'
# {job="application", level="error", service="checkout"}
# {job="application", level="info", service="checkout"}
# 4. No high-cardinality field leaked into a label.
logcli series --since=15m '{job="application"}' | grep -c request_id
# 0
# 5. The timestamps are honoured, not the ingest time.
logcli query --since=1h --limit=1 '{job="application"}' | head -1
# 2026-01-15T03:12:44.512Z <-- source time, not "now"
If any of the five steps returns the wrong shape, the failure is upstream of the query.
How it can fail
Six failure modes appear repeatedly in production. Each has a recognisable symptom.
- A newline inside a JSON value. A stack trace printed with
log.Println(err)instead of the structured logger, with embedded\ncharacters, breaks the JSON object into multiple lines. Loki rejects the malformed half. Symptom: roughly half the lines from one service are missing in Loki, no log of the rejection. - A field-name change between releases.
request_idbecomesreq_idin version 1.7.0. Every LogQL query that names the old field returns nothing. Symptom: dashboards go blank after a deploy, no error in Loki, no error in Grafana. - A high-cardinality field promoted to a label. A developer
adds
stage.labels { values = { user_id = "user_id" } }and ships it. Loki ingests one stream per user per minute. The ingester OOMs within an hour. Symptom:loki_ingester_streamscounter rises into the millions and memory follows. - A timestamp in the wrong format. The application writes Unix
seconds; the pipeline expects RFC3339Nano. The
timestampstage fails to parse, Loki falls back to ingest time, and the lines appear under the time the agent received them, not the time the event happened. Symptom: a deploy at 02:00 appears in Loki under 02:55, the analyst concludes the deploy is innocent. - A multi-line stack trace collapsed to one line. The exception
is joined with spaces. The
jsonstage sees one giant string formsgand the stack frames are unrecoverable. Symptom: error logs appear with no stack trace; debugging takes twice as long. - Mixed formats in the same file. Old code paths still write English; new code paths write JSON. The pipeline parser succeeds on the JSON lines and silently drops the English ones (or, worse, ingests them as opaque lines with no fields). Symptom: older service versions appear to have stopped logging after an upgrade.
How to troubleshoot it
The diagnostic order for “Loki is not showing what I expect”:
- Is the file on disk what you think?
tail -F /var/log/app/checkout.logand look at the raw bytes. Half of “Loki is broken” tickets are actually “the application stopped writing”. - Is the agent tailing it?
alloy fmt-check /etc/alloy/config.riverthencurl -s localhost:12345/-/readyto confirm Alloy itself is up. Checkjournalctl -u alloy --since=10mfor tail errors. - Did the pipeline parse the line?
logcli query '\{job="app"\} | json'— drop the field filter and inspect the parsed result. If the parsed output is empty, the JSON stage failed silently. - Are the labels correct?
logcli series '{job="app"}'shows the exact set of stream labels. If you expectedserviceand it is absent, thelabelsstage did not run. - Is the timestamp being honoured? Add
--limit=1and inspect the first line. If the timestamp is the wall-clock time of the query, thetimestampstage failed to parse the source format.
Security implications
Structured logging makes PII and secret handling reviewable.
A schema field named password is visible in code review; a free-form
sentence containing the same data is not. Structured payloads also
make redaction tractable — the pipeline can rewrite the password
field to "[REDACTED]" before the line reaches Loki.
The risk is the inverse. Structured logs that include password,
Authorization headers, or session tokens are a clean, indexed
copy of everything that should never have left the process. The
remediation is to drop these fields at the source, not to redact at
Loki — Loki has already stored them by the time the redaction runs.
Performance implications
The cost comparison at one million lines per minute:
- Unstructured: ~120 bytes per line, no parse cost at ingest, full regex parse at every query. Storage roughly proportional to bytes retained.
- Structured JSON: ~200 bytes per line, one JSON parse at ingest, label lookup at every query. Storage roughly proportional to bytes retained, plus the cost of stream labels (bounded).
The structured path pays more at ingest and less at query. For a platform where most of the cost is queries (which is every platform older than six months), structured wins decisively. For a write-heavy cold-store where queries are rare, the comparison narrows.
Production guidance
- Adopt JSON in application code as the default. Use logfmt at the edge (syslog forwarders, kernel messages) only when the producer cannot be modified.
- Extract only low-cardinality fields as Loki labels. Anything bounded by the number of services, log levels, status codes, or error categories is a label. Anything bounded by requests, users, or trace IDs is not.
- Validate the pipeline shape after every deploy. The first deploy that changes a field name should fail the roll-out, not the dashboards.
Verification
You should now be able to answer:
- What is the operational difference between an unstructured log and a structured log?
- Where should the parsing happen — at the source, in the pipeline, or at query time?
- Which fields belong as Loki labels and which belong in the JSON payload?
- Why does JSON-in-JSON cost more than JSON alone, and why is the extra cost worth paying?
Quiz
Knowledge check · 8 questions
Q1. What is the primary operational advantage of structured logs over unstructured text logs?
Q2. When should a field be promoted to a Loki stream label rather than left in the parsed payload?
Q3. Parsing belongs at the source, because the application already knows its own field names.
Q4. Which of these are real failure modes of a structured-logging pipeline?
Q5. Name the two structured-log encodings commonly used in production observability stacks.
Q6. Which is a valid reason to keep an unstructured log line in a production fleet?
Q7. In LogQL, the pipe-equals operator |= performs a label-scoped search.
Q8. What is the canonical minimum field set for an application log record?
Passing score: 75%. Answers are checked in this browser.