Intercepting Standard Logging with Loguru
Adopting Loguru in application code takes an afternoon. Getting your dependencies' records into the same stream takes an InterceptHandler — about fifteen lines, most of which exist to solve one problem: keeping the reported source location accurate. This page covers that handler, the propagation setup around it, and the framework-specific cases. It builds on Loguru configuration and sinks, part of the modern Python logging libraries deep dive section.
Prerequisites
pip install "loguru>=0.7.2,<1.0.0"
Implementation
Step 1 — Write the handler. Three responsibilities: translate the level, find the real caller, and forward with exc_info intact.
import logging
import sys
from loguru import logger
class InterceptHandler(logging.Handler):
"""Forward every standard-library record into Loguru."""
def emit(self, record: logging.LogRecord) -> None:
try:
level = logger.level(record.levelname).name # by name, not by number
except ValueError:
level = record.levelno # custom level: use the number
# Walk out of the logging module so Loguru reports the real call site.
frame, depth = sys._getframe(6), 6
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(
level, record.getMessage()
)
The try/except ValueError matters more than it looks. logger.level() raises for a name Loguru does not know, and a project that registered a custom level with logging.addLevelName(25, "AUDIT") will hit it on the first audit record. Falling back to the numeric level keeps that record rather than losing it inside handleError.
Step 2 — Install it on the root logger, once. force=True removes any handlers a library installed at import time, which is the usual source of duplicate output.
logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO, force=True)
Then remove handlers from named loggers that ship with their own, leaving propagation on so their records reach the root:
for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "gunicorn.error", "gunicorn.access"):
logging.getLogger(name).handlers = []
logging.getLogger(name).propagate = True
This is the same handlers-plus-propagation rule that produces duplicates in any root-handler configuration — the version for Django is in logging configuration in Django settings.
Step 3 — Keep level control in the standard library. The handler only sees records that already passed the logger's level check, so noisy dependencies are still quietened the usual way.
logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
logging.getLogger("botocore").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
That split is worth stating plainly: levels stay in logging, formatting and delivery move to Loguru.
Step 4 — Wire the sinks. With interception in place, Loguru owns the output for the whole process.
import sys
from loguru import logger
logger.remove()
logger.add(sys.stdout, serialize=True, level="INFO", enqueue=True, backtrace=False, diagnose=False)
serialize=True emits one JSON object per line, including for intercepted records, so a library warning and an application info line have the same shape. Rotation, retention and compression, if you write files rather than stdout, are covered in Loguru rotation, retention and compression.
Configuration options
| Option | Where | Default | Recommended |
|---|---|---|---|
basicConfig(force=True) |
stdlib | False |
True — removes library handlers |
| Handler placement | root only | — | root; clear named-logger handlers |
propagate |
named loggers | True |
True, with no handlers of their own |
logger.opt(depth=…) |
handler | 0 |
computed by walking frames |
logger.opt(exception=…) |
handler | None |
record.exc_info |
| Level control | stdlib loggers | — | stays in logging |
logger.level(name) |
Loguru | raises | wrapped in try/except ValueError |
Verification
import logging
from loguru import logger
configure() # intercept + sinks from above
logging.getLogger("urllib3.connectionpool").warning("Retrying (Retry(total=2)) after error")
logger.info("order accepted", order_id=8812)
Expected Output:
{"text": "...", "record": {"level": {"name": "WARNING"}, "name": "urllib3.connectionpool", "function": "urlopen", "file": {"name": "connectionpool.py"}, "line": 876, "message": "Retrying (Retry(total=2)) after error"}}
{"text": "...", "record": {"level": {"name": "INFO"}, "name": "__main__", "function": "<module>", "file": {"name": "app.py"}, "line": 12, "message": "order accepted"}}
The first record is the test: file.name is connectionpool.py, not __init__.py, which means the depth calculation is working. That is the assertion worth keeping:
def test_intercepted_records_keep_their_call_site(capsys):
logging.getLogger("urllib3").warning("probe")
out = capsys.readouterr().out
assert "__init__.py" not in out # the depth walk did its job
Common mistakes
Every library record points at logging/__init__.py
Error signature: the source location is identical for every intercepted record.
Root cause: depth was not computed, or was hard-coded for a different Python version.
Remediation: walk back while frame.f_code.co_filename == logging.__file__, and assert on the call site in a test.
Records appear twice
Error signature: each library warning is printed once in Loguru's format and once in the library's own.
Root cause: the named logger kept its own handler while also propagating to the root.
Remediation: clear the handlers on those loggers and leave propagate=True.
A custom level raises inside the handler
Error signature: records at a project-specific level vanish, and stderr shows a ValueError from logger.level.
Root cause: Loguru has no level registered under that name.
Remediation: catch ValueError and pass the numeric level, or register the level with logger.level("AUDIT", no=25) at startup.
Carrying structured fields across the bridge
The intercept handler as written forwards the message and the exception, and drops everything else the record was carrying. That is fine for a library warning and a real loss for your own records, because extra fields are exactly the part worth keeping.
Loguru's equivalent of extra is logger.bind(), so the bridge has to translate one into the other. The reference-set difference used for a standard-library JSON formatter works here too: anything on the record that is not a standard attribute is a field somebody added deliberately.
import logging
from loguru import logger
_RESERVED = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {
"message", "asctime", "taskName",
}
class InterceptHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame, depth = sys._getframe(6), 6
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
extras = {k: v for k, v in record.__dict__.items() if k not in _RESERVED}
logger.bind(**extras).opt(depth=depth, exception=record.exc_info).log(
level, record.getMessage()
)
With serialize=True on the sink, those fields appear under record.extra in the emitted JSON, which is where a query expects them. Without this, a service that migrates to Loguru silently loses every structured field it was previously emitting — and the loss is invisible in development, because the console renderer never showed them prominently anyway.
| Standard library | Loguru | Notes |
|---|---|---|
record.getMessage() |
the message argument | interpolation already done |
record.levelname |
logger.level(name) |
falls back to the number for custom levels |
record.exc_info |
.opt(exception=...) |
preserves the traceback |
extra={...} fields |
logger.bind(**extras) |
needs the reference-set difference |
record.name |
extra["name"] |
Loguru's own name is the calling module |
| Caller location | .opt(depth=...) |
the frame walk above |
The direction nobody expects
Interception moves standard-library records into Loguru. The opposite direction — Loguru records into standard-library handlers — comes up when part of the stack already depends on logging infrastructure: a QueueHandler for non-blocking delivery, an OpenTelemetry logs bridge, a vendor handler.
Loguru supports it because a sink is any callable, so a sink that constructs a LogRecord and hands it to a standard-library logger is a few lines. It is worth knowing that this exists and worth avoiding when possible: records crossing twice pay both translations and lose a little at each. If the standard library owns the delivery infrastructure, the simpler arrangement is usually to keep logging as the front end and use Loguru only where its ergonomics are wanted.
Deciding whether interception is the right move
Interception makes Loguru the whole process's output layer, which is a commitment. Three questions decide whether it is the right one.
Does the ecosystem you depend on assume logging? Django's LOGGING, Celery's setup_logging, gunicorn's logconfig_dict, pytest's caplog and the OpenTelemetry logs bridge are all built around the standard library. Each has a workaround under Loguru, and each workaround is one more thing to maintain.
Do you need dictConfig-style declarative configuration? Loguru is configured in code by design. That is an advantage for a small service and a limitation where operations expects to change a configuration file without a deploy.
Does anything in the stack need to reconfigure logging at runtime? A level change through logging.getLogger(name).setLevel(...) still works after interception, because the standard library is still deciding what to admit — which is convenient, and is also why the level control described elsewhere on this site applies unchanged. What does not work is a runtime change to the output format, since that lives in a Loguru sink and Loguru sinks are added and removed rather than reconfigured. Removing and re-adding a sink at runtime is supported and is a heavier operation than swapping a formatter.
Is the ergonomic gain worth it for your team? Loguru's logger.add() genuinely is simpler than a handler graph, and for a service with two sinks and no unusual requirements that simplicity is the whole argument. The comparison in full is in structlog vs Loguru vs standard library logging.
Related
- Loguru configuration and sinks — the parent guide: sinks, formats, and the handler model.
- Loguru rotation, retention and compression — what the intercepted records land in.
- Taming third-party library loggers — deciding which of those records you want at all.
- Loguru vs structlog for microservices — the equivalent bridge on the structlog side.
- Configuring logging with dictConfig — declaring the handler placement rather than coding it.
Frequently Asked Questions
Why does every intercepted record point at logging/__init__.py?
Because Loguru records the caller by walking the stack, and by default it stops inside the logging module, which is where the call to your handler came from. The fix is the depth argument: count the frames belonging to logging itself and pass that number, so Loguru skips them and reports the application frame that made the original call.
Do I still need to configure levels on standard-library loggers?
Yes. The intercept handler only receives records that already passed the logger's level check, so setting a library's logger to WARNING still suppresses its INFO records before Loguru ever sees them. Level configuration stays in the standard library; formatting and delivery move to Loguru.
Why do some records appear twice after installing the handler?
Because a named logger has both its own handler and propagation to root, where your intercept handler now sits — so the record is handled once by each. Clear the handlers on those named loggers and leave propagation on, which is the same rule that applies to any root-handler setup.
How do custom levels map across?
Loguru looks up the level by name, so a custom standard-library level registered with addLevelName resolves only if a Loguru level of the same name exists. Wrap the lookup in a try/except and fall back to the numeric level, otherwise a single record at a custom level raises inside the handler and is dropped by handleError.