Index Ai Academy
Phase 7 · Software engineering · Python in depth

Logging

Loggers, levels, handlers and configuration, without leaking secrets.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

print() is fine while you're writing a script and watching it run. Software that runs on a server at 3 a.m. needs logging: a record of what happened, with times and severity, that goes to files or monitoring systems, and that can be turned up when you're investigating and down when you're not. When something breaks in production, the logs are often the only evidence you have, so writing good ones is part of writing the program.

What you'll be able to do

  • Use module loggers with getLogger(__name__) and pick the right level for each message
  • Send different levels to different places with handlers, formatters and dictConfig
  • Log exceptions with their tracebacks, and write machine-readable JSON lines
  • Keep passwords and personal data out of logs with a filter

What it covers

  1. Loggers and levels
  2. Handlers: where messages go
  3. Logging exceptions
  4. Configuring it all in one place
  5. A filter that hides secrets
  6. What makes a good log line
  7. Worked example: logging in a small job

Loggers and levels

The logging module is in the standard library. Each module gets its own logger, named after the module, and calls one method per level of importance:

import logging
import sys

logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s", force=True)
log = logging.getLogger("shop.orders")

log.debug("cart contents: %s", {"TE-101": 1})
log.info("order %s placed, total $%.2f", 50231, 200)
log.warning("stock for %s is low: %d left", "PA-108", 2)
log.error("payment for order %s was declined", 50232)
log.critical("database unreachable, orders paused")
It runs right here in your browser, with nothing to install.

The levels, from least to most serious: DEBUG (details for investigating), INFO (normal events worth recording), WARNING (something unexpected that the program handled), ERROR (something failed) and CRITICAL (the program can't continue). The configured level is a threshold: at INFO, debug messages are dropped, so the first line printed nothing.

Notice the arguments: log.info("order %s placed", order_id), not an f-string. The message is only built if it will actually be shown, and log tools can group messages by their unformatted text. In real modules, write log = logging.getLogger(__name__) at the top: __name__ is the module's dotted name, so every message says where it came from.

Try it now

Pick the level by who needs to know, and how urgently. Most messages in a healthy program are INFO or DEBUG.

Handlers: where messages go

A logger decides whether a message is recorded; handlers decide where it goes, and each handler can have its own level and format. One message can go to the console and a file at once:

import io
import logging

log = logging.getLogger("shop.alerts")
log.setLevel(logging.DEBUG)
log.handlers.clear()
log.propagate = False

console = logging.StreamHandler(io.StringIO())
console.setLevel(logging.INFO)
console.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))

problems = logging.StreamHandler(io.StringIO())
problems.setLevel(logging.WARNING)
problems.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S"))

log.addHandler(console)
log.addHandler(problems)

log.debug("checking 7 products")
log.info("3 digests ready")
log.warning("CA-129 unavailable")

print("console handler got:\n" + console.stream.getvalue())
print("problems handler got:\n" + problems.stream.getvalue().split(" ", 2)[2])

The logger lets DEBUG through, but each handler applies its own threshold: the console shows INFO and up, the problems log only WARNING and up. In a real program the second handler would be a logging.FileHandler("problems.log") or a service's handler. Formatters choose what each line contains: %(asctime)s the time, %(name)s the logger, %(levelname)s the level.

propagate = False stops messages from also going up to the root logger's handlers. Loggers form a tree by their dotted names, so "shop.alerts" passes its messages to "shop", then to the root, unless told not to.

Quick check
What does this print?
import io, logging
buf = io.StringIO()
log = logging.getLogger("demo.check")
log.handlers.clear(); log.propagate = False
h = logging.StreamHandler(buf); h.setLevel(logging.WARNING)
log.addHandler(h); log.setLevel(logging.INFO)
log.debug("a"); log.info("b"); log.warning("c"); log.error("d")
print(buf.getvalue().split())
  1. ['a', 'b', 'c', 'd']
  2. ['c', 'd']
  3. ['d']
  4. ['b', 'c', 'd']
Show the answer

['c', 'd']

The logger lets INFO and up through, dropping a. The handler then keeps only WARNING and up, so only c and d are written.

Logging exceptions

Inside an except block, log.exception(...) logs at ERROR level and adds the full traceback, which is exactly what you need to find the cause later:

import io
import logging

buf = io.StringIO()
log = logging.getLogger("shop.import")
log.handlers.clear(); log.propagate = False
log.addHandler(logging.StreamHandler(buf))

def import_line(line):
    sku, qty = line.split(",")
    return sku, int(qty)

for line in ["TE-101,2", "PA-108,two"]:
    try:
        import_line(line)
    except ValueError:
        log.exception("couldn't import line %r", line)

text = buf.getvalue()
print(text.splitlines()[0])
print("traceback included:", "Traceback" in text, "| last line:", text.strip().splitlines()[-1])

Log an exception once, where you handle it. Logging it at every level as it travels up the call stack fills the logs with the same traceback five times. And don't log and re-raise and log again higher up: pick the place that can do something about it.

