Monitoring, logs and alerts
Add structured logs, Prometheus metrics and an alert rule to a FastAPI service, run Prometheus and Grafana with Docker Compose, and learn the golden signals, percentiles and how to write alerts people will trust.
In this tutorial you will add the three pillars of observability to a small FastAPI service: structured logs, metrics and an alert. You will run the app, Prometheus (which collects metrics) and Grafana (which draws graphs) with Docker Compose, create some failures on purpose, and see them on a dashboard and in a firing alert rule.
Prerequisites: Docker with Compose (see *Docker: images and containers* and *Docker Compose for multi-service apps*) and basic Python. Everything runs on your laptop. Port numbers used: 8000 (API), 9090 (Prometheus), 3000 (Grafana).
Monitoring versus observability
Monitoring means watching known signals and getting told when they look wrong ("CPU above 90%"). Observability means your system gives out enough information that you can work out *why* something is wrong, even for a problem you did not predict.
Imagine a doctor. A thermometer reading is monitoring. Blood tests, scans and the patient's history are what let the doctor explain the fever. In software the three kinds of evidence are:
The three pillars
| Pillar | What it is | Good for | Tool examples |
|---|---|---|---|
| Logs | Time-stamped events written by your code | Detail: what happened to this one request | Loki, Elasticsearch/OpenSearch, CloudWatch Logs |
| Metrics | Numbers measured over time | Trends and alerts: how many, how slow, how full | Prometheus, CloudWatch, Datadog |
| Traces | The path of one request across many services | Finding which service in a chain is slow | OpenTelemetry with Jaeger or Tempo |
This lesson builds logs and metrics by hand. Traces matter once you have several services; the standard to learn is OpenTelemetry, which works with all the common back ends.
What to measure: the four golden signals
Google's SRE book recommends four signals for any user-facing service. If you only measure four things, measure these:
The four golden signals
| Signal | Question | Example for the Brew & Bean API |
|---|---|---|
| Latency | How long do requests take? | 95% of POST /orders finish in under 300 ms |
| Traffic | How much demand is there? | Requests per second |
| Errors | How many requests fail? | Share of responses with status 5xx |
| Saturation | How full is the system? | CPU, memory, database connections, queue length |
Averages hide pain. If 99 requests take 100 ms and one takes 10 seconds, the average looks fine but one customer is miserable. Use percentiles: p95 means 95% of requests were faster than this value.
Step 1: an app that logs and exposes metrics
Create a folder observe-demo with the files below. The app has a normal endpoint, a deliberately flaky one that fails about 30% of the time, and a /metrics endpoint for Prometheus.
fastapi
uvicorn
prometheus-clientimport json
import logging
import random
import time
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
class JsonFormatter(logging.Formatter):
"""Write each log line as one JSON object."""
def format(self, record):
data = {
"time": self.formatTime(record),
"level": record.levelname,
"message": record.getMessage(),
}
data.update(getattr(record, "fields", {}))
return json.dumps(data)
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
log = logging.getLogger("brew-api")
log.addHandler(handler)
log.setLevel(logging.INFO)
REQUESTS = Counter("http_requests_total", "Total HTTP requests", ["method", "path", "status"])
LATENCY = Histogram("http_request_duration_seconds", "Request duration in seconds", ["path"])
app = FastAPI()
@app.middleware("http")
async def observe(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
# Use the route template (/menu), never the raw URL, to keep label values few.
route = request.scope.get("route")
path = route.path if route else "unmatched"
REQUESTS.labels(request.method, path, str(response.status_code)).inc()
LATENCY.labels(path).observe(duration)
log.info(
"request handled",
extra={"fields": {
"method": request.method,
"path": request.url.path,
"status": response.status_code,
"duration_ms": round(duration * 1000, 1),
}},
)
return response
@app.get("/menu")
def menu():
time.sleep(random.uniform(0.01, 0.15))
return [{"id": "espresso", "name": "Espresso", "price": 120}]
@app.get("/flaky")
def flaky():
time.sleep(random.uniform(0.01, 0.3))
if random.random() < 0.3:
return JSONResponse({"error": "kitchen is overloaded"}, status_code=500)
return {"ok": True}
@app.get("/metrics")
def metrics():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)The middleware runs for every request. It records a counter (how many, by status) and a histogram (how long), and writes one JSON log line. Putting /metrics behind a normal route keeps this demo simple; in production, do not expose it publicly.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]Step 2: Prometheus and Grafana
Prometheus works by pulling: every few seconds it visits /metrics on each target and stores the numbers. Create its configuration and an alert rule.
global:
scrape_interval: 5s
rule_files:
- alerts.yml
scrape_configs:
- job_name: brew-api
static_configs:
- targets: ["api:8000"]api is the Compose service name, which works as a host name on the Compose network. A 5 second interval is for the demo; 15 to 60 seconds is typical.
groups:
- name: brew-api
rules:
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{status=~"5.."}[1m]))
/
sum(rate(http_requests_total[1m])) > 0.05
for: 1m
labels:
severity: page
annotations:
summary: "More than 5% of requests are failing"rate(...[1m]) turns an ever-growing counter into requests per second over the last minute. for: 1m means the condition must hold for a full minute before the alert fires, which avoids paging someone for a blip.
services:
api:
build: .
ports:
- "8000:8000"
prometheus:
image: prom/prometheus
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./alerts.yml:/etc/prometheus/alerts.yml:ro
ports:
- "9090:9090"
grafana:
image: grafana/grafana
ports:
- "3000:3000"Pin image versions (for example prom/prometheus:v3.0.0 style tags, checking Docker Hub for current ones) for anything beyond a demo. Without pinning you get the latest image.
Start everything
- 1Run
docker compose up --buildinsideobserve-demo. - 2Open
http://localhost:8000/menu. You should see the JSON, and in the terminal a JSON log line with method, path, status and duration. - 3Open
http://localhost:8000/metrics. You will see lines such ashttp_requests_total{method="GET",path="/menu",status="200"} 1.0. - 4Open
http://localhost:9090/targets. Thebrew-apitarget should showUP.
{"time": "2026-10-31 10:15:02,114", "level": "INFO", "message": "request handled", "method": "GET", "path": "/menu", "status": 200, "duration_ms": 87.3}Structured logs
JSON logs look noisy to humans but are easy for tools to search and filter (status >= 500, duration_ms > 1000). Add a request id to each line so you can follow one request across services. Never log passwords, tokens or full phone numbers.
Step 3: make some traffic and some failures
for i in $(seq 1 300); do curl -s -o /dev/null http://localhost:8000/flaky; curl -s -o /dev/null http://localhost:8000/menu; doneOn Windows PowerShell use: 1..300 | ForEach-Object { curl.exe -s -o NUL http://localhost:8000/flaky; curl.exe -s -o NUL http://localhost:8000/menu }
In the Prometheus UI (http://localhost:9090/query) try these queries. This small query language is called PromQL.
Golden signals as PromQL
| Query | Meaning |
|---|---|
sum(rate(http_requests_total[1m])) | Traffic: requests per second |
sum(rate(http_requests_total{status=~"5.."}[1m])) / sum(rate(http_requests_total[1m])) | Errors: fraction of failing requests |
histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[1m]))) | Latency: p95 in seconds |
Keep sending traffic for over a minute, then open http://localhost:9090/alerts. HighErrorRate should move from inactive to pending to firing, because about 30% of /flaky calls fail.
Step 4: a dashboard in Grafana
Build three panels
- 1Open
http://localhost:3000. Sign in withadminandadmin. Grafana asks you to set a new password; do so. - 2Go to Connections, Data sources, add a Prometheus data source and set the URL to
http://prometheus:9090. Click Save and test. - 3Create a new dashboard and add a visualisation using that data source. Paste the traffic query, then add two more panels for the error fraction and the p95 latency.
- 4Set the time range to the last 15 minutes and refresh every 5 seconds. Run the traffic loop again and watch the graphs move.
Local demo only
The Grafana login admin/admin and the open ports are fine on a laptop only. Never expose Grafana, Prometheus or /metrics to the internet without authentication.
From alert to action
An alert in Prometheus is only a rule being true. In a real set-up Alertmanager (or Grafana alerting, or a cloud service) sends it to Slack, email or a paging tool, and someone responds.
Good alerts follow a few rules. Alert on symptoms users feel (errors, slowness) rather than every cause (a CPU spike that nobody notices). Every alert must need a human to act; if it is routinely ignored, delete or fix it, because alert fatigue makes people ignore the one that matters. Each alert should link to a runbook: a short page saying what it means and what to check first. Link this to the next lesson, *Reliability: SLOs, incidents and postmortems*, which explains how to choose alert thresholds from reliability targets.
On the cloud
You do not have to run Prometheus yourself. AWS has CloudWatch (logs, metrics, alarms), Azure has Azure Monitor and Application Insights, and Google Cloud has Cloud Monitoring and Cloud Logging. Managed Prometheus and Grafana services also exist. The ideas are identical: structured logs, the golden signals and symptom-based alerts. Watch the cost: log ingestion and custom metrics are billed, so do not log every debug line in production forever.
You are an on-call SRE. Below are JSON log lines from my service around the time errors began. Find the pattern: which paths and status codes are failing, when did it start, and what is the most likely cause?
Give me (1) a summary in three sentences, (2) the three next checks I should do, in order, and (3) one metric or alert I should add so I would have been told earlier. Do not guess about things the logs do not show; say what is unknown.
<logs>
[paste 30 to 100 log lines here, with secrets removed]
</logs>What to expect: A good answer groups lines by path and status, points to a time the pattern changed and clearly separates evidence from guesses.
How this comes up in interviews
Typical questions: What is the difference between logs, metrics and traces? What are the golden signals? Why use p95 or p99 instead of the average? What makes a good alert? How would you find out why the site is slow? A strong answer starts from the user symptom, checks the dashboard (traffic, errors, latency, saturation), narrows by service, then reads logs or traces for the exact requests.
Try it: Add a business metric
Add a counter orders_created_total to the app and a POST /orders endpoint that increments it. Send ten orders with curl and graph rate(orders_created_total[1m]) in Grafana. Then add an alert that fires if no orders are created for 5 minutes during opening hours (hint: the metric must exist for that to work).
Key takeaways
- Observability is having enough signals (logs, metrics, traces) to explain why a system misbehaves, not only that it does.
- Measure the four golden signals: latency, traffic, errors and saturation.
- Use percentiles (p95, p99), because averages hide slow requests.
- Write structured JSON logs without secrets, and keep metric labels low in number (use route templates, never raw ids).
- Prometheus pulls
/metrics, Grafana draws graphs, and alert rules turn symptoms into notifications. - Alert on user-visible symptoms, make every alert actionable, and link a runbook to it.
Check yourself
Finishing the quiz marks the lesson as done.
1. Why is p95 latency often more useful than average latency?
2. Which of these is a metric rather than a log?
3. Why does the alert rule use for: 1m?
4. What is wrong with using the raw URL (like /orders/8123) as a metric label?
5. Which is the best alert?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.