Open any application log and you'll see the same kind of message tagged with different labels: DEBUG, INFO, WARNING, ERROR. Why not just write down everything that happens, in one uniform stream? This post looks at what log levels actually do, and at the companion problem every long-running app eventually faces: keeping a log file from growing forever (rotation).
A Log Level Is a Filtering Threshold
Note: logging means recording what happened while a program runs, so it can be reviewed later in a file or on screen.
Python's standard logging module defines five levels:
| Level | Numeric value | Meaning |
|---|---|---|
DEBUG |
10 | Fine-grained detail for tracing exactly what the code did |
INFO |
20 | Normal progress record |
WARNING |
30 | Something unexpected, but processing continues |
ERROR |
40 | An operation actually failed |
CRITICAL |
50 | The application itself can no longer continue |
The key point is that this number isn't just a label — it's a threshold used for filtering. Set logger.setLevel(logging.INFO) and only messages at 20 or above (INFO, WARNING, ERROR, CRITICAL) get written out; anything at DEBUG (10) is silently dropped. In other words, level design isn't only about deciding what to record — it's about being able to dial the visible detail up or down later without touching the code. In normal operation you watch INFO and above; when something goes wrong, you temporarily drop the threshold to DEBUG to see the fine-grained trace.
How This App Actually Configures It
At the top of maintenance_agent.py, log output is routed to two handlers:
_rotating_handler = logging.handlers.RotatingFileHandler(
"maintenance.log",
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding='utf-8'
)
_stream_handler = logging.StreamHandler()
logging.basicConfig(level=logging.INFO, handlers=[_rotating_handler, _stream_handler])
Because the threshold is logging.INFO, DEBUG-level messages produce no output during normal operation. Across the codebase there are 19 logger.debug() calls — statements written to stay quiet by default and only become useful once someone deliberately lowers the threshold during an investigation. That's the practical payoff of level filtering: you can leave detailed diagnostic statements embedded in the code permanently, without needing to add them later, and without them cluttering the log under normal conditions.
What the Actual Level Distribution Reveals
Counting calls across the same codebase gives:
-
INFO: 135 calls -
WARNING: 114 calls -
ERROR: 28 calls -
CRITICAL: 0 calls
INFO dominates because the app processes multiple WordPress sites in sequence during a maintenance run, and each step of that sequence needs a progress record. WARNING is the next largest category, and a good example is in core/alert_utils.py, where send_alert_email() detects incomplete SMTP settings:
if not all([settings.get('smtp_host'), settings.get('smtp_user'), settings.get('to_email')]):
logger.warning(t(
"メール送信設定が不足しているためスキップします。",
"Email settings incomplete. Skipping notification."
))
return False
Notice this uses logger.warning, not logger.error. Even if the notification email can't be sent, the rest of the maintenance run — backups, updates, rollback decisions — can still proceed; nothing has actually failed. ERROR is reserved for cases where an operation genuinely did fail (a WP-CLI update command erroring out, an exception during mail delivery, and similar), which is why it's the smallest of the three at 28 occurrences.
CRITICAL doesn't appear at all, and that's not an oversight — it follows from how the app is designed. Each site is processed independently: if one site's maintenance run fails, that site alone gets rolled back and the run moves on to the next site. There's essentially no scenario where the whole application needs to be treated as unable to continue. On top of that, urgent notifications to the user aren't handled through the log level at all — they go through a separate channel, send_alert_email(). Deciding what gets written to the log and deciding what the user needs to be told are two different concerns, and the second one lives in application logic, not in log-level plumbing.
Rotation: Making "Keep Recording Forever" Safe
Logs get more useful the longer they accumulate, but an unbounded log file will eventually fill the disk. RotatingFileHandler solves this:
_rotating_handler = logging.handlers.RotatingFileHandler(
"maintenance.log",
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding='utf-8'
)
maxBytes=10 * 1024 * 1024 triggers a rotation once the file reaches 10MB; backupCount=5 caps how many rotated generations are kept. When the active file hits the size limit, it's renamed maintenance.log.1 and a fresh empty file takes over. The next time the limit is hit, maintenance.log.1 becomes maintenance.log.2, and so on — once a generation would exceed backupCount, it's deleted.
That fixes the maximum disk footprint at 10MB × (1 + backupCount) — about 60MB in this app's case — no matter how long the process keeps running. For a desktop app meant to stay running over long periods, this single mechanism satisfies two requirements at once: the log can never grow without bound, and a meaningful amount of recent history is still available whenever something needs investigating.
A Second Handler for Logs That Shouldn't Persist
There's one more custom handler in this app, _SiteLogCapture:
class _SiteLogCapture(logging.Handler):
"""Temporarily captures the log output for a single site's run."""
def emit(self, record):
self.lines.append(self.format(record))
Its purpose is entirely different from the rotating file handler. It gets attached to the logger only while one site's maintenance run is in progress, collecting just that run's log lines into an in-memory list. Once the run finishes, that captured text becomes the "execution log" section of the white-label report or notification email, and the handler is detached again.
Two handlers can be attached to the same logger and treat the exact same stream of messages completely differently. The rotating handler exists to preserve the app's history long-term under a fixed size cap; _SiteLogCapture exists to gather one run's output ephemerally, for one specific downstream use, and then discard it. If log levels are the "vertical" filter — how much detail to keep — handler choice is the "horizontal" filter: which audience, and which purpose, a given record ends up serving.
Takeaway
A log level isn't a static classification tag; it's a threshold you can adjust after the fact. DEBUG stays hidden by default, INFO records normal progress, WARNING marks recoverable anomalies, ERROR marks actual failures, and CRITICAL marks a failure the application itself cannot survive — choosing deliberately among them means the resulting distribution of log calls ends up reflecting the application's actual design assumptions. Rotation, meanwhile, is the practical mechanism that lets "keep recording indefinitely" and "don't run out of disk space" coexist, simply by fixing a size and a generation count up front.
This article was originally published by DEV Community and written by Susumu Takahashi.
Read original article on DEV Community