Repository: hynek/structlog

Issue:
ProcessorFormatter should be able to pass the message unformatted
https://github.com/hynek/structlog/blob/f7145a6a815083a748ec2e41a1d66dd63ce6c411/src/structlog/stdlib.py#L1016

The problematic pipeline is logging ->  ProcessorFormatter -> structlog -> Sentry.

Currently ProcessorFormatter calls `getMessage` on the log record.

This means that the formatted log message from `logging` becomes the "event" field in `structlog`. This causes duplicates if structlog is integrated with Sentry, which groups messages by the "event" field. If the "event" field wasn't formatted, the messages would get grouped correctly.


There should be a flag on ProcessorFormatter that allows it to pass the log message unformatted, like `"event": str(record.msg),` ([`LogRecord.msg` can be anything supporting `__str__`](https://docs.python.org/3/library/logging.html#logging.LogRecord)).

Passing positional args is already supported thanks to the `pass_foreign_args` flag.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a12c8f..7a0e35c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,9 @@ 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: `ProcessorFormatter` can now be told to not render the log record message using `getMessage` and just `str(record.msg)` instead.
+  [#550](https://github.com/hynek/structlog/issues/550)
+
 - 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)
diff --git a/src/structlog/stdlib.py b/src/structlog/stdlib.py
index 9e82035..5e05e9b 100644
--- a/src/structlog/stdlib.py
+++ b/src/structlog/stdlib.py
@@ -964,6 +964,10 @@ class ProcessorFormatter(logging.Formatter):
             This parameter exists for historic reasons. Please use *processors*
             instead.
 
+        use_get_message:
+            If True, use ``record.getMessage`` to get a fully rendered log
+            message, otherwise use ``str(record.msg)``. (default: True)
+
     Raises:
 
         TypeError: If both or neither *processor* and *processors* are passed.
@@ -976,6 +980,7 @@ class ProcessorFormatter(logging.Formatter):
     .. deprecated:: 21.3.0
        *processor* (singular) in favor of *processors* (plural). Removal is not
        planned.
+    .. versionadded:: 23.3.0 *use_get_message*
     """
 
     def __init__(
@@ -987,6 +992,7 @@ class ProcessorFormatter(logging.Formatter):
         keep_stack_info: bool = False,
         logger: logging.Logger | None = None,
         pass_foreign_args: bool = False,
+        use_get_message: bool = True,
         *args: Any,
         **kwargs: Any,
     ) -> None:
@@ -1011,6 +1017,7 @@ class ProcessorFormatter(logging.Formatter):
         self.keep_stack_info = keep_stack_info
         self.logger = logger
         self.pass_foreign_args = pass_foreign_args
+        self.use_get_message = use_get_message
 
     def format(self, record: logging.LogRecord) -> str:
         """
@@ -1043,7 +1050,9 @@ class ProcessorFormatter(logging.Formatter):
             logger = self.logger
             meth_name = record.levelname.lower()
             ed = {
-                "event": record.getMessage(),
+                "event": record.getMessage()
+                if self.use_get_message
+                else str(record.msg),
                 "_record": record,
                 "_from_structlog": False,
             }


Test patch:
diff --git a/tests/test_stdlib.py b/tests/test_stdlib.py
index 626b1a7..569bbbd 100644
--- a/tests/test_stdlib.py
+++ b/tests/test_stdlib.py
@@ -1189,6 +1189,34 @@ class TestProcessorFormatter:
 
         assert not records
 
+    def test_use_get_message_false(self):
+        """
+        If use_get_message_is False, the event is obtained using
+        str(record.msg) instead of calling record.getMessage. That means
+        positional formatting is not performed.
+        """
+        event_dicts = []
+
+        def capture(_, __, ed):
+            event_dicts.append(ed.copy())
+
+            return str(ed)
+
+        proc = ProcessorFormatter(processors=[capture], use_get_message=False)
+
+        record = logging.LogRecord(
+            "foo",
+            logging.INFO,
+            "path.py",
+            42,
+            "le msg: %s",
+            ("keep separate",),
+            None,
+        )
+
+        assert proc.format(record)
+        assert "le msg: %s" == event_dicts[0]["event"]
+
 
 @pytest_asyncio.fixture(name="abl")
 async def _abl(cl):