Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Failure & Observability  ›  Lesson

Logging

Logging 26 minReplace print with named loggers, levels and handlers — configured once at the edge — so an unattended program says what matters, to the right place
You're building a piece ofQuarry — the black box
This piece — stage_log(): Gives each stage a named logger with a level and a format, and cleans its handler up so nothing is ever printed twice.
Scenario At 03:00 nobody reads the terminal; each Quarry stage now reports through its own named logger, at a level you choose, to wherever the run sends it.
Your task
Build stage_log(stage, events, level): get the logger "quarry." + stage, set it to level, attach a fresh ListHandler (from the starter) formatted as "%(levelname)s %(name)s: %(message)s", log each [level_name, message] pair in events, remove the handler in a finally, and return its captured lines. Example: stage_log("export", [["INFO", "saved 3 rows"], ["DEBUG", "row 1 ok"]], "INFO") → ["INFO quarry.export: saved 3 rows"].

Logging

In short: print answers one question — show this text now, here — and an unattended program needs four more: how important is this (level), who said it (name), where should it go (handler), and how much do I want today (the logger's threshold). You never construct a logger; logging.getLogger(name) always hands back the same object for the same dotted name, and names form a tree that mirrors your package layout. A record below a logger's level is dropped immediately; one that passes is handed to that logger's own handlers, then propagates up to every ancestor's handlers too — the single fact behind almost every "why did this print twice" bug.

One INFO record from quarry.fx.vault: it fires the child's own handler, then propagates up unchecked to the parent, whose handler fires too — the same line printed twice

Give every stage its own named logger

Quarry's nightly run used to talk with print. At 03:00 with nobody watching, it all went into a terminal nobody would scroll, and on the days someone was watching, the useful lines drowned in "row 4127 ok". A logger, a level and a formatter fix all of it:

python

Those five calls are the five levels — DEBUG (10, detail for hunting a bug), INFO (20, normal milestones), WARNING (30, handled but unexpected), ERROR (40, a piece of work didn't get done), CRITICAL (50, the program can't continue) — and a logger's level is a threshold: records below it are dropped before any formatting, before any output:

What does this print?
import logging, sys

log = logging.getLogger("quarry.p1")
log.handlers.clear()
log.propagate = False
out = logging.StreamHandler(sys.stdout)
out.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
log.addHandler(out)

log.setLevel("WARNING")
log.info("loaded 3 rows")
log.error("disk full")

The convention is one line at the top of every module — log = logging.getLogger(__name__), so quarry/vault.py gets the logger "quarry.vault" — and names being dotted means the tree mirrors your package layout. A logger with no level of its own inherits its nearest ancestor's:

python

Set quarry to WARNING and every stage goes quiet; set quarry.vault to DEBUG and you hear everything from the vault, and only the vault, while chasing a bug there.

Stop a vault warning from printing twice

A logger decides whether a record is worth making; everything after is another object's job. A handler decides where it goes (StreamHandler to a stream, FileHandler to a file) and has its own level, so one logger can send everything to a file but only warnings to the console. A formatter decides what it looks like. And the rule that causes duplicate lines: after a logger's own handlers run, the record passes up to its parent's handlers, and so on to the root — without re-checking any ancestor's level, only each handler's own:

Drawing diagram…

Here's that walk in plain Python — the level check happens once, where the record was born, then every ancestor's handlers get a turn:

step through it
1tree = {
2 "": {"level": 30, "handlers": [("console", 30)]},
3 "quarry": {"level": 10, "handlers": [("file", 10)]},
4}
5record_level = 40
6name = "quarry.vault"
7 
8probe = name
9while probe and probe not in tree:
10 probe = probe.rpartition(".")[0]
11fired = []
12if record_level >= tree[probe]["level"]:
13 current = name
14 while True:
15 for handler, threshold in tree.get(current, {}).get("handlers", []):
16 if record_level >= threshold:
17 fired.append(handler)
18 if current == "":
19 break
20 current = current.rpartition(".")[0]
21print(fired)

So when a child and its parent both have a handler, every record is handled twice:

broken — fix it

The vault's message is printed twice. Configure output in one place so each record appears once.

Only the program's entry point should attach handlers or set levels — a library, Quarry's own modules included, should only ever do log = logging.getLogger(__name__) and call log.info(...). For a script, logging.basicConfig(level=..., format=..., force=True) does the whole setup in one call on the root logger.

Log an exception without flooding the log

Pass values as arguments, not pre-formatted into the message — log.debug("row %d parsed: %r", index, row) only builds the text if some handler actually outputs the record, so a million skipped debug calls cost almost nothing. But be precise about what that saves:

What does this print?
import logging

calls = 0

def expensive_summary():
    global calls
    calls += 1
    return "12 stages, 40,000 rows"

log = logging.getLogger("quarry.p2")
log.setLevel("INFO")
log.debug("state: %s", expensive_summary())
print(calls)

Inside an except block, log.exception(...) logs at ERROR and attaches the traceback — the full cause chain from the last lesson:

python

Use log.exception only inside an except block, and log or re-raise, not both — if every layer logs and re-raises, one failure produces five copies of the same traceback. Log it once, at the layer that decides what to do about it.

Traps worth knowing

basicConfig only works once. It does nothing if the root logger already has a handler — call it twice with different settings and the second call is silently ignored; force=True removes the old handlers first. Logging isn't free. At DEBUG, Quarry writes about 600 bytes per row; at 5 million rows a day that's 3,000 MB. To fit a 100 MB budget it can afford 20 bytes per row — so per-row detail belongs at DEBUG, switched off in production, and per-row news at INFO should be a summary instead: one line per batch, plus a WARNING for each row actually skipped.

Quick practice

What does this print?
import logging

log = logging.getLogger("quarry.q1")
log.setLevel(logging.NOTSET)
print(log.getEffectiveLevel() == logging.WARNING)
What does this print?
import logging, sys

logging.getLogger("quarry.q2").setLevel("ERROR")
child = logging.getLogger("quarry.q2.vault")
print(child.getEffectiveLevel() == logging.ERROR, child.level == logging.ERROR)
broken — fix it

Nothing prints. The handler is attached and formatted correctly — the logger itself is silencing the record.


🎯 Your turn

Build stage_log(stage, events, level). The starter gives you ListHandler, a handler that keeps each formatted line in a list instead of printing it.

Get the logger named "quarry." + stage, set it to level, attach a fresh ListHandler formatted as "%(levelname)s %(name)s: %(message)s", and log each [level_name, message] pair in events. Return the captured lines — and make sure the handler is removed afterwards even if logging fails, or the next call to the same stage will print everything twice.

stage_log("export", [["INFO", "saved 3 rows"], ["DEBUG", "row 1 ok"]], "INFO")
# → ["INFO quarry.export: saved 3 rows"]

Press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upDoes this record get through?

Write level_passes(record_level, threshold): both are level names like "INFO". Return True if a record at record_level passes a logger whose level is threshold.

logging
DrillFormat a record by hand

Write render(fmt, name, level, msg, args): build a logging.LogRecord for logger name at level level (a name like "INFO") with message template msg and arguments args (a list), and return what logging.Formatter(fmt) makes of it. Use any path and line number.

loggingformatting
BuildWho inherits which level?

configured maps logger names to the level set on them (the root logger is ""). Write effective_levels(configured, names) returning {name: effective level} for each name: its own level if configured, else its nearest configured ancestor's, else "WARNING" (the root's default). Ancestors are found by dropping the last dotted part.

logging
BossFollow one record through the tree

tree describes a logging setup: each key is a logger name ("" is root) mapping to {"level": optional, "handlers": [[handler name, handler level]], "propagate": optional, default true}. Write route(name, level, tree) returning the handler names that output a record logged at level on logger name, in order. The record is dropped at once if it is below the logger's effective level (inherited as in the last exercise, default "WARNING"). Otherwise walk from the logger up to the root: each handler outputs it if the record's level is at least the handler's; stop after a logger whose propagate is false. Ancestor loggers' own levels are not checked.

logging
CapstoneA batch that leaves a record

Write logged_batch(rows). Use the logger "quarry.loader" at DEBUG with a RecordList handler from the starter. For each row, try int(row): on ValueError log a warning "row %d skipped: %r"; on any other exception use log.exception("row %d crashed", i); otherwise count it. Finish with log.info("loaded %d of %d rows", loaded, len(rows)). Remove the handler in a finally, and return [[levelname, message, has_traceback] ...] for every record captured.

loggingtracebacksexceptions
ExplainWhy Quarry logs instead of printing

A teammate asks why Quarry's stages call log.info(...) and log.warning(...) instead of print(...), since both put text on the screen. Explain what logging gives an unattended program that print doesn't.

stage_log.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
stage_log(stage, events, level) → listLog a stage's events through a named logger at a chosen level and return the lines that got through.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.