Week 13: Observability: Monitoring, Logging & Alerting

The Horizontal Pod Autoscaler in Week 12 made a scaling decision from a single CPU number — this week is about generating and reading the far richer signal your own app can expose: custom metrics, structured logs, and alerts tuned to actually be worth waking someone up for. You'll instrument an app with Prometheus client libraries, build a Grafana dashboard on top of that data, replace scattered docker logs/kubectl logs calls with centralized structured logging, and write alerting rules that distinguish a real incident from noise. Everything here becomes the evidence you reach for during incident response, and the NetworkPolicies and least-privilege habits from Week 14 rely on the same logs and metrics to prove a security control is actually working.

Module 10 of 22 Week 13 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Instrument an app with Prometheus metrics and query them with PromQL
  • Build a Grafana dashboard and ship structured logs to a central store
  • Write alerting rules that page on symptoms, not noise, and avoid alert fatigue

1. Prometheus Metrics & Instrumentation

Prometheus is a time-series metrics database that works by pulling — it scrapes an HTTP endpoint (conventionally /metrics) on your app at a regular interval, rather than your app pushing data out. Your job as the app owner is exposing that endpoint; a Prometheus client library does the bookkeeping.

app.py — instrumenting a Flask app
from prometheus_client import Counter, Histogram, generate_latest
from flask import Flask, Response, request
import time

app = Flask(__name__)

REQUEST_COUNT = Counter(
    "http_requests_total", "Total HTTP requests",
    ["method", "path", "status"]
)
REQUEST_LATENCY = Histogram(
    "http_request_duration_seconds", "Request latency in seconds",
    ["path"]
)

@app.before_request
def start_timer():
    request.start_time = time.time()

@app.after_request
def record_metrics(response):
    latency = time.time() - request.start_time
    REQUEST_LATENCY.labels(path=request.path).observe(latency)
    REQUEST_COUNT.labels(
        method=request.method, path=request.path, status=response.status_code
    ).inc()
    return response

@app.route("/metrics")
def metrics():
    return Response(generate_latest(), mimetype="text/plain")

Prometheus has four core metric types: Counter (only goes up — request totals, error totals), Gauge (goes up or down — queue depth, active connections), Histogram (buckets observations — request latency, response size), and Summary (similar to Histogram but computes quantiles client-side). Almost every app metric you'll write fits a Counter or a Histogram.

prometheus.yml — scrape config
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: "web-app"
    kubernetes_sd_configs:
      - role: pod
    relabel_configs:
      - source_labels: [__meta_kubernetes_pod_label_app]
        action: keep
        regex: web
      - source_labels: [__address__]
        action: replace
        regex: (.+):\d+
        replacement: ${1}:8080
        target_label: __address__

Once data is flowing, PromQL is how you query it. The single most useful pattern you'll reuse constantly is rate() over a Counter, which converts a monotonically increasing total into a per-second rate over a time window:

PromQL
# requests per second, per path, averaged over the last 5 minutes
rate(http_requests_total[5m])

# error rate as a percentage of total traffic
sum(rate(http_requests_total{status=~"5.."}[5m]))
  /
sum(rate(http_requests_total[5m])) * 100

# 95th percentile request latency
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))
Never put unbounded values in a label

A label like user_id or a raw request path with an ID in it (/orders/48213) creates a new time series per unique value — Prometheus calls this "cardinality explosion," and it can silently take down a Prometheus server's memory. Only put low-cardinality values (route templates like /orders/:id, status codes, methods) in labels.

2. Grafana Dashboards

Prometheus stores and queries data; Grafana visualizes it. You point Grafana at Prometheus as a data source, then build panels — each one backed by a PromQL query — and arrange them into a dashboard.

terminal — run Prometheus + Grafana locally with Docker Compose
docker compose up -d prometheus grafana
# Prometheus: http://localhost:9090
# Grafana:    http://localhost:3000  (default admin/admin, change on first login)

In Grafana, add Prometheus as a data source (http://prometheus:9090 if they're on the same Compose network), then build a panel with a query like the latency one above. A first real dashboard for any HTTP service should cover what's often called the RED method: Rate (requests/sec), Errors (error rate), Duration (latency percentiles) — three panels that answer "is this service healthy right now" faster than almost anything else you could build.

dashboard.json (excerpt — a single panel definition)
{
  "title": "p95 Latency",
  "type": "timeseries",
  "targets": [
    {
      "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))",
      "legendFormat": "{{path}}"
    }
  ],
  "fieldConfig": {
    "defaults": { "unit": "s" }
  }
}

Dashboards built by clicking through the UI are hard to reproduce and easy to lose. Export a dashboard as JSON (Dashboard settings → JSON Model) and commit it to your repository — the same "config as a reviewable file" principle you've applied to Terraform and Kubernetes manifests all course long applies to dashboards too.

Provision dashboards, don't recreate them by hand

Grafana can load dashboard JSON and data source config from files at startup (a provisioning/ directory), so a fresh Grafana instance comes up with your dashboards already in place — no manual re-clicking after every environment rebuild.

3. Centralized Structured Logging

docker logs and kubectl logs work fine for one container on one machine you already know is misbehaving. They fall apart the moment you have multiple replicas across multiple nodes: the request you're chasing could have hit any of them, and a Pod's logs disappear the moment it's rescheduled. Centralized logging solves this by shipping every container's logs to one searchable store.

terminal — a common local stack (Loki + Promtail + Grafana)
docker compose up -d loki promtail grafana
# Promtail tails container log files and ships them to Loki
# Grafana queries Loki the same way it queries Prometheus, via LogQL

Shipping logs anywhere is only half the fix, though. Plain-text lines like User 4821 logged in at 14:32 are easy for a human to read once, but impossible to reliably filter or aggregate across millions of lines. Structured logging — emitting each log line as JSON with consistent field names — makes logs queryable the same way a database table is:

structured log output
{"timestamp":"2026-08-03T14:32:07Z","level":"info","event":"user_login","user_id":4821,"latency_ms":42,"trace_id":"a1b2c3d4"}
{"timestamp":"2026-08-03T14:32:11Z","level":"error","event":"payment_failed","order_id":9931,"reason":"card_declined","trace_id":"e5f6a7b8"}
LogQL — querying structured logs in Grafana
{app="web-app"} | json | level="error"
{app="web-app"} | json | event="payment_failed" | rate({app="web-app"}[5m])

A trace_id field, generated once per request and passed through every log line and downstream service call for that request, is what lets you pull the full story of a single failed request out of millions of interleaved lines from every replica — it's the single highest-leverage field to add to a logging setup.

Never log secrets, even by accident

A structured logger that dumps an entire request object will happily log an Authorization header or a password field verbatim into a system that, unlike a Kubernetes Secret, usually has broad read access and long retention. Explicitly allow-list which fields get logged rather than logging whole objects, and redact known-sensitive keys at the logging layer.

4. Alerting Rules & On-Call Basics

Dashboards are for humans looking at a screen; alerting rules are for the system to tell you something's wrong before a human notices. Prometheus evaluates alerting rules continuously and hands anything that fires to Alertmanager, which handles deduplication, grouping, and routing to a paging tool (PagerDuty, Opsgenie, or a simple Slack webhook for a small team).

alert-rules.yml
groups:
  - name: web-app.rules
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[5m]))
            /
          sum(rate(http_requests_total[5m])) > 0.05
        for: 5m
        labels:
          severity: page
        annotations:
          summary: "web-app error rate above 5% for 5 minutes"
          runbook: "https://wiki.internal/runbooks/web-app-errors"

      - alert: HighLatency
        expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 1
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "p95 latency above 1s for 10 minutes"

The for: 5m clause matters as much as the threshold itself — it requires the condition to stay true for the full duration before firing, which absorbs brief, self-correcting blips instead of paging on every one of them. A rule with no for clause fires on the very first scrape that crosses the threshold, and is a common cause of noisy alerting.

