09 · Centralized Logging Basics¶
Level 1 covered reading logs on a single box (journalctl, /var/log).
Once you have more than one server, "SSH into each box and grep" stops
scaling — you need logs shipped somewhere central where you can search
across every host at once. This module builds that pipeline with the free,
widely-used combination of Vector (or Filebeat) shipping into a
central store.
Why centralize¶
- An incident spans multiple backends (module 3) — you need one place to correlate what happened on all of them at the same timestamp.
- Logs on a crashed/terminated box are gone with it — shipping them off-box in near-real-time is your only record if the host itself doesn't survive.
- Grepping 10 files by hand doesn't scale; a search index does.
Architecture¶
app (stdout/stderr → journald)
│
▼
log shipper (Vector/Filebeat) reads journald, forwards over the network
│
▼
central log store (Loki / Elasticsearch / a hosted service)
│
▼
you, searching/dashboarding (Grafana / Kibana)
Shipping journald logs with Vector¶
# /etc/vector/vector.toml
[sources.app_logs]
type = "journald"
include_units = ["myapp.service", "nginx.service"]
[transforms.add_host]
type = "remap"
inputs = ["app_logs"]
source = '''
.host = get_hostname!()
.environment = "production"
'''
[sinks.loki]
type = "loki"
inputs = ["add_host"]
endpoint = "http://loki.internal:3100"
encoding.codec = "json"
labels.job = "myapp"
labels.host = "{{ host }}"
The remap transform adds fields (hostname, environment) to every log line
before it's shipped — this is what lets you later filter "show me only
production, only host web-3" in the central UI.
Querying centralized logs (LogQL, Loki's query language)¶
{job="myapp"} |= "ERROR"
{job="myapp", host="web-2"} | json | status_code >= 500
{job="myapp"} |= "ERROR" [5m] # count errors in the last 5 minutes, e.g. for an alert
The equivalent single-host journalctl command you already know from
level 1 (journalctl -u myapp -p err) only ever sees one machine —
{job="myapp"} |= "ERROR" across a Loki index sees every machine shipping
that label, which is the entire point of this module.
Structured logging: log JSON, not free text¶
Free-text logs ("user 42 logged in from 1.2.3.4") are hard to query
precisely. Structured JSON logs are trivial to filter and aggregate on
specific fields:
import logging, json, sys
class JsonFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"timestamp": self.formatTime(record),
"level": record.levelname,
"message": record.getMessage(),
"user_id": getattr(record, "user_id", None),
"request_id": getattr(record, "request_id", None),
})
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.getLogger().addHandler(handler)
logging.info("user logged in", extra={"user_id": 42, "request_id": "abc-123"})
# -> {"timestamp": "...", "level": "INFO", "message": "user logged in", "user_id": 42, "request_id": "abc-123"}
With structured logs, {job="myapp"} | json | user_id="42" finds every
event for that user across every host and every deploy — a query that's
essentially impossible to do reliably against free-text logs at scale.
Log retention and cost control¶
Centralized logging has a real storage cost that grows with volume — set a retention policy deliberately rather than by accident:
A common pattern: keep verbose (debug) logs for a short window (days), keep error/warn-level logs longer (weeks/months) for incident postmortems and compliance, and sample or drop extremely high-volume, low-value log lines (e.g. per-request access logs on a busy endpoint) rather than shipping 100% of everything forever.
Correlating a request across services with a request ID¶
import uuid
request_id = request.headers.get("X-Request-Id", str(uuid.uuid4()))
# pass it through: log it locally, AND forward it as a header to any downstream service call
Generating (or propagating) one request_id per incoming request and
including it in every log line that request produces — across every
service it touches — is what turns "logs from five different backends"
into "the complete story of one user's failed checkout," searchable with a
single request_id="..." query.
How It Actually Works¶
Why centralized logging needs a shipping agent, not just a shared filesystem. Logs are generated by writing to a local file descriptor on each host; getting them into one searchable place requires an agent (Filebeat, Fluentd, Vector) that tails each log file (tracking file offset so it survives restarts), parses/structures each line, and ships it over the network to a central store. This decoupling — write locally first, ship asynchronously — exists so that logging never blocks the application on network I/O to a remote logging service; if the shipper or network is down, logs simply queue locally (or on disk) until it recovers, rather than stalling the application that's producing them.
Structured (JSON) logs vs plain text — the actual query cost
difference. A centralized log store ingests each line and, for JSON logs,
can index individual fields (level, request_id, status_code)
independently, so a query like status_code >= 500 is a fast indexed
lookup. Free-text logs require either regex parsing at ingest time (fragile,
breaks silently when a log format changes) or full-text search across the
raw string at query time, which is dramatically more expensive at scale —
the structural difference is exactly what separates a
grep-through-flat-files workflow from a real log-analytics query.
Correlation IDs and distributed request tracing. A single user request touching a proxy, an app server, and a database each produces separate log lines on separate hosts with no inherent link between them — a correlation ID (a UUID generated at the edge and propagated through headers/context on every downstream call) is the only thing that lets you reconstruct "every log line produced while handling this one request" after the fact, by querying the central store for that ID across all sources.
Exercise¶
- Install Vector (or Filebeat) on a test VM and configure it to ship
journalctloutput for one unit to a local file or a free-tier Loki/ Grafana Cloud instance. - Switch a toy app's logging to structured JSON with at least a
request_idfield, and confirm you can query for a single request's full log trail. - Write a LogQL (or equivalent) query that counts ERROR-level lines in the last 5 minutes — the kind of query you'd wire into an alert in the next level's observability work.