Skip to main content

🪵 Log Aggregation

One server writing logs to one file is easy to read. Fifty containers across three regions, each writing its own file, is a needle-in-a-haystack nightmare when a payment fails at 2 a.m. Log aggregation pulls every log line into one searchable place — so instead of SSH-ing into ten boxes, you type one query and find the answer.

Week 13 · Day 4 (Thursday: Monitoring and Logging) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why centralized logging is essential in distributed systems
  • Write structured JSON logs with pino and understand the winston alternative
  • Choose the right log level for each message and configure it per environment
  • Thread a correlation ID through a request so related logs group together
  • Compare the ELK, Grafana Loki, and cloud-native (CloudWatch) aggregation stacks
  • Set retention policies and keep secrets and PII out of your logs

Estimated Time: 75 minutes

Practice: Add a pino logger and a correlation-ID middleware to an Express app, then trace one request end to end.

In This Lesson

Why Aggregate Logs?

Imagine a detective investigating a case where witnesses are scattered across a dozen towns, each keeping a private diary in a different language. To reconstruct what happened, the detective would have to visit every town, translate each diary, and line the timelines up by hand. That's debugging a distributed system without log aggregation.

Log aggregation hires a courier to collect every diary, translate them into one language, and file them in a single searchable archive. Now the detective asks one question — "what happened to order 12345 between 14:30 and 14:33?" — and gets every relevant entry from every service, in order.

⚠️ The cost of not aggregating

  • Troubleshooting drags on — hours spent grepping across disparate files
  • Cross-service events don't connect — you can't see the timeout in Service A caused the error in Service B
  • Logs vanish — containers are ephemeral; a crashed pod takes its logs to the grave
  • No real-time view — problems fester until a user reports them

Real case: an e-commerce team chased intermittent payment failures for three days across separate app, DB, and gateway logs. After aggregating, one query revealed network timeouts spiking past a traffic threshold — fixed in an afternoon.

The Log Pipeline

Every aggregation system, whatever the branding, is the same four stages. Learn the shape once and every tool becomes "which piece plays which role."

graph LR A["Apps & Services"] --> B["Collector
ships logs"] B --> C["Storage
indexed for search"] C --> D["Query & Visualize"] D --> E["Dashboards"] D --> F["Search"] D --> G["Alerts"]
Four stages of a log pipeline: collect, transport, store, analyze 1. Collect Filebeat, Fluent Bit 2. Transport HTTP, Kafka, syslog 3. Store Elasticsearch, Loki 4. Analyze Kibana, Grafana
Collect → transport → store → analyze. Swap the tools; the pipeline shape never changes.

Structured Logging with pino

The foundation of any log pipeline is the format your app emits. A human sentence like User 12 failed login from 1.2.3.4 is fine for one person reading one file, but a machine can't reliably filter it. Structured logging emits JSON, where every field is a queryable key.

Pino is the fast, modern choice for Node — it logs JSON by default and adds barely any overhead:

// logger.js
const pino = require('pino');

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  // Redact sensitive paths so secrets never reach disk (more on this below)
  redact: ['req.headers.authorization', 'password', '*.creditCard'],
  base: { service: 'user-service', env: process.env.NODE_ENV },
});

module.exports = logger;
// Using it — the second arg is structured context, not string concatenation
const logger = require('./logger');

logger.info({ userId: 12, route: '/login' }, 'Login attempt');
logger.warn({ userId: 12 }, 'User not found');
logger.error({ err, orderId: '12345' }, 'Payment failed');

Output (one JSON object per line)

{"level":30,"time":1715176337345,"service":"user-service","env":"production","userId":12,"route":"/login","msg":"Login attempt"}

📖 pino vs winston

Winston is the older, highly configurable logger with a large transport ecosystem. Pino is leaner and faster because it defers formatting to a separate process. Both produce structured JSON — pick pino for performance-sensitive services, winston when you need its rich transport plugins. The concepts in this lesson apply to either.

Notice we log to stdout, not a file. In a containerized world that's the rule: write logs to standard output and let the platform (Docker, Kubernetes) capture and ship them. Your app shouldn't know or care where logs end up.

Log Levels

Levels let you turn the volume up or down without changing code. In development you might show everything down to debug; in production you show info and above, so the noise stays manageable and the bill stays lower.

