diff --git a/CHANGELOG.md b/CHANGELOG.md index b23d71a7..41554387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/ ## [Unreleased](https://github.com/hynek/structlog/compare/26.1.0...HEAD) +### Fixed + +- `structlog.stdlib.BoundLogger` now avoids double-rendering exception information when a processor has already rendered it, preventing duplicate traceback output for `exception` events. ## [26.1.0](https://github.com/hynek/structlog/compare/25.5.0...26.1.0) - 2026-06-06 diff --git a/src/structlog/stdlib.py b/src/structlog/stdlib.py index 2906598d..4bb91180 100644 --- a/src/structlog/stdlib.py +++ b/src/structlog/stdlib.py @@ -283,7 +283,22 @@ def _proxy_to_logger( if event_args: event_kw["positional_args"] = event_args - return super()._proxy_to_logger(method_name, event=event, **event_kw) + try: + args, kw = self._process_event(method_name, event, event_kw) + + # If a processor already rendered the exception, suppress stdlib's automatic rendering. + is_rendered = kw.pop( + "_structlog_exception_already_rendered", False + ) or kw.get("extra", {}).pop( + "_structlog_exception_already_rendered", False + ) + + if method_name == "exception" and is_rendered: + kw["exc_info"] = False + + return getattr(self._logger, method_name)(*args, **kw) + except DropEvent: + return None # Pass-through attributes and methods to mimic the stdlib's logger # interface. diff --git a/tests/test_exc_info_double_render.py b/tests/test_exc_info_double_render.py new file mode 100644 index 00000000..81ca1d9a --- /dev/null +++ b/tests/test_exc_info_double_render.py @@ -0,0 +1,45 @@ +import logging + +import structlog + +from structlog.stdlib import BoundLogger, LoggerFactory, render_to_log_kwargs + + +def test_exception_no_double_render_with_in_band_signaling(caplog): + """ + Ensure that the stdlib BoundLogger suppresses exc_info when a processor + has already rendered the exception, preventing duplicate tracebacks. + """ + + def mock_format_exc_info(logger, name, event_dict): + event_dict["exception"] = "Mocked Traceback" + event_dict["_structlog_exception_already_rendered"] = True + return event_dict + + structlog.configure( + processors=[ + mock_format_exc_info, + structlog.stdlib.add_log_level, + render_to_log_kwargs, + ], + logger_factory=LoggerFactory(), + wrapper_class=BoundLogger, + ) + + logger = structlog.get_logger("test_logger") + + with caplog.at_level(logging.ERROR): + try: + raise ValueError("test error") + except ValueError: + logger.exception("An error occurred") + + assert len(caplog.records) == 1 + record = caplog.records[0] + + # Verify the underlying logger was called without exc_info + assert record.exc_info is False or record.exc_info is None + + # Verify the message and extra data are intact + assert record.msg == "An error occurred" + assert record.__dict__.get("exception") == "Mocked Traceback"