The deeper principle behind good alerting is alert on symptoms users feel, not on causes. "Error rate above 5%" and "p95 latency above 1s" are symptoms — they mean something is actually broken for a real user right now. "CPU usage above 80%" is a cause, and causes are frequently fine: an autoscaler might already be handling it, or a batch job might legitimately spike CPU without anyone being affected. Paging on every possible cause instead of on user-facing symptoms is exactly how teams end up with alert fatigue — so many low-value pages that on-call engineers start ignoring or muting alerts, including the real ones.

Every page should link to a runbook

An alert that fires at 3 a.m. with no runbook annotation forces a half-asleep engineer to reconstruct the diagnosis from scratch every single time. A one-paragraph runbook — "check X dashboard, likely cause is Y, mitigation is Z" — turns a 45-minute incident into a 5-minute one, and it's worth writing before the alert ever fires for real, not during the incident.

5. Hands-on Exercise

Hands-on

Instrument, visualize and alert on a real app

Build the full observability loop end to end: metrics out of your app, a dashboard on top, and an alert that actually fires.

Requirements:

  1. Add a /metrics endpoint to a small app (reuse one from an earlier week) with a request-count Counter and a latency Histogram, labeled by method, path and status code.
  2. Stand up Prometheus and Grafana with Docker Compose, configure Prometheus to scrape your app every 15 seconds, and confirm data with a raw query in the Prometheus UI at http://localhost:9090.
  3. Build a Grafana dashboard with three panels covering the RED method: request rate, error rate, and p95 latency, each built from a real PromQL query.
  4. Convert your app's logging to structured JSON with at least timestamp, level, event, and a generated trace_id per request.
  5. Write a Prometheus alerting rule that fires when the error rate exceeds 5% for 5 minutes, with a summary and runbook annotation.
  6. Force an error condition (e.g. hammer a route that returns 500s with a quick script) and confirm the alert transitions from pending to firing in the Prometheus UI's Alerts tab.
Hint

Write a tiny load-testing loop (for i in $(seq 1 200); do curl -s localhost:8080/fail >/dev/null; done) rather than clicking a route by hand 200 times — you'll reuse this "generate synthetic traffic" pattern constantly when validating alerting rules and dashboards.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why would putting a raw user ID into a Prometheus label be dangerous, even though it seems useful for debugging?

Every distinct label value combination creates a brand-new time series in Prometheus. A label with unbounded, high-cardinality values like user IDs can generate millions of unique series, which drives Prometheus's memory usage up dramatically and can crash it — this is called a cardinality explosion. Debugging detail for a specific user belongs in structured logs (queryable per-event) rather than in metric labels (meant to stay low-cardinality).

Q2

Why does docker logs or kubectl logs stop being sufficient once you have multiple replicas?

Those commands only show logs for one specific container, but a request from a failing user could have landed on any of several replicas across different nodes, and a Pod's logs vanish once it's rescheduled or deleted. Centralized logging ships every container's output to one durable, searchable store so you can find the relevant lines regardless of which replica handled the request.

Q3

An alerting rule has no for clause and fires the instant error rate crosses 5%. What problem does this likely cause?

Without a for duration, the alert fires on the very first scrape that crosses the threshold, including brief, self-correcting blips that resolve themselves within a scrape interval or two. This produces frequent low-value pages, which is one of the main drivers of alert fatigue — on-call engineers start ignoring or muting alerts because most of them turn out not to matter.

Q4

Why is "alert on symptoms, not causes" a better principle than paging whenever CPU usage crosses 80%?

A cause like high CPU usage is often harmless on its own — an autoscaler might already be compensating, or a scheduled batch job might legitimately spike it without affecting any real user. A symptom like elevated error rate or high latency means a user is actually experiencing a problem right now, so alerting on symptoms keeps pages meaningful and actionable, while alerting on every possible cause floods on-call with noise that erodes trust in the alerting system.