PYTH-260 · Lesson 10 of 12 · 23 min

Monitoring and logging in production

▶ Video coming soon A video version of this lesson is on the way.

Ridgeline's silent Tuesday, when the morning report produced nothing and nobody noticed for three hours, is the problem this lesson solves. Once software runs unattended on machines nobody watches, the only way to know what it is doing is to have it tell you. Production software reports on itself in three ways: logs, which record individual events; metrics, which count and measure over time; and traces, which follow single requests through the system. On top of those sit dashboards for people to look at and alerts that wake someone when something is wrong. Together they are called observability: the ability to understand what a system is doing from the outside.

Logging with the standard library

Python's logging module, in the standard library since 2002, is the foundation. Code creates a logger, usually one per module with logging.getLogger(__name__), and calls methods named after severity levels: debug for detail useful while developing, info for normal events worth recording, warning for something unexpected that the program handled, error for a failure of one operation, and critical for a failure of the whole program. Configuration, done once at start-up, decides which levels are recorded and where they go. Library code should only create loggers and never configure them; configuring is the application's job.

The logging module: levels decide which messages are recorded
import logging, sys

logging.basicConfig(stream=sys.stdout, level=logging.INFO,
                    format="%(levelname)-7s %(name)s: %(message)s")
log = logging.getLogger("ridgeline.report")

log.debug("loaded 412 orders")                   # below INFO: not shown
log.info("morning report started for %s", "2026-11-12")
log.warning("supplier price feed is 2 days old; using cached prices")
try:
    1 / 0
except ZeroDivisionError as exc:
    log.error("could not compute average order value: %s", exc)   # log.exception would add the traceback
log.info("report written: %d lines", 38)
Output
INFO    ridgeline.report: morning report started for 2026-11-12
WARNING ridgeline.report: supplier price feed is 2 days old; using cached prices
ERROR   ridgeline.report: could not compute average order value: division by zero
INFO    ridgeline.report: report written: 38 lines

Two details in the example are worth copying. Messages use %s placeholders with separate arguments rather than f-strings, so the text is only built if the message will actually be recorded, and so tools can group messages by their template. And log.exception, called inside an except block, logs at error level and adds the full traceback, which is what anyone diagnosing the problem will want first.

Logs as structured events

In containers, logs go to standard output, and the platform collects them into a central log service where they can be searched (lesson 6). Plain sentences are pleasant to read but hard to search: finding every slow request for one cafe means writing fragile text patterns. Structured logging writes each event as a machine-readable record, usually one JSON object per line, with consistent field names. The log service can then filter by any field: level is error, customer is Bean There Cafe, duration over a second.

Structured logging: JSON lines that a log service (or a few lines of Python) can filter by field
import json, logging, sys, io

class JsonFormatter(logging.Formatter):
    """One JSON object per line, with any extra fields passed through."""
    def format(self, record):
        event = {"level": record.levelname.lower(), "logger": record.name, "message": record.getMessage()}
        for key in ("request_id", "customer", "path", "status", "ms"):
            if hasattr(record, key):
                event[key] = getattr(record, key)
        return json.dumps(event)

stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(JsonFormatter())
log = logging.getLogger("ridgeline.web")
log.addHandler(handler)
log.setLevel(logging.INFO)
log.propagate = False

log.info("order placed", extra={"request_id": "r-7f3a", "customer": "Bean There Cafe", "path": "/orders", "status": 201, "ms": 84})
log.warning("slow request", extra={"request_id": "r-7f3b", "path": "/stock", "status": 200, "ms": 1450})
for line in stream.getvalue().splitlines():
    print(line)
slow = [json.loads(l) for l in stream.getvalue().splitlines() if json.loads(l).get("ms", 0) > 1000]
print("slow requests:", [e["request_id"] for e in slow])
Output
{"level": "info", "logger": "ridgeline.web", "message": "order placed", "request_id": "r-7f3a", "customer": "Bean There Cafe", "path": "/orders", "status": 201, "ms": 84}
{"level": "warning", "logger": "ridgeline.web", "message": "slow request", "request_id": "r-7f3b", "path": "/stock", "status": 200, "ms": 1450}
slow requests: ['r-7f3b']