LevelUse it forExample
fatalApp is about to crash and cannot continueCan't bind to port, config missing
errorAn operation failed and needs attentionUnhandled exception, payment declined by gateway error
warnUnexpected but recoverableRetry succeeded, deprecated API used
infoNormal, noteworthy eventsServer started, request completed, user signed up
debugDetailed flow for diagnosingCache miss, query parameters, branch taken
traceExtremely verbose, rarely onEvery function entry/exit

⚠️ Pick the level by action needed, not by mood

A retried-and-recovered blip is warn, not error — because nobody needs to wake up for it. Reserve error for things a human should look at. Miscalibrated levels are the number-one cause of alert fatigue, which the next lesson tackles head-on.

Correlation IDs

A single user action fans out across services, each logging independently. A correlation ID (also called a request ID or, when it spans services, a trace ID) is one identifier attached to every log line for that action — the thread that lets you pull the whole story out of millions of lines.

sequenceDiagram participant C as Client participant G as API Gateway participant A as Auth Service participant U as User Service C->>G: Request arrives, generate correlation ID Note over G: Log entry tagged with the correlation ID G->>A: Forward request and pass the ID in a header Note over A: Log token check tagged with the same ID A-->>G: Token is valid G->>U: Fetch user, still passing the same ID Note over U: Log user lookup tagged with the same ID U-->>G: Return user data G-->>C: Send final response

In Express, generate the ID once per request and bind it to a child logger so you never have to pass it around manually:

const { randomUUID } = require('crypto');
const baseLogger = require('./logger');

app.use((req, res, next) => {
  // Reuse an incoming ID from upstream, or mint a fresh one
  const correlationId = req.headers['x-correlation-id'] || randomUUID();

  // A child logger stamps EVERY log on this request with the ID
  req.log = baseLogger.child({ correlationId });

  // Pass it downstream and back to the client for support tickets
  res.setHeader('x-correlation-id', correlationId);
  next();
});

app.get('/users/:id', (req, res) => {
  req.log.info({ userId: req.params.id }, 'Fetching user');
  // ...every log via req.log now carries the same correlationId
  res.json({ id: req.params.id });
});

✅ The support-desk superpower

Return the correlation ID to the client (in a header or error body). When a user reports "request abc-123 failed," you paste that ID into your log search and instantly see every hop it took. No guessing.

Aggregation Stacks: ELK, Loki, Cloud

Three families dominate. They all implement the collect-store-analyze pipeline; they differ in how they index and what they cost.

The ELK / Elastic Stack

The classic open-source stack: Elasticsearch (stores and full-text-indexes logs), Logstash or lightweight Beats (collect and process), Kibana (visualize). It indexes the full content of every log, so search is powerful — but that indexing is storage-hungry.

