Logging
Why print() stops being enough
print() has been a fine debugging tool for this entire course so far —
but it has real limits the moment code is actually running somewhere
other than your own terminal while you watch it. A few concrete gaps:
- No severity — a genuine error and a routine status update look identical.
- No way to turn it off without deleting or commenting out the line.
- No built-in way to route it elsewhere — a file, a monitoring system — without rewriting every call site by hand.
The logging module (standard library, no install needed) solves all
three.
logging.basicConfig(level=logging.INFO) sets up basic output once, at
the start of a program. logging.getLogger(__name__) is the standard
pattern for getting a logger —
__name__ is the same built-in variable from the modules and imports concept,
which here identifies which module a log message came from, useful the
moment a program has more than one file logging things.
(force=True here just makes sure this demo’s own settings apply even if
you’ve already run another logging demo on this page — every demo on this
page shares one live Python session, and basicConfig normally only
takes effect the very first time it’s called.)
Levels — severity, not just on/off
Every log call has a level, in increasing order of severity:
DEBUG < INFO < WARNING < ERROR < CRITICAL. Setting a level in
basicConfig filters out anything below it — this is the mechanism
that replaces “comment out the print statement”:
Setting level=logging.WARNING means only WARNING and above actually
print — DEBUG and INFO calls stay in the code, ready to turn back on
by changing one line (level=logging.DEBUG), rather than needing to be
found and uncommented one by one. This is the core practical advantage
over print(): the same code can be verbose during development and quiet
in production, controlled by a single setting.
logger.exception() — the bridge back to error handling
Recall
the re-raising pattern from the previous section:
observe a failure, then let it continue. logger.exception() is the real
version of the print() placeholder used there — call it from inside an
except block, and it automatically includes the full traceback in the
log output, not just whatever message you write:
logger.exception("tool call failed") is specifically meant to be called
from inside an except block — it logs at ERROR level and
automatically attaches the traceback of whatever’s currently being
handled, without you having to format or extract it yourself. The bare
raise immediately after still re-raises the original exception, exactly
as before — logging it doesn’t change or suppress it. The message you
pass ("tool call failed") is your own context describing what
operation was happening; the automatically-attached traceback supplies
what actually broke.
Formatting output, and logging to a file
The default output so far — WARNING:__main__:config file missing — is
missing something every real log line needs: a timestamp. basicConfig
accepts a format string to control exactly what each log line includes:
%(asctime)s, %(levelname)s, %(name)s, and %(message)s are
placeholders logging fills in for you — the timestamp, the level, the
logger’s name (from __name__), and the message you passed. This
particular %(...)s syntax is specific to logging’s format strings, not
the same thing as an f-string or the %s-style formatting covered next.
basicConfig also accepts filename, which routes every log call to a
file instead of the terminal — directly relevant for exactly the kind of
pipeline this lesson has been building, where nobody’s necessarily
watching a terminal when it runs:
Reading agent.log back afterward with
the plain file-reading techniques from earlier in this lesson
shows exactly the same formatted lines that would otherwise have gone to
the terminal.
Lazy message formatting
There’s a subtle cost worth knowing about how a log message gets built. Compare these two calls:
import logging
logging.basicConfig(level=logging.WARNING, force=True) # DEBUG is filtered out
logger = logging.getLogger(__name__)
tool_name = "search"
arguments = {"query": "a very long value that's expensive to format into a string"}
logger.debug(f"calling {tool_name} with {arguments}") # the f-string still runs
logger.debug("calling %s with %s", tool_name, arguments) # the formatting is skipped entirely(not run live — both produce no visible output, since DEBUG is filtered; the difference is in what work happens behind the scenes, not what’s printed)
The first call’s f-string is built immediately, the moment that line
executes — Python has to construct the full message string before it can
even hand it to logger.debug(), regardless of whether DEBUG is
actually going to be shown. The second call passes tool_name and
arguments separately, and %s is logging’s own placeholder syntax
(distinct from an f-string) — logging only substitutes them into the
message if the log call is actually going to be emitted, skipping the
formatting work entirely when the level filters it out. This rarely
matters for a short string, but matters a great deal for something
expensive to format — a large object, a big dict — inside a DEBUG call
that’s disabled in normal operation, called frequently in a hot loop.
What's a concrete limitation print() has that the logging module addresses?