š Centralized Logging Implementation
Metrics tell you a graph turned red; logs tell you the exact request, user, and stack trace behind it. But in a system of a dozen containers, logs are scattered everywhere. This lesson shows you how to gather them into one searchable place ā with structured JSON, a proper collection pipeline, and trace IDs that stitch a single request across every service it touched.
šÆ Learning Objectives
By the end of this lesson, you will be able to:
- Explain why centralized logging is essential in distributed systems
- Implement structured (JSON) logging in Node.js and Python with correct log levels
- Describe the collect ā process ā store ā analyze logging architecture
- Compare the ELK and PLG (Loki) stacks and stand one up with Docker Compose
- Correlate logs across services using trace IDs, and know what must never be logged
Estimated Time: 35ā45 minutes ⢠Difficulty: Intermediate
Hands-on: Add structured logging with a request-scoped trace ID to an Express service.
In This Lesson
Why Centralize Logs?
In the previous lesson, metrics gave you quantitative signals ā numbers over time. Logs are qualitative: a timestamped record of discrete events, each carrying the detail metrics leave out. Together they complete the picture.
š§ A car analogy: Metrics are your dashboard gauges ā they warn that the engine is overheating. Logs are the mechanic's detailed service notes ā they tell you which hose burst, when, and in what order things failed. You need both to actually fix the car.
The trouble is that a modern app is not one program. It is a fleet of containers ā microservices, a frontend, databases, a load balancer ā each writing logs to its own ephemeral filesystem. When a container restarts, those logs vanish. Centralized logging ships every log to one durable, searchable system so you can investigate the whole system at once.
ā Why it pays off
- Unified view ā every component's logs in one query bar
- Cross-service tracing ā follow one request through many services
- Durability ā logs outlive the container that wrote them
- Faster resolution ā teams routinely cut incident diagnosis from hours to minutes once correlated search is available
The Logging Pipeline
Every centralized logging system, regardless of the tools, is built from the same four stages. Data flows left to right:
- Collect ā lightweight agents (Fluent Bit, Vector, Promtail, Filebeat) read logs from files or container stdout and forward them. In Kubernetes this is usually a DaemonSet or a sidecar.
- Process ā parse unstructured text into fields, drop noise, enrich with context (add the pod name, region, or a GeoIP lookup), and mask sensitive data.
- Store ā a system built for high write volume and fast queries, with hot/warm/cold tiers so recent logs are fast and old logs are cheap.
- Analyze ā search, dashboards, and alerting on top of the stored logs.
Structured Logging
The single most impactful habit in logging is to emit structured logs ā JSON objects with named fields ā instead of free-form text. Compare these two lines describing the same event:
# Unstructured ā a human can read it, a machine struggles
2026-08-01 15:23:45 ERROR Payment failed for user johndoe amount 99.99 code DECLINED
{
"timestamp": "2026-08-01T15:23:45.678Z",
"level": "error",
"service": "payment-service",
"message": "Payment failed",
"userId": "u_12345",
"amount": 99.99,
"currency": "USD",
"errorCode": "CARD_DECLINED"
}
The JSON version can be filtered by errorCode, aggregated by currency, and searched by userId ā no fragile text parsing required. Here is how to produce it.
Node.js with Pino
Pino is the modern, fast structured logger for Node. It emits JSON by default:
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
base: { service: 'payment-service' }, // fields added to every log line
});
logger.info({ userId: 'u_12345', loginTime: new Date().toISOString() },
'User logged in');
logger.error(
{ userId: 'u_12345', amount: 99.99, currency: 'USD', errorCode: 'CARD_DECLINED' },
'Payment failed'
);
Python with structlog
import structlog
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
],
)
log = structlog.get_logger(service="payment-service")
log.info("payment_processed", user_id="u_67890",
payment_id="PMT123456", amount=99.99, currency="USD")
log.error("payment_failed", user_id="u_67890",
payment_id="PMT123457", amount=149.99,
error_code="CARD_EXPIRED")
š” Log to stdout, let the platform ship it
In containers, follow the Twelve-Factor rule: applications should write logs to stdout as a stream and stay out of the routing business. The container runtime captures the stream, and a collector (Fluent Bit, Promtail) ships it onward. Your app never manages log files.
Log Levels Done Right
Log levels let you dial verbosity up in development and down in production. Using them consistently is what makes a log store searchable rather than a swamp.
| Level | Use for | Example |
|---|---|---|
| ERROR | Failures needing attention | Database connection lost; payment gateway down |
| WARN | Recoverable / suspicious situations | Retry succeeded on 2nd attempt; deprecated API used |
| INFO | Normal milestones | Service started; user logged in; order placed |
| DEBUG | Detail for diagnosing issues | Cache miss; query returned 0 rows |
| TRACE | Extreme detail, dev only | Every function entry/exit |
ā ļø INFO is not "log everything"
Excessive INFO logging in production is expensive and drowns the signal. A good rule: if a line would not help you during an incident and is not a meaningful business event, it belongs at DEBUG. Run production at INFO, and be able to flip to DEBUG via an environment variable when investigating.
ELK vs. PLG Stacks
Two open-source stacks dominate. They fill the same pipeline boxes differently.
The ELK / Elastic Stack
Elasticsearch (store & search) + Logstash (process) + Kibana (analyze), with Beats or Fluent Bit collecting. It indexes the full content of every log, making it extremely powerful to search ā at the cost of heavy storage and memory.
The PLG Stack (Grafana Loki)
Promtail (collect) + Loki (store) + Grafana (analyze). Loki's trick: it indexes only a small set of labels, not the full log content ā like Prometheus, but for logs. That makes it far cheaper to run, and it shares Grafana with your metrics.
| ELK / Elastic | PLG / Loki | |
|---|---|---|
| Indexes | Full log content | Labels only |
| Cost & resources | Higher | Lower |
| Search power | Very rich (full-text) | Label filter + grep-style |
| Best when | You need deep ad-hoc search | You already run Grafana & want cheap logs |
Here is a minimal Loki + Promtail + Grafana stack in Docker Compose ā lightweight enough to run on a laptop:
# docker-compose.yml ā PLG logging stack
services:
loki:
image: grafana/loki:2.9.0
command: -config.file=/etc/loki/local-config.yaml
ports:
- "3100:3100"
promtail:
image: grafana/promtail:2.9.0
volumes:
- /var/log:/var/log:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- ./promtail-config.yml:/etc/promtail/config.yml
command: -config.file=/etc/promtail/config.yml
depends_on:
- loki
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
depends_on:
- loki
Once running, add Loki as a Grafana data source (http://loki:3100) and query logs with LogQL, Loki's PromQL-inspired language:
# All error logs from the payment service
{service="payment-service"} | json | level="error"
# Count errors per minute
sum(count_over_time({service="payment-service"} | json | level="error" [1m]))
š Managed alternatives
You do not have to run any of this yourself. Cloud providers offer managed logging ā AWS CloudWatch Logs, Google Cloud Logging, Azure Monitor Logs ā as do SaaS vendors like Datadog and Splunk. They trade money for operational simplicity; the concepts you are learning transfer directly.
Correlating with Trace IDs
Centralized logs are useful; correlated logs are transformative. When a single user request fans out across five services, you want to see all five services' log lines for that one request, in order. The mechanism is a trace ID: a unique identifier generated at the edge and propagated (via an HTTP header) to every downstream service, which stamps it on every log line it writes.
You can do this manually with a header and a per-request logger, or adopt OpenTelemetry, which standardizes trace-ID generation and propagation across languages and auto-instruments popular frameworks. Here is the lightweight manual version in Express using AsyncLocalStorage so every log inside a request automatically includes the ID:
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import pino from 'pino';
const als = new AsyncLocalStorage();
const base = pino({ base: { service: 'api-gateway' } });
// Wrap the base logger so it always merges in the current trace ID
export const logger = new Proxy(base, {
get(target, prop) {
const traceId = als.getStore()?.traceId;
if (['info', 'warn', 'error', 'debug'].includes(prop)) {
return (obj, msg) => target[prop]({ ...obj, traceId }, msg);
}
return target[prop];
},
});
// Middleware: reuse an incoming trace ID or mint a new one
export function traceMiddleware(req, res, next) {
const traceId = req.headers['x-trace-id'] ?? randomUUID();
res.setHeader('x-trace-id', traceId);
als.run({ traceId }, () => next());
}
Now a search for traceId="abc123" in Loki or Kibana returns the complete story of one request across every service that honored the header.
Hands-on Exercise
šļø Add correlated structured logging
Objective: Give an Express service structured JSON logs, each tagged with a request-scoped trace ID.
Instructions:
- Install Pino (
npm install pino) and set up theloggerandtraceMiddlewarefrom the previous section. - Register the middleware before your routes so every request gets a trace ID.
- In a route, log an
infowhen it starts and anerrorin acatchblock ā without ever passing the trace ID yourself. - Make two requests, one with a
x-trace-id: manual-123header and one without, and confirm the logs show a reused vs. a freshly minted ID. - Verify every log line is valid JSON containing
service,level, andtraceId.
š” Hint
Because the logger reads the trace ID from AsyncLocalStorage, you never thread it through function arguments ā that is the whole point. Just call logger.info({ ... }, 'message') and the ID appears automatically.
ā Example solution
import express from 'express';
import { logger, traceMiddleware } from './logging.js';
const app = express();
app.use(traceMiddleware);
app.get('/checkout', async (req, res) => {
logger.info({ route: '/checkout' }, 'Checkout started');
try {
// ...business logic...
res.json({ ok: true });
} catch (err) {
logger.error({ route: '/checkout', err: err.message }, 'Checkout failed');
res.status(500).json({ error: 'internal' });
}
});
app.listen(3000);
A request with no header produces "traceId":"9b1cā¦" (a fresh UUID); a request sending x-trace-id: manual-123 produces "traceId":"manual-123" on every line ā proving propagation works.
Best Practices & Security
| ā Do | ā Never |
|---|---|
| Emit structured JSON with consistent fields | Log passwords, tokens, or API keys |
| Include correlation / trace IDs | Log full credit-card or bank numbers |
| Log to stdout; let the platform ship it | Log unnecessary PII (mask or omit it) |
| Set retention tiers (hot / warm / cold) | Keep everything forever at full detail |
| Encrypt logs in transit and at rest | Leave the log store open to the internet |
ā ļø Logs are a security surface
Logs frequently leak secrets and personal data by accident. Apply data masking at the processing stage, restrict who can read the log store, and remember that logging PII can put you in breach of GDPR or similar regulations. When in doubt, log an identifier (a user ID) rather than the sensitive value itself.
Retention & cost
Logs grow fast. Manage them with tiers: hot storage for the last few days (fast queries), warm for a few weeks (ongoing investigations), and cold/archived for long-term compliance (cheap object storage, rarely queried). Compress and rotate aggressively.
Summary & Quiz
š Key Takeaways
- Centralized logging gathers scattered, ephemeral container logs into one durable, searchable place.
- Emit structured JSON with consistent fields ā it is filterable and aggregatable where free text is not.
- The pipeline is always collect ā process ā store ā analyze, filled by tools like ELK or PLG (Loki).
- Loki indexes labels not content, making it cheaper and Grafana-native; Elasticsearch indexes everything for richer search.
- Trace IDs correlate one request across every service; never log secrets or PII.
šÆ Quick Quiz
Question 1: What is the main advantage of structured (JSON) logging over free-form text logs?
Question 2: How does Grafana Loki keep costs lower than Elasticsearch for logs?
Question 3: A single user request touches five services. What lets you see all five services' logs for that one request, in order?
š Further Reading
- Grafana Loki Documentation
- Elastic Stack Documentation
- Pino ā fast Node.js logger
- The Twelve-Factor App ā Logs
š What's Next?
You now have metrics and logs. In the final lesson we bring them together into a complete monitoring stack ā deploying Prometheus and Grafana, writing PromQL, building dashboards, and wiring up Alertmanager.
š Well done!
Your logs are structured, correlated, and centralized. Time to visualize everything.