# filebeat.yml — ship JSON logs to Elasticsearch
filebeat.inputs:
  - type: log
    enabled: true
    paths:
      - /var/log/app/*.log
    json.keys_under_root: true    # promote JSON fields to top level
    json.add_error_key: true

output.elasticsearch:
  hosts: ["elasticsearch:9200"]
  index: "app-logs-%{+yyyy.MM.dd}"    # daily time-based index

setup.kibana:
  host: "kibana:5601"

Grafana Loki — "like Prometheus, but for logs"

Loki takes a leaner approach: it indexes only a small set of labels (service, level, env) and stores the log body compressed and unindexed. That makes it dramatically cheaper than Elasticsearch, and it lives in the same Grafana pane as your metrics — so you jump from a latency spike straight to the logs behind it.

# promtail-config.yml — Loki's collector, scraping container logs
scrape_configs:
  - job_name: app
    static_configs:
      - targets: [localhost]
        labels:
          job: user-service
          env: production
          __path__: /var/log/app/*.log

Cloud-native — CloudWatch, Cloud Logging, Azure Monitor

If you run on a cloud provider, the managed option is often the least work. AWS CloudWatch Logs ingests stdout from Lambda and ECS automatically; GCP has Cloud Logging; Azure has Monitor Logs. You trade some flexibility and portability for zero infrastructure to run.

💡 How to choose

  • ELK — max search power, you're willing to operate it (or pay Elastic Cloud)
  • Loki — cost-conscious, already using Grafana for metrics
  • CloudWatch/cloud-native — already all-in on one cloud, want minimal ops

Whatever you pick, the app-side work — structured JSON, levels, correlation IDs — is identical. That's the point of structured logging: it's portable.

Retention & Security

Tiered retention

Logs are expensive to keep and most are never read. Use tiered storage that ages data out automatically:

  • Hot (1–7 days) — fast, searchable, for active debugging
  • Warm (weeks to months) — cheaper, for trend analysis
  • Cold / archive (months to years) — object storage like S3, for compliance
# Elasticsearch Index Lifecycle Management (ILM) policy
policy:
  phases:
    hot:
      actions:
        rollover: { max_size: "50gb", max_age: "1d" }
    warm:
      min_age: "7d"
      actions:
        forcemerge: { max_num_segments: 1 }
    delete:
      min_age: "90d"      # logs older than 90 days are removed
      actions:
        delete: {}

Never log secrets or PII

This is the rule with teeth. A logged password or credit-card number is a breach waiting to happen and can violate GDPR or HIPAA. Redact at the source:

// pino redaction — these paths are replaced with [Redacted] before write
const logger = require('pino')({
  redact: {
    paths: ['password', 'req.headers.authorization', 'user.ssn', '*.creditCard'],
    censor: '[Redacted]',
  },
});

// This is now SAFE — the password field is scrubbed automatically
logger.info({ user: { name: 'Ada', password: 'hunter2' } }, 'User created');
// => {"user":{"name":"Ada","password":"[Redacted]"},"msg":"User created"}

⚠️ Redact defensively

Don't rely on remembering to leave secrets out — one day someone will log the whole request object. Configure redaction in the logger itself, restrict who can read log data, and audit periodically. Assume anything logged could leak.

Practice & Quiz

🏋️ Exercise 1: Structured log with context

Goal: Using pino, log an info message "Order placed" that includes an orderId and a total field as structured data (not string concatenation).

💡 Hint

Pino's methods take the context object as the first argument and the message string as the second: logger.info({ ... }, 'message').

✅ Solution
const logger = require('pino')();
logger.info({ orderId: 'A-1001', total: 49.99 }, 'Order placed');
// => {"level":30,...,"orderId":"A-1001","total":49.99,"msg":"Order placed"}

🏋️ Exercise 2: Correlation-ID middleware

Goal: Write Express middleware that attaches a per-request child logger carrying a correlationId, reusing an incoming x-correlation-id header if present.

✅ Solution
const { randomUUID } = require('crypto');
const baseLogger = require('pino')();

app.use((req, res, next) => {
  const correlationId = req.headers['x-correlation-id'] || randomUUID();
  req.log = baseLogger.child({ correlationId });
  res.setHeader('x-correlation-id', correlationId);
  next();
});

🎯 Quick Quiz

Question 1: Why is structured (JSON) logging preferred over plain-text sentences?

Question 2: A network call failed once, was retried, and succeeded. What level should that log be?

Question 3: What is the main advantage of Grafana Loki over the ELK stack?

Best Practices & Pitfalls

✅ Do

  • Log structured JSON to stdout and let the platform ship it
  • Attach a correlation ID to every request and pass it downstream
  • Choose log levels by the action required, and set the threshold per environment
  • Configure redaction in the logger so secrets can't leak
  • Set retention and lifecycle policies so old logs age out automatically

❌ Don't

  • Log passwords, tokens, API keys, or PII — ever
  • Build log messages with string concatenation instead of fields
  • Write logs to local files inside a container (they die with the container)
  • Keep everything forever — cost and compliance both bite you
  • Log at debug in production and drown in noise

⚠️ Logging is not free

Every line costs CPU to serialize, bandwidth to ship, and money to store and index. Over-logging can slow a service and blow your observability budget. Log what you'd actually want during an incident — no more.

Summary

🎉 Key Takeaways

  • Aggregation centralises scattered logs into one searchable place — essential once you have more than one service
  • Every pipeline is collect → transport → store → analyze; only the tools change
  • Emit structured JSON (pino or winston) to stdout, and pick log levels by action needed
  • A correlation ID per request is the thread that reconstructs a story across services
  • ELK, Loki, and CloudWatch trade search power against cost and ops — set retention and never log secrets or PII

📚 Additional Resources

🚀 What's Next?

You can now measure your system (metrics) and understand it (logs). The final piece is being told when something breaks — without drowning in noise. Next up: Alerting Systems, where you'll set thresholds on symptoms and SLOs, build escalation paths, and design alerts people actually trust.

🎉 One query, every answer.

Structured, correlated, centralized logs turn a three-day investigation into a three-minute search.