Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 16 additions & 1 deletion src/structlog/stdlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions tests/test_exc_info_double_render.py
Original file line number Diff line number Diff line change
@@ -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"
Loading