Repository: hynek/structlog

Issue:
exc_info is not resolved properly when calling BoundLogger.exception
I have a slightly unusual setup that I should explain first. I'm using the maximalist approach of routing all of my structlog and stdlib logging through `ProcessorFormatter` as per [the docs](https://www.structlog.org/en/stable/standard-library.html#rendering-using-structlog-based-formatters-within-logging), but we're also sending logs at `ERROR` and above to Rollbar using `pyrollbar` and its [RollbarHandler](https://github.com/rollbar/pyrollbar/blob/master/rollbar/logger.py).

Unfortunately, `RollbarHandler` on its own doesn't play nicely with that approach because it doesn't call `self.format()` anywhere meaning we don't have anywhere to plug in `ProcessorFormatter`. So, we have a custom subclass (called `StructlogRollbarHandler`) that does similar things to `ProcessorFormatter.format()` in its `emit()` method before calling the superclass `emit()` method to send the payload to Rollbar. A notable exception to that is using the raw `record.msg` rather than `record.getMessage()` as per #520.

That means that it's really important for us that all our `LogRecord` objects, whether from `structlog` or stdlib loggers, look the same when they get to the `Handler.emit(record)` stage of the pipeline, because we can't rely on `ProcessorFormatter` or any structlog renderers patching things up for us. This is mostly working fine, apart from calls to `BoundLogger.exception`.

When we call `BoundLogger.exception`, the `LogRecord` that gets to our `StructlogRollbarHandler.emit(record)` method has an event dict for its `record.msg` attribute, as you'd expect, and that dict has `"exc_info"` in it, but the `record.exc_info` attribute itself is `None`! Obviously this isn't the case for stdlib-originated records.

Having spent a while debugging, this seems to be because `BoundLogger.exception` calls `self.error` rather than doing something like `self._proxy_to_logger("exception", event, *args, **{"exc_info": True, **kw})`. That means that the wrapped `logging.Logger` thinks this was a `logger.error` call and not a `logger.exception` call, which changes the behaviour in a really subtle way.

All of our `LogRecord` objects from `structlog` go through `ProcessorFormatter.wrap_for_formatter`, which puts `exc_info` in the `record.msg` event dict and strips it out of the kwargs. Then, because of the aforementioned change of the effective log method from `.exception` to `.error`, the stdlib logger doesn't set this back to `True` for us and then `Logger._log` doesn't call `sys.exc_info()` for us.

- [logging.Logger.error](https://github.com/python/cpython/blob/4fe22c73770fe86b01ef7a4f1f38e7e925c0e090/Lib/logging/__init__.py#L1525)
- [logging.Logger.exception](https://github.com/python/cpython/blob/4fe22c73770fe86b01ef7a4f1f38e7e925c0e090/Lib/logging/__init__.py#L1537)

I'm honestly not sure whether this is a bug in my code or `structlog`. I accept that using `ProcessorFormatter` alongside other formatters isn't really documented/supported, but it's really close to working perfectly apart from this!

Sorry for writing such a long issue, thank you for an amazing library! :heart: 

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbc966e..9a12c8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,10 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
 
 - `structlog.threadlocal.tmp_bind()` now also works with `BoundLoggerLazyProxy` (in other words: before anything is bound to a bound logger).
 
+- stdlib: `structlog.stdlib.BoundLogger.exception()`'s handling of`LogRecord.exc_info` is now set consistent with `logging`.
+  [#571](https://github.com/hynek/structlog/issues/571)
+  [#572](https://github.com/hynek/structlog/issues/572)
+
 
 ## [23.2.0](https://github.com/hynek/structlog/compare/23.1.0...23.2.0) - 2023-10-09
 
diff --git a/src/structlog/stdlib.py b/src/structlog/stdlib.py
index 0cac035..9e82035 100644
--- a/src/structlog/stdlib.py
+++ b/src/structlog/stdlib.py
@@ -16,9 +16,10 @@ import contextvars
 import functools
 import logging
 import sys
+import warnings
 
 from functools import partial
-from typing import Any, Callable, Collection, Iterable, Sequence
+from typing import Any, Callable, Collection, Dict, Iterable, Sequence, cast
 
 from . import _config
 from ._base import BoundLoggerBase
@@ -27,7 +28,14 @@ from ._log_levels import _LEVEL_TO_NAME, _NAME_TO_LEVEL, add_log_level
 from .contextvars import _ASYNC_CALLING_STACK, merge_contextvars
 from .exceptions import DropEvent
 from .processors import StackInfoRenderer
-from .typing import Context, EventDict, ExcInfo, Processor, WrappedLogger
+from .typing import (
+    Context,
+    EventDict,
+    ExcInfo,
+    Processor,
+    ProcessorReturnValue,
+    WrappedLogger,
+)
 
 
 __all__ = [
@@ -209,12 +217,11 @@ class BoundLogger(BoundLoggerBase):
         self, event: str | None = None, *args: Any, **kw: Any
     ) -> Any:
         """
-        Process event and call `logging.Logger.error` with the result,
-        after setting ``exc_info`` to `True`.
+        Process event and call `logging.Logger.exception` with the result,
+        after setting ``exc_info`` to `True` if it's not already set.
         """
         kw.setdefault("exc_info", True)
-
-        return self.error(event, *args, **kw)
+        return self._proxy_to_logger("exception", event, *args, **kw)
 
     def log(
         self, level: int, event: str | None = None, *args: Any, **kw: Any
@@ -1019,16 +1026,17 @@ class ProcessorFormatter(logging.Formatter):
         logger = getattr(record, "_logger", _SENTINEL)
         meth_name = getattr(record, "_name", "__structlog_sentinel__")
 
+        ed: ProcessorReturnValue
         if logger is not _SENTINEL and meth_name != "__structlog_sentinel__":
             # Both attached by wrap_for_formatter
             if self.logger is not None:
                 logger = self.logger
-            meth_name = record._name  # type: ignore[attr-defined]
+            meth_name = cast(str, record._name)  # type:ignore[attr-defined]
 
             # We need to copy because it's possible that the same record gets
-            # processed by multiple logging formatters.  LogRecord.getMessage
+            # processed by multiple logging formatters. LogRecord.getMessage
             # would transform our dict into a str.
-            ed = record.msg.copy()  # type: ignore[union-attr]
+            ed = cast(Dict[str, Any], record.msg).copy()
             ed["_record"] = record
             ed["_from_structlog"] = True
         else:
@@ -1045,27 +1053,38 @@ class ProcessorFormatter(logging.Formatter):
 
             record.args = ()
 
-            # Add stack-related attributes to event_dict and unset them
-            # on the record copy so that the base implementation wouldn't
-            # append stacktraces to the output.
+            # Add stack-related attributes to the event dict
             if record.exc_info:
                 ed["exc_info"] = record.exc_info
             if record.stack_info:
                 ed["stack_info"] = record.stack_info
 
-            if not self.keep_exc_info:
-                record.exc_text = None
-                record.exc_info = None
-            if not self.keep_stack_info:
-                record.stack_info = None
-
             # Non-structlog allows to run through a chain to prepare it for the
             # final processor (e.g. adding timestamps and log levels).
             for proc in self.foreign_pre_chain or ():
-                ed = proc(logger, meth_name, ed)
+                ed = cast(EventDict, proc(logger, meth_name, ed))
+
+        # If required, unset stack-related attributes on the record copy so
+        # that the base implementation doesn't append stacktraces to the
+        # output.
+        if not self.keep_exc_info:
+            record.exc_text = None
+            record.exc_info = None
+        if not self.keep_stack_info:
+            record.stack_info = None
 
         for p in self.processors:
-            ed = p(logger, meth_name, ed)
+            ed = p(logger, meth_name, cast(EventDict, ed))
+
+        if not isinstance(ed, str):
+            warnings.warn(
+                "The last processor in ProcessorFormatter.processors must "
+                f"return a string, but {self.processors[-1]} returned a "
+                f"{type(ed)} instead.",
+                category=RuntimeWarning,
+                stacklevel=1,
+            )
+            ed = cast(str, ed)
 
         record.msg = ed
 
diff --git a/src/structlog/typing.py b/src/structlog/typing.py
index 66f6a7e..c258e11 100644
--- a/src/structlog/typing.py
+++ b/src/structlog/typing.py
@@ -60,11 +60,15 @@ copy itself.
 .. versionadded:: 20.2.0
 """
 
-Processor = Callable[
-    [WrappedLogger, str, EventDict],
-    Union[Mapping[str, Any], str, bytes, bytearray, Tuple[Any, ...]],
+ProcessorReturnValue = Union[
+    Mapping[str, Any], str, bytes, bytearray, Tuple[Any, ...]
 ]
 """
+A value returned by a processor.
+"""
+
+Processor = Callable[[WrappedLogger, str, EventDict], ProcessorReturnValue]
+"""
 A callable that is part of the processor chain.
 
 See :doc:`processors`.


Test patch:
diff --git a/tests/test_stdlib.py b/tests/test_stdlib.py
index 4e3c9f5..626b1a7 100644
--- a/tests/test_stdlib.py
+++ b/tests/test_stdlib.py
@@ -12,7 +12,7 @@ import os
 import sys
 
 from io import StringIO
-from typing import Any, Callable, Collection
+from typing import Any, Callable, Collection, Dict
 
 import pytest
 import pytest_asyncio
@@ -193,7 +193,8 @@ class TestFilterByLevel:
 
 class TestBoundLogger:
     @pytest.mark.parametrize(
-        ("method_name"), ["debug", "info", "warning", "error", "critical"]
+        ("method_name"),
+        ["debug", "info", "warning", "error", "exception", "critical"],
     )
     def test_proxies_to_correct_method(self, method_name):
         """
@@ -203,14 +204,6 @@ class TestBoundLogger:
 
         assert method_name == getattr(bl, method_name)("event")
 
-    def test_proxies_exception(self):
-        """
-        BoundLogger.exception is proxied to Logger.error.
-        """
-        bl = BoundLogger(ReturnLogger(), [return_method_name], {})
-
-        assert "error" == bl.exception("event")
-
     def test_proxies_log(self):
         """
         BoundLogger.exception.log() is proxied to the appropriate method.
@@ -1123,6 +1116,79 @@ class TestProcessorFormatter:
             {"foo": "bar", "_record": "foo", "_from_structlog": True},
         )
 
+    def test_non_string_message_warning(self):
+        """
+        A warning is raised if the last processor in
+        ProcessorFormatter.processors doesn't return a string.
+        """
+        configure_logging(None)
+        logger = logging.getLogger()
+
+        formatter = ProcessorFormatter(
+            processors=[lambda *args, **kwargs: {"foo": "bar"}],
+        )
+        logger.handlers[0].setFormatter(formatter)
+
+        with pytest.warns(
+            RuntimeWarning,
+            match="The last processor in ProcessorFormatter.processors must return a string",
+        ):
+            logger.info("baz")
+
+    def test_logrecord_exc_info(self):
+        """
+        LogRecord.exc_info is set consistently for structlog and non-structlog
+        log records.
+        """
+        configure_logging(None)
+
+        # This doesn't test ProcessorFormatter itself directly, but it's
+        # relevant to setups where ProcessorFormatter is used, i.e. where
+        # handlers will receive LogRecord objects that come from both structlog
+        # and non-structlog loggers.
+
+        records: Dict[  # noqa: UP006 - dict isn't generic until Python 3.9
+            str, logging.LogRecord
+        ] = {}
+
+        class DummyHandler(logging.Handler):
+            def emit(self, record):
+                # Don't do anything; just store the record in the records dict
+                # by its message, so we can assert things about it.
+                if isinstance(record.msg, dict):
+                    records[record.msg["event"]] = record
+                else:
+                    records[record.msg] = record
+
+        stdlib_logger = logging.getLogger()
+        structlog_logger = get_logger()
+
+        # It doesn't matter which logger we add the handler to here.
+        stdlib_logger.addHandler(DummyHandler())
+
+        try:
+            raise Exception("foo")
+        except Exception:
+            stdlib_logger.exception("bar")
+            structlog_logger.exception("baz")
+
+        stdlib_record = records.pop("bar")
+
+        assert "bar" == stdlib_record.msg
+        assert stdlib_record.exc_info
+        assert Exception is stdlib_record.exc_info[0]
+        assert ("foo",) == stdlib_record.exc_info[1].args
+
+        structlog_record = records.pop("baz")
+
+        assert "baz" == structlog_record.msg["event"]
+        assert True is structlog_record.msg["exc_info"]
+        assert structlog_record.exc_info
+        assert Exception is structlog_record.exc_info[0]
+        assert ("foo",) == structlog_record.exc_info[1].args
+
+        assert not records
+
 
 @pytest_asyncio.fixture(name="abl")
 async def _abl(cl):
@@ -1154,7 +1220,7 @@ class TestAsyncBoundLogger:
         """
         await getattr(abl.bind(foo="bar"), stdlib_log_method)("42")
 
-        aliases = {"exception": "error", "warn": "warning"}
+        aliases = {"warn": "warning"}
 
         alias = aliases.get(stdlib_log_method)
         expect = alias if alias else stdlib_log_method