Configuring it all in one place

Configuration belongs at the program's entry point (the main function or the web app's startup), never inside library modules, which should only call getLogger(__name__). logging.config.dictConfig describes the whole setup as data, which can also come from a file:

import logging.config

logging.config.dictConfig({
    "version": 1,
    "formatters": {"short": {"format": "%(levelname)s %(name)s: %(message)s"}},
    "handlers": {
        "console": {"class": "logging.StreamHandler", "level": "INFO", "formatter": "short"},
        "file": {"class": "logging.FileHandler", "filename": "problems.log", "level": "WARNING", "formatter": "short"},
    },
    "root": {"level": "DEBUG", "handlers": ["console", "file"]},
})

Many services collect logs as JSON lines: one JSON object per line, so a search tool can filter by level, logger or any field without parsing free text. A small Formatter subclass does it, as the exercise shows.

Never log secrets or personal data. Passwords, API keys, tokens, card numbers and often email addresses must not reach logs. Logs get copied to many systems and kept for a long time, and more people can read them than can read the database. A filter on a handler can redact anything that slips through, but the first defense is not passing secrets to log calls at all.

Drill

A filter that hides secrets

Write Redact, a logging.Filter subclass whose filter(record) rewrites the message so any password=..., token=... or api_key=... value becomes ***, then returns True. Use record.getMessage() for the full text, then set record.msg to the cleaned text and record.args to ().

import io
import logging
import re


class Redact(logging.Filter):
    def filter(self, record):
        return True


buf = io.StringIO()
log = logging.getLogger("drill.redact")
log.handlers.clear(); log.propagate = False
handler = logging.StreamHandler(buf)
handler.addFilter(Redact())
log.addHandler(handler)
log.warning("login failed for %s with password=%s", "ada", "hunter2")
print(buf.getvalue().strip())
In the lesson, your code is checked as you write it.

What makes a good log line

A log is written for a stranger: you, months from now, at an inconvenient hour, trying to work out what happened. A few habits make that person's job much easier.

Say what happened, to what, with the numbers. "Failed" tells nobody anything. "Payment for order 50232 declined by the bank (code 51)" says which order, what went wrong and where to look next. Include the ids that let someone find the record: order numbers, product codes, request ids.

Log events, not every line. One INFO line when a job starts, one when it finishes with a summary, and a WARNING or ERROR for each problem is usually right. A log that records every loop step is too long to read, and it hides the line that matters.

Keep the format consistent. The same event should always produce the same message template, so you can search for it, count it and set an alert on it. That's another reason to pass values as arguments instead of building the text with an f-string.

Think about volume and cost. Logs are stored, shipped and searched, and all of that costs money. DEBUG is for turning on while investigating, not for leaving on in production. Rotating file handlers (logging.handlers.RotatingFileHandler) cap how much disk a log may use.

Test that you log what matters. pytest's caplog fixture captures log records in a test, so you can check that a failed payment really does produce an ERROR line.

Worked example

Worked example: logging in a small job

A price-check job logs its progress: INFO for normal steps, WARNING for skipped products, and an exception with its traceback when a step fails.

import io
import logging

log_text = io.StringIO()
log = logging.getLogger("jobs.prices")
log.handlers.clear(); log.propagate = False
handler = logging.StreamHandler(log_text)
handler.setFormatter(logging.Formatter("%(levelname)-7s %(name)s: %(message)s"))
log.addHandler(handler)
log.setLevel(logging.INFO)

PRICES = {"TE-107": 356.25, "PA-111": 267.56, "CA-129": None}

def check_prices(targets):
    log.info("checking %d products", len(targets))
    alerts = 0
    for sku, target in targets.items():
        try:
            price = PRICES[sku]
            if price is None:
                log.warning("price for %s unavailable; skipped", sku)
                continue
            if price <= target:
                alerts += 1
                log.info("%s at $%.2f reached the target $%.2f", sku, price, target)
        except KeyError:
            log.exception("unknown product %s", sku)
    log.info("done: %d alert(s)", alerts)
    return alerts

check_prices({"TE-107": 400, "PA-111": 240, "CA-129": 25, "XX-000": 10})
for line in log_text.getvalue().splitlines():
    if not line.startswith(("Traceback", "  ")):
        print(line)
In the lesson you can change this and run it.

The job's logger is named for where it lives, "jobs.prices". Normal progress is INFO; the unavailable price is a WARNING, because the job handled it; the unknown product is logged with log.exception, so its traceback is kept. Every message uses %-style arguments rather than f-strings. Reading the log afterwards tells the story of the run: what it did, what it skipped and what went wrong, which is exactly what you need the morning after.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Why write log.info("order %s placed", order_id) instead of log.info(f"order {order_id} placed")?

  1. The message is only built if it will be shown, and tools can group messages by their template
  2. f-strings don't work in logging
  3. It's required for the message to appear
  4. It hides the order id

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson