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.

Try it — edit and run

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”:

Try it — edit and run

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:

Try it — edit and run

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:

Try it — edit and run

%(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:

Try it — edit and run

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.

Check your understanding
1/7

What's a concrete limitation print() has that the logging module addresses?