From 95b5d4990332772ceac36dc7efdda80595b16c63 Mon Sep 17 00:00:00 2001 From: Mohammed Date: Sat, 18 Jul 2026 19:59:10 +0100 Subject: [PATCH 1/3] Fix exception rendering regression --- src/structlog/stdlib.py | 13 ++++++++- tests/test_exc_info_double_render.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/test_exc_info_double_render.py diff --git a/src/structlog/stdlib.py b/src/structlog/stdlib.py index 2906598d..9719e5d6 100644 --- a/src/structlog/stdlib.py +++ b/src/structlog/stdlib.py @@ -283,7 +283,18 @@ 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..1333aefe --- /dev/null +++ b/tests/test_exc_info_double_render.py @@ -0,0 +1,42 @@ +import logging +import pytest +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" From ee456cd9e9bc5aa46c4e7d34d6b67268c6b51d23 Mon Sep 17 00:00:00 2001 From: Mohammed Date: Sat, 18 Jul 2026 20:05:47 +0100 Subject: [PATCH 2/3] Document exception rendering fix --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) 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 From 0becadaa40894a572963e7013dfc2132eec7717a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:06:28 +0000 Subject: [PATCH 3/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/structlog/stdlib.py | 12 ++++++++---- tests/test_exc_info_double_render.py | 15 +++++++++------ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/structlog/stdlib.py b/src/structlog/stdlib.py index 9719e5d6..4bb91180 100644 --- a/src/structlog/stdlib.py +++ b/src/structlog/stdlib.py @@ -285,13 +285,17 @@ def _proxy_to_logger( 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) - + 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 diff --git a/tests/test_exc_info_double_render.py b/tests/test_exc_info_double_render.py index 1333aefe..81ca1d9a 100644 --- a/tests/test_exc_info_double_render.py +++ b/tests/test_exc_info_double_render.py @@ -1,13 +1,16 @@ import logging -import pytest + 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 @@ -22,21 +25,21 @@ def mock_format_exc_info(logger, name, event_dict): 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"