A request ID ties together every log line produced while handling one request, even across several services. The load balancer or the first service generates an ID, passes it along in a header, and every log line includes it. When a cafe reports an error, the ID shown on the error page leads straight to every relevant line.

Logs need care about what goes in them. Never log passwords, API keys or session tokens (lesson 7's redaction applies), and think hard before logging personal information such as names, emails and addresses: privacy laws apply to logs too, and logs are often kept longer and shared more widely than databases. Log identifiers instead of personal details where you can, and set a retention period so old logs are deleted.

Metrics: counting and measuring

Logs answer questions about particular events. Metrics answer questions about the system as a whole over time: how many requests per minute, what share failed, how long they took, how full the disk is. A metric is a number sampled or counted repeatedly and stored as a time series. The two most common kinds are counters, which only go up (requests served, errors, orders placed), and gauges, which go up and down (memory in use, items in a queue, instances running). Histograms record the distribution of measurements such as durations.

For a service that handles requests, a widely used checklist is RED: Rate (requests per second), Errors (failed requests per second, or the failure share), and Duration (how long requests take). For resources such as machines, disks and databases, the matching checklist is USE: Utilization, Saturation and Errors. Ridgeline's dashboard for the wholesale site shows the RED metrics for each endpoint, plus database connections and container count.

Turning request logs into RED metrics: rate, errors and duration per endpoint
import json

log_lines = [
    '{"path": "/orders", "status": 201, "ms": 84}',
    '{"path": "/stock", "status": 200, "ms": 31}',
    '{"path": "/orders", "status": 500, "ms": 2010}',
    '{"path": "/stock", "status": 200, "ms": 27}',
    '{"path": "/orders", "status": 201, "ms": 95}',
    '{"path": "/stock", "status": 200, "ms": 1450}',
]
window_seconds = 60

stats = {}
for line in log_lines:
    e = json.loads(line)
    s = stats.setdefault(e["path"], {"count": 0, "errors": 0, "ms": []})
    s["count"] += 1
    s["errors"] += e["status"] >= 500
    s["ms"].append(e["ms"])

for path, s in sorted(stats.items()):
    rate = s["count"] / window_seconds
    print(f"{path:<8} rate {rate:.2f}/s  errors {s['errors']}/{s['count']}  slowest {max(s['ms'])} ms")
Output
/orders  rate 0.05/s  errors 1/3  slowest 2010 ms
/stock   rate 0.05/s  errors 0/3  slowest 1450 ms

Percentiles, not averages

Averages hide the experience that matters. If 99 requests take 50 milliseconds and one takes 5 seconds, the average is about 100 milliseconds, which describes nobody's actual experience, and the one customer who waited five seconds is invisible. Duration is therefore reported as percentiles. The 50th percentile, p50 or the median, is the time within which half the requests finished. The 95th percentile, p95, is the time within which 95 in 100 finished; the remaining 5 were slower. p99 shows the worst one in a hundred. Slow requests often come from the busiest customers with the biggest orders, so the tail matters.

A histogram of request durations, most bars clustered between 20 and 120 milliseconds with a long thin tail stretching to 2000 milliseconds. Vertical lines mark the mean at about 140 milliseconds, p50 at 60, p95 at 410 and p99 at 1500. A note says the mean is pulled up by the tail but describes no real request.
Request durations have long tails: percentiles describe them far better than the mean
Percentiles of 100 request durations: the mean suggests all is well, p99 does not
import math

def percentile(values, p):
    """Nearest-rank percentile: the smallest value with at least p in 100 of values at or below it."""
    ordered = sorted(values)
    rank = max(1, math.ceil(p / 100 * len(ordered)))
    return ordered[rank - 1]

durations = [50] * 90 + [120] * 5 + [400, 650, 900, 2400, 5000]
print(f"mean {sum(durations) / len(durations):.0f} ms")
for p in (50, 95, 99):
    print(f"p{p}  {percentile(durations, p)} ms")
Output
mean 144 ms
p50  50 ms
p95  120 ms
p99  2400 ms

Monitoring systems such as Prometheus and the cloud providers' own services compute percentiles from histograms rather than keeping every value, but the meaning is the same.

Service level objectives and alerts

Which numbers deserve an alert? Alerting on everything produces so many notifications that people ignore them, which is worse than no alerts at all. The disciplined approach, popularized by Google's site reliability engineering practice, starts from what customers care about. A service level indicator (SLI) measures it, such as the share of order requests that succeed in under a second. A service level objective (SLO) sets the target, such as 99.5 in 100 over 30 days. The gap between the target and perfection, here 0.5 in 100, is the error budget: the amount of failure the team accepts. Alerts fire when the budget is being spent too fast, not at every blip.

Good alerts are about symptoms customers feel (orders failing, the site slow) rather than causes (CPU at 85 in 100), are actionable (someone can do something now), and point to a runbook, a short page saying what to check and how to fix common causes. Ridgeline has three alerts: order errors above 2 in 100 for five minutes, p95 of order requests above 1.5 seconds for ten minutes, and no successful morning report by 6:30. The third would have caught the silent Tuesday in thirty minutes.

An error-budget burn alert: quiet for normal noise, loud for a real problem
def burn_alert(window, slo_success=0.995, max_burn=10):
    """Alert if errors in this window use the 30-day error budget more than max_burn times too fast."""
    total = sum(w["requests"] for w in window)
    errors = sum(w["errors"] for w in window)
    if total == 0:
        return "no traffic"
    error_rate = errors / total
    budget_rate = 1 - slo_success                  # 0.005 allowed
    burn = error_rate / budget_rate
    return f"ALERT: burning error budget {burn:.1f}x too fast" if burn > max_burn else f"ok (burn {burn:.1f}x)"

quiet = [{"requests": 300, "errors": 1}] * 5
bad_release = [{"requests": 300, "errors": 1}] * 2 + [{"requests": 300, "errors": 45}] * 3
print("normal morning:", burn_alert(quiet))
print("after release: ", burn_alert(bad_release))
Output
normal morning: ok (burn 0.7x)
after release:  ALERT: burning error budget 18.3x too fast

A different kind of alert covers things that should happen but did not. A dead man's switch, or heartbeat check, expects a signal on a schedule: the morning report pings a monitoring service when it finishes, and an alert fires if no ping arrives by 6:30. Absence of output is otherwise invisible, which is exactly how the silent Tuesday went unnoticed.

Tracing

When one request passes through several components, such as the load balancer, the web app, the database and the shipping API, logs and metrics show each piece but not the whole path. Distributed tracing records a trace for a request, made of spans: timed steps such as handle POST /orders, query stock, call shipping API, each linked to its parent. A trace shows at a glance that an order took 1.4 seconds because the shipping API took 1.2 of them. OpenTelemetry is the open standard for producing traces, metrics and logs; its Python packages can instrument Flask, database drivers and HTTP clients automatically, and send the data to many back ends.

The idea of tracing in miniature: nested, timed spans show where a request spent its time (timings vary)
import time
from contextlib import contextmanager

spans = []

@contextmanager
def span(name, parent=None):
    """A tiny tracer: records how long each named step took and what it was part of."""
    start = time.perf_counter()
    try:
        yield name
    finally:
        spans.append((name, parent, (time.perf_counter() - start) * 1000))

with span("POST /orders") as root:
    with span("validate cart", root):
        time.sleep(0.002)
    with span("query stock", root):
        time.sleep(0.004)
    with span("call shipping API", root):
        time.sleep(0.03)

for name, parent, ms in reversed(spans):
    indent = "  " if parent else ""
    print(f"{indent}{name:<20} {ms:6.1f} ms")
Output
POST /orders           36.9 ms
  call shipping API    30.2 ms
  query stock           4.2 ms
  validate cart         2.1 ms

Worked problem

Diagnosing slow orders

At 7:40 the p95 alert fires: order requests are taking 2.3 seconds. Error rates are normal. The dashboard shows CPU at 30 in 100 on every container and database connections normal. What should Jordan look at next, and in what order?

Show solution
  1. Errors are normal and CPU is low, so the app is waiting on something, not overloaded.
  2. Open a few traces for slow order requests: they show which span takes the time. Here, call shipping API takes about 2 seconds in each.
  3. Search the structured logs for the shipping client with ms over 1000: they show timeouts and retries starting at 7:31.
  4. Check the shipping provider's status page: it reports degraded performance.
  5. Short term, the runbook says to switch quote requests to the cached shipping rates; long term, add a tighter timeout and a circuit breaker so a slow partner cannot slow every order.

Answer: Use traces to find the slow step, logs to confirm and date it, then apply the runbook's fallback; the shipping API, not Ridgeline's code, was the cause.

Try it yourself

Try it yourself

Percentiles

Write percentile(values, p) using the nearest-rank method: sort the values, take rank = ceil(p / 100 * n) (at least 1), and return the value at that rank (counting from 1). Raise ValueError for an empty list.

def percentile(values, p):
    return sum(values) / len(values)

Try it yourself

Logs to metrics

Each log line is a JSON string with "path", "status" and "ms". Write summarize(lines) that returns a dictionary keyed by path, each value a dictionary with "count", "errors" (status 500 or above) and "max_ms". Skip lines that are not valid JSON.

import json

def summarize(lines):
    return {}

Try it yourself

A heartbeat check

Scheduled jobs ping when they finish. Write missing_jobs(expected, pings, now) where expected maps job names to a deadline in minutes after midnight, pings maps job names to the minute their latest ping arrived today (absent if none), and now is the current minute. Return a sorted list of jobs whose deadline has passed (now > deadline) without a ping at or before the deadline.

def missing_jobs(expected, pings, now):
    return []

Common mistakes

  • MistakeUsing print for diagnostics in production code.

    InsteadUse the logging module with levels, configured once by the application.

  • MistakeWriting free-text logs that can only be searched with fragile patterns.

    InsteadLog structured JSON events with consistent fields, including a request ID.

  • MistakeLogging passwords, tokens or personal details.

    InsteadRedact secrets, log identifiers instead of personal data, and set a retention period.

  • MistakeReporting average response time.

    InsteadReport percentiles such as p50, p95 and p99; averages hide the slow tail.

  • MistakeAlerting on every metric that looks unusual.

    InsteadAlert on customer-facing symptoms tied to an SLO, with a runbook for each alert.

Summary and key terms

  • Observability combines logs (events), metrics (numbers over time) and traces (the path of one request).
  • Use the logging module with levels; libraries create loggers, applications configure them.
  • Structured JSON logs with request IDs can be searched by field; keep secrets and personal data out.
  • RED (rate, errors, duration) describes services; USE describes resources.
  • Durations are reported as percentiles because averages hide the slow tail.
  • SLIs measure what customers feel, SLOs set targets, and error budgets decide when to alert.
  • Heartbeat checks catch jobs that silently fail to run; tracing shows where a slow request spent its time.
observability
The ability to understand a running system from the data it emits.
structured logging
Writing log events as machine-readable records, such as JSON lines.
request ID
An identifier attached to every log line produced while handling one request.
metric
A number counted or measured repeatedly and stored as a time series.
percentile
The value below which a given share of measurements fall, such as p95.
service level objective
A target for a service level indicator, such as 99.5 in 100 successful orders.
error budget
The amount of failure an SLO allows over its period.
trace
A record of one request's path through a system, made of timed spans.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. 99 requests take 40 ms and one takes 6 seconds. What are the mean and the median?

    Show answer

    The mean is about 100 ms; the median is 40 ms. Neither shows the 6-second request; p99 would.

  2. Why use log.info("sold %d bags", n) rather than an f-string?

    Show answer

    The message is formatted only if it will be recorded, and tools can group messages by template.

  3. An SLO is 99.5 in 100 successful orders over 30 days. What is the error budget?

    Show answer

    0.5 in 100 of orders may fail, 1 in 200.

  4. How would a heartbeat check have changed the silent Tuesday?

    Show answer

    The missing ping would have raised an alert at the deadline, about 6:30, instead of Theo noticing at nine.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz