Skip to main content

šŸ“œ 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.

flowchart TD A[Microservice A] -->|logs| C[Centralized Logging System] B[Microservice B] -->|logs| C D[Frontend] -->|logs| C E[Database] -->|logs| C F[Load Balancer] -->|logs| C C --> G[Search] C --> H[Dashboards] C --> I[Alerting]

āœ… 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:

The four-stage logging pipeline Four boxes connected by arrows — Collect, Process, Store, and Analyze — showing the flow of log data from sources to insight. Collect agents & shippers Process parse & enrich Store index & archive Analyze search & alert
Figure 1 — The universal logging pipeline. Different stacks fill each box with different tools, but the stages never change.
  • 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.

LevelUse forExample
ERRORFailures needing attentionDatabase connection lost; payment gateway down
WARNRecoverable / suspicious situationsRetry succeeded on 2nd attempt; deprecated API used
INFONormal milestonesService started; user logged in; order placed
DEBUGDetail for diagnosing issuesCache miss; query returned 0 rows
TRACEExtreme detail, dev onlyEvery 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.

flowchart LR A[App logs] --> B[Filebeat / Fluent Bit] --> C[Logstash] --> D[Elasticsearch] --> E[Kibana]

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.

flowchart LR A[App logs] --> B[Promtail] --> C[Loki] --> D[Grafana]
ELK / ElasticPLG / Loki
IndexesFull log contentLabels only
Cost & resourcesHigherLower
Search powerVery rich (full-text)Label filter + grep-style
Best whenYou need deep ad-hoc searchYou 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.

sequenceDiagram participant U as User participant GW as API Gateway participant AUTH as Auth Service participant CART as Cart Service U->>GW: GET /cart (generate trace-id abc123) GW->>AUTH: validate (header: x-trace-id abc123) AUTH-->>GW: ok GW->>CART: get items (header: x-trace-id abc123) CART-->>GW: items GW-->>U: cart response Note over GW,CART: Every log line carries trace-id abc123

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:

  1. Install Pino (npm install pino) and set up the logger and traceMiddleware from the previous section.
  2. Register the middleware before your routes so every request gets a trace ID.
  3. In a route, log an info when it starts and an error in a catch block — without ever passing the trace ID yourself.
  4. Make two requests, one with a x-trace-id: manual-123 header and one without, and confirm the logs show a reused vs. a freshly minted ID.
  5. Verify every log line is valid JSON containing service, level, and traceId.
šŸ’” 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 fieldsLog passwords, tokens, or API keys
Include correlation / trace IDsLog full credit-card or bank numbers
Log to stdout; let the platform ship itLog unnecessary PII (mask or omit it)
Set retention tiers (hot / warm / cold)Keep everything forever at full detail
Encrypt logs in transit and at restLeave 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

šŸš€ 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.