Traceback not displayed when using `RichHandler` with Loguru
Description
When using RichHandler (from rich.logging) as a Loguru sink, tracebacks are not rendered as expected. Only the error message is displayed.
Environment
rich: v14.1.0
loguru: v0.7.3
Run the following minimal reproducible example:
from loguru import logger as logger1
import logging
from rich.logging import RichHandler
logger1.remove()
logger1.add(
RichHandler(
show_time=True,
show_level=True,
show_path=True,
enable_link_path=True,
log_time_format="[%Y-%m-%d %H:%M:%S]",
rich_tracebacks=True,
tracebacks_show_locals=True,
),
format=lambda _: "{message}",
level="DEBUG",
enqueue=True,
backtrace=False,
diagnose=False,
colorize=False,
)
logging.basicConfig(
level="DEBUG",
format="%(message)s",
datefmt="[%Y-%m-%d %H:%M:%S]",
handlers=[
RichHandler(
rich_tracebacks=True,
tracebacks_show_locals=True,
),
],
)
logger2 = logging.getLogger("rich")
def main(logger):
try:
logger.info("logging an info message")
x = 2 / 0
except Exception as e:
try:
raise Exception("This is an exception") from e
except Exception as se:
logger.exception(se)
Calling main(logger2) (the standard logging version) correctly displays a rich-formatted traceback.
Log
[2025-10-24 11:19:59] INFO logging an info message test.py:40
ERROR This is an exception test.py:46
╭──────────────────────────────────────────────────────── Traceback (most recent call last) ─────────────────────────────────────────────────────────╮
│ test.py:41 in main │
│ │
│ 38 def main(logger): ╭──────────────────── locals ────────────────────╮ │
│ 39 │ try: │ e = ZeroDivisionError('division by zero') │ │
│ 40 │ │ logger.info("logging an info message") │ logger = <Logger rich (DEBUG)> │ │
│ ❱ 41 │ │ x = 2 / 0 │ se = Exception('This is an exception') │ │
│ 42 │ except Exception as e: ╰────────────────────────────────────────────────╯ │
│ 43 │ │ try: │
│ 44 │ │ │ raise Exception("This is an exception") from e │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
ZeroDivisionError: division by zero
The above exception was the direct cause of the following exception:
╭──────────────────────────────────────────────────────── Traceback (most recent call last) ─────────────────────────────────────────────────────────╮
│ test.py:44 in main │
│ │
│ 41 │ │ x = 2 / 0 ╭──────────────────── locals ────────────────────╮ │
│ 42 │ except Exception as e: │ e = ZeroDivisionError('division by zero') │ │
│ 43 │ │ try: │ logger = <Logger rich (DEBUG)> │ │
│ ❱ 44 │ │ │ raise Exception("This is an exception") from e │ se = Exception('This is an exception') │ │
│ 45 │ │ except Exception as se: ╰────────────────────────────────────────────────╯ │
│ 46 │ │ │ logger.exception(se) │
│ 47 │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Exception: This is an exception
But main(logger1) , i.e. loguru emits
[2025-10-24 11:22:17] INFO logging an info message test.py:40
ERROR This is an exception test.py:46
Exception: This is an exception
RichHandler subclasses logging.Handler, which means it is wrapped by Loguru’s StandardSink. It appears the issue may stem from Loguru’s formatter not passing exception info in the expected structure for RichHandler to render rich tracebacks, but I couldn’t confirm this.
Hi.
This is because of enqueue=True. When this parameter is enabled, each log message need to be serialized before being sent through a Pipe to the main process (where logs are processed in a multiprocessing-safe way). Unfortunately, it's a known Python limitation that tracebacks are not picklable. This explains why they are not pretty-formatted by the RichHandler: when enqueue=True Loguru removes them from the log record.
Thanks for the explanation! Just to clarify - in my case, I’m using enqueue=True not for multiprocessing but to process logs in a separate thread. Would the same limitation apply in a single-process, multithreaded scenario, or is it specific to cross-process serialization?
Also, is there a recommended way to wrap the RichHandler in a new event loop or thread so that tracebacks can still be rendered correctly?
Yes, unfortunately this limitation still applies in your case, as the enqueue=True option is implemented generically and always routes logs through a Pipe, even though this isn't technically required for a single-process application.
Loguru's handlers circumvent this problem by formatting the traceback before sending it through the Pipe. This does not apply to standard logging.Handler since Loguru can't easily control the formatting. In particular, the RichHandler formats the traceback within the emit() method which obviously must occur in the background thread, thus after serialization.
Sorry, I didn't mention it earlier, but there are indeed several possible workarounds.
First, since you do not need the multiprocessing safety guarantees of enqueue=True, you could setup the logger around the built-in logging.QueueListener which will run a background thread for you. It is supposed to be used in conjunction with logging.QueueHandler, but the QueueHandler strips away the traceback (just like Loguru), so you'll need to subclass it:
Code (click to expand)
from queue import SimpleQueue
from rich.logging import RichHandler
from loguru import logger
class SimpleQueueHandler(logging.handlers.QueueHandler):
def prepare(self, record):
# Send the record as-is in the queue (preserve the traceback).
return record
rich_handler = RichHandler(
show_time=True,
show_level=True,
show_path=True,
enable_link_path=True,
log_time_format="[%Y-%m-%d %H:%M:%S]",
rich_tracebacks=True,
tracebacks_show_locals=True,
)
log_queue = SimpleQueue()
queue_handler = SimpleQueueHandler(log_queue)
listener = logging.handlers.QueueListener(log_queue, rich_handler)
listener.start()
logger.remove()
logger.add(
queue_handler,
format=lambda _: "{message}",
level="DEBUG",
enqueue=False, # Turned off.
backtrace=False,
diagnose=False,
colorize=False,
)
try:
logger.info("logging an info message")
x = 2 / 0
except Exception as e:
try:
raise Exception("This is an exception") from e
except Exception as se:
logger.exception(se)
listener.stop()
Another approach would be to manually pre-format the traceback and store it into the extra record dict, before it's serialized:
Code (click to expand)
import logging
import logging.handlers
from queue import SimpleQueue
from rich.logging import RichHandler
from loguru import logger
from rich.traceback import Traceback
from rich.console import Console
from io import StringIO
rich_handler = RichHandler(
show_time=True,
show_level=True,
show_path=True,
enable_link_path=True,
log_time_format="[%Y-%m-%d %H:%M:%S]",
rich_tracebacks=False, # Turned off.
tracebacks_show_locals=True,
)
rich_handler.terminator = ""
def patcher(record):
if record["exception"]:
tb = Traceback.from_exception(*record["exception"], show_locals=True)
buffer = StringIO()
Console(file=buffer, record=True).print(tb)
record["extra"]["rich_tb"] = "\n" + buffer.getvalue()
logger.configure(patcher=patcher, extra={"rich_tb": ""})
logger.remove()
logger.add(
rich_handler,
format=lambda _: "{message}{extra[rich_tb]}",
level="DEBUG",
enqueue=True, # Can be turned on.
backtrace=False,
diagnose=False,
colorize=False,
)
try:
logger.info("logging an info message")
x = 2 / 0
except Exception as e:
try:
raise Exception("This is an exception") from e
except Exception as se:
logger.exception(se)
logger.complete()
None of these solutions are very satisfactory, in my opinion. However, even without involving Loguru, users of RichHandler are a bit limited since the traceback can't be pre-formatted, meaning they won't show up when using builtin QueueHandler for multiprocessing.
Thank you very much for the snippets. I think the first approach is good for me. Maybe this could be a nice feature addition that can be abstracted away in Loguru. Something that would still allow processing logs in a queue by a background thread but would not default to routing through a Pipe. One idea could be to have a default pipe=True parameter, so that this does not become a breaking change, but that's more of a design consideration, since this behaviour only applies to instances of logging.Handler as you mentioned.
I agree with your sentiment, which is why I don't really like the proposed workarounds. Logging messages "asynchronously", in a background thread, should be an out-of-the-box feature of Loguru. And actually it is, because this is notably what enqueue=True is advertised for. In practice, it works fine most of the time, except in the specific case of a handler with custom exception formatting.
Adding a new parameter to handle such corner case is another possibility, but not my favorite solution. See, it extends the API for a very specific use case because of low-level technical limitations. From an user experience perspective, differences between enqueue=True and pipe=True would be very subtle and only revolve around whether the traceback is preserved.
Now, there have been discussions about adding a new parameter to Loguru to allow customisation of exception formatting, and I think that would be a first step forward. Using such solution, the traceback could be automatically formatted before being serialized into the queue (just like it's done currently with default Loguru exception formatting). I think it would solve most of the problem. Not entirely, perhaps, but that's the preferred approach I am leaning towards.
Another alternative I considered would be to create a fake traceback and stripping away the non-picklable variables, but this seems fragile and in any case only partially useful.
@Delgan Following up after revisiting this a month later. I have had some time to think this over a bit more but I still think there’s value in separating the multiprocessing-oriented behavior from the single-process, background-thread use case. As an example, the Pydantic Logfire integration with Loguru exhibits the same enqueue=True issue due to the logic around stacktrace removal in its handler implementation. The underlying limitation remains unchanged, and it becomes a point of friction for integrations that rely on exception context.
While routing the handler through a QueueListener works, it feels more like an ad-hoc workaround than a clean integration path. It would be ideal if Loguru exposed a mechanism that avoids the multiprocessing serialization constraints when only thread-based asynchronism is required, without forcing handlers to adopt custom queue plumbing or lose traceback fidelity.