ObservabilityXXXI · Logging FoundationsLoggingFoundations
Machine-Readable Logs
What you'll learn
- Choose between JSON and logfmt for a given workload and justify the choice
- Apply the canonical field-naming conventions from the OpenTelemetry log data model
- Configure the application to emit a stable schema with explicit field types
- Validate that a line is parseable end to end (application, pipeline, store, query)
- Diagnose the failure modes of a partial or drifted schema
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
A senior engineer is asked how long the checkout P99 latency has
been. They open Grafana. The panel is empty. The reason is that
three of the seven services emit latency_ms, two emit
response_time, one emits duration, and the last emits
elapsed in seconds, not milliseconds. The LogQL query that the
panel was built on returned five minutes of data and then nothing
when the field name changed.
This is the lesson. A machine-readable log is one a machine can parse. A log that is parseable but uses different field names across services is barely machine-readable at all.
What “machine-readable” means
A log line is machine-readable when a parser can extract every piece of meaning from it without falling back to heuristics. The two encodings the production fleet uses are JSON and logfmt:
{"ts":"2026-01-15T03:12:44.512Z","level":"info","service":"checkout","msg":"request received","request_id":"7f4a1c","http.method":"POST","http.status":200,"duration_ms":187}
ts=2026-01-15T03:12:44.512Z level=info service=checkout msg="request received" request_id=7f4a1c http.method=POST http.status=200 duration_ms=187
Both lines are parseable. Both carry the same fields with the same values. The choice between them is operational: JSON is the default for application code because every language has a JSON serialiser; logfmt is common at the edge (syslog, shell scripts, HAProxy) because it is cheaper to type and easier to grep from a shell.
A log line is not machine-readable when:
- The format is free-form English (
"Request was processed successfully in 187ms for user 88472"). - The field names drift between services (
latency_msvsresponse_timevsduration). - The field types are mixed (
status=200as a string in one service, as an integer in another).
Why a sysadmin cares
Three operational payoffs.
- Field-name stability. A Grafana panel that filters on
duration_msreturns the same data after a service refactor. The cost is a documented field-name registry and a code-review check that the registry is honoured. - Type stability. A field that is always an integer is aggregable. A field that is sometimes a string and sometimes an integer forces the query layer to coerce on every read.
- Cross-service analytics. A panel that shows
histogram_quantile(0.99, sum by (le, service) (rate( http_server_duration_ms_bucket[5m])))works only when every service names the histogram bucket field the same way and exposes it as a numeric metric or as a structured log field that Loki can aggregate.
The cost is a documented schema and a code-review discipline. The return is the difference between a Grafana panel that works across services and one that works in one service until the refactor.
How it works — the mental model
Application
log library reads the field set from a typed structure
field names are taken from a package-level constant table
field types are explicit (string, int, float, bool, time)
serialised as JSON or logfmt
Pipeline
Promtail/Alloy parses the line
field-name mapping (if any) is applied here
field-type coercion is applied here
Store
Loki stores the parsed payload
Loki can aggregate numeric fields across lines
Query
LogQL queries by field name
numeric aggregation is possible because the type is stable
The crucial point is the schema lives at the source. The application chooses the field names. The pipeline can rename fields, but only to follow a documented convention. The store holds whatever the pipeline handed it. The query trusts the field names it has been given.
How to configure it
The application side — a typed log record in Go:
type LogRecord struct {
Timestamp time.Time `json:"ts"`
Severity string `json:"level"`
Service string `json:"service"`
Version string `json:"service.version"`
Environment string `json:"deployment.environment"`
Body string `json:"msg"`
RequestID string `json:"request_id,omitempty"`
HTTPMethod string `json:"http.method,omitempty"`
HTTPStatus int `json:"http.status,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
UserID string `json:"user_id,omitempty"`
}
// Field names are package-level constants. Renaming requires
// changing the constant, not the call site.
const (
FieldTimestamp = "ts"
FieldSeverity = "level"
FieldService = "service"
// ...
)
The pipeline side — field-name mapping in Grafana Alloy:
loki.process "normalise" {
stage.json {
expressions = {
"level" = "level",
"service" = "service",
"duration" = "duration_ms",
"status" = "http.status",
"method" = "http.method",
}
}
// Coerce types so numeric aggregation works in LogQL.
stage.template {
name = "duration"
template = "{{ .duration }}"
}
forward_to = [loki.write.default.receiver]
}
The query side — a LogQL query that aggregates the structured field:
sum by (service) (
rate(
{job="application"} | json | duration_ms > 0 [5m]
)
)
How to validate it
The validation ladder:
# 1. The line is parseable JSON.
tail -n 1 /var/log/app/checkout.log | jq -e '.ts and .level and .service and .msg'
# true
# 2. The field names match the registry.
for f in ts level service msg request_id duration_ms http.status; do
jq -e ".$f" /var/log/app/checkout.log || echo "missing: $f"
done
# (no output — all fields present)
# 3. The field types are stable.
jq -r '.duration_ms | type' /var/log/app/checkout.log | sort -u
# number (only one type — good)
# 4. The pipeline parses and aggregates correctly.
logcli query --since=1h --output=stats \
'{job="application"} | json | duration_ms > 100'
# {service="checkout"} 12 24 187 4217
# 5. The registry is documented.
cat /etc/runbook/log-schema.yaml
# - name: ts type: string format: RFC3339Nano
# - name: level type: string enum: [debug, info, warn, error]
# - name: service type: string cardinality: low
# - name: duration_ms type: number unit: milliseconds
How it can fail
Six recurring failure modes.
- Field-name drift. Service A names it
duration_ms; Service B renames tolatency_msin a refactor. The cross-service panel breaks. Symptom: the dashboard shows data for some services and N/A for others; thehistogram_quantilereturns inconsistent values across services. - Type drift. A field that was an integer becomes a string
when a code path serialises it differently (
"187"vs187). Symptom: numeric aggregation in LogQL returns errors; the> 100filter matches both187and"187", then fails to aggregate. - Mixed encodings. Half the fleet emits JSON, half emits logfmt, one service emits free-form. Symptom: the pipeline parses some lines and rejects others; the audit grep finds missing fields in production Loki.
- Drifted registry. The schema document is updated; the application is not. Symptom: the validation step above reports missing fields; the discrepancy is visible in the diff between the registry and the live line.
- Unicode in field names. A developer uses
caféas a field name in the local environment, and the JSON serialiser accepts it. Loki’s parser rejects it. Symptom: lines from one environment fail to parse in another; the pipeline errors metric rises. - Numeric overflow. A duration expressed in nanoseconds
overflows JavaScript’s number type in Grafana. Symptom:
the Grafana panel shows
1.7e+308for any duration over roughly nine quadrillion nanoseconds; the panel is unreadable.
How to troubleshoot it
The diagnostic order for “the panel is empty after the refactor”:
- What does the live line say?
tail -n 1 /var/log/app/checkout.log | jq— confirm the field names and types in the actual emitted record. - What does Loki see?
logcli query '\{job="application"\} | json | __error__=""' | head— Loki’s__error__label is set on lines that failed parsing. - Which service changed? Compare the registry to the live field names per service. The drift is usually local to one service that shipped a refactor.
- Is the pipeline normalising?
logcli query '\{job="app"\} | json | duration_ms=~".+"' --limit=1— confirms the pipeline applied the rename. - Is the registry the source of truth? The remediation is the registry, not the application code. Update the registry, roll the pipeline, then ask the application team to align.
Security implications
Machine-readable logs make data classification reviewable. A
schema field named password is visible in code review; a
free-form sentence containing the same data is not. A registry
that names every field and its classification is the contract
between platform, security, and legal.
The risk is the inverse. A field that is misnamed (auth_blob
for what is actually a session token) escapes the pipeline
scrubber because the scrubber regex is keyed on the standard
names. The remediation is to keep the field names in the
registry aligned with the data classification, not with the
developer’s preferred abbreviation.
Performance implications
The cost of machine-readable logs is the parse cost. JSON parsing is roughly 200 ns per field for a typical record; logfmt is roughly 100 ns per field. At 50 000 lines per second, the parse cost is 10 ms of CPU per second — negligible.
The expensive failure shape is the regex-driven rename. The
pipeline stage.template runs once per line and copies a
template result into the record. The cost is roughly 50 ns per
template invocation. Multiple renames compound. The remediation
is to rename at the source (one-time cost per service) rather
than at the pipeline (per-line cost for the life of the fleet).
Production guidance
- Adopt the OpenTelemetry log data model field names where they
apply.
http.method,http.status_code,duration_ms. The standard fields (ts,level,service,msg) are mandatory. - Document the registry. A YAML file in the platform repository, reviewed on every schema change, is the minimum.
- Validate on every deploy. The validation step above should run in CI against a representative sample from production. A refactor that breaks the registry should fail the build.
- Rename at the source. The pipeline rename is the bridge during a migration, not the permanent home of the convention.
Verification
You should now be able to answer:
- What is the difference between parseable and machine-readable?
- Why is a schema registry preferable to per-service field-name conventions?
- Where does the rename happen — at the source, in the pipeline, or in the query?
- What is the OpenTelemetry log data model, and which fields does it mandate?
Quiz
Knowledge check · 8 questions
Q1. Which encoding is the default for application code in a production observability stack?
Q2. A Grafana panel that aggregates a numeric field across services returns inconsistent values. What is the most likely cause?
Q3. A schema registry that is documented but not enforced in CI is sufficient to keep field names consistent.
Q4. Which of these are real production failure modes of a machine-readable logging pipeline?
Q5. Name the OpenTelemetry data model that defines the canonical log field names.
Q6. Where should a field rename happen during a fleet-wide schema migration?
Q7. Logfmt is roughly the same size on the wire as JSON for the same set of fields.
Q8. A query that aggregates a structured field returns errors in LogQL. What is the first thing to check?
Passing score: 75%. Answers are checked in this browser.