Repository: litestar-org/litestar

Issue:
Bug: binary files can't be uploaded if logging is active without structlog installed
### Description

*This is a pro forma report, I will create a PR that fixes this*


### URL to code causing the issue

_No response_

### MCVE

```python
from typing import Annotated

from litestar import Litestar, post
from litestar.datastructures import UploadFile
from litestar.enums import RequestEncodingType
from litestar.logging import LoggingConfig
from litestar.middleware.logging import LoggingMiddlewareConfig
from litestar.params import Body


@post("/")
async def handler(data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> str:
    return "foo"

app = Litestar(
        route_handlers=[handler],
        middleware=[LoggingMiddlewareConfig().middleware],
        logging_config=LoggingConfig(),
)
```


### Steps to reproduce

Now, install litestar *without* structlog and run it:
```bash
pip install litestar[standard]
LITESTAR_APP=bug:app litestar run --debug
```

Posting a binary file that contains an invalid UTF-8 sequence will cause an exception.

```
Traceback (most recent call last):                                                                              
  File "/home/dennis/src/litestar/litestar/middleware/exceptions/middleware.py", line 191, in __call__
    await self.app(scope, receive, send)                                                                        
  File "/home/dennis/src/litestar/litestar/middleware/base.py", line 129, in wrapped_call
    await original__call__(self, scope, receive, send)
  File "/home/dennis/src/litestar/litestar/middleware/logging.py", line 115, in __call__    
    await self.log_request(scope=scope, receive=receive)                                                        
  File "/home/dennis/src/litestar/litestar/middleware/logging.py", line 129, in log_request           
    extracted_data = await self.extract_request_data(request=scope["app"].request_class(scope, receive=receive))
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/dennis/src/litestar/litestar/middleware/logging.py", line 183, in extract_request_data            
    data[key] = self._serialize_value(serializer, value)                                
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 
  File "/home/dennis/src/litestar/litestar/middleware/logging.py", line 164, in _serialize_value
    return value.decode("utf-8") if isinstance(value, bytes) else value                                         
           ^^^^^^^^^^^^^^^^^^^^^                                                                                
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xfa in position 134: invalid start byte        
```

The problem is that the body is not parsed when structlog is not installed, and therefore the raw byte sequence will get logged, however, the serialize function assumes it's always valid utf-8.

### Litestar Version

2.2.1 and main

### Platform

- [X] Linux
- [ ] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)



Discussion:

Resolving patch:
diff --git a/litestar/middleware/logging.py b/litestar/middleware/logging.py
--- a/litestar/middleware/logging.py
+++ b/litestar/middleware/logging.py
@@ -161,7 +161,7 @@ def log_message(self, values: dict[str, Any]) -> None:
     def _serialize_value(self, serializer: Serializer | None, value: Any) -> Any:
         if not self.is_struct_logger and isinstance(value, (dict, list, tuple, set)):
             value = encode_json(value, serializer)
-        return value.decode("utf-8") if isinstance(value, bytes) else value
+        return value.decode("utf-8", errors="backslashreplace") if isinstance(value, bytes) else value
 
     async def extract_request_data(self, request: Request) -> dict[str, Any]:
         """Create a dictionary of values for the message.


Test patch:
diff --git a/tests/unit/test_middleware/test_logging_middleware.py b/tests/unit/test_middleware/test_logging_middleware.py
--- a/tests/unit/test_middleware/test_logging_middleware.py
+++ b/tests/unit/test_middleware/test_logging_middleware.py
@@ -3,20 +3,25 @@
 
 import pytest
 from structlog.testing import capture_logs
+from typing_extensions import Annotated
 
 from litestar import Response, get, post
 from litestar.config.compression import CompressionConfig
 from litestar.connection import Request
-from litestar.datastructures import Cookie
+from litestar.datastructures import Cookie, UploadFile
+from litestar.enums import RequestEncodingType
 from litestar.exceptions import ImproperlyConfiguredException
 from litestar.handlers import HTTPRouteHandler
 from litestar.logging.config import LoggingConfig, StructLoggingConfig
+from litestar.middleware import logging as middleware_logging
 from litestar.middleware.logging import LoggingMiddlewareConfig
+from litestar.params import Body
 from litestar.status_codes import HTTP_200_OK, HTTP_201_CREATED
 from litestar.testing import create_test_client
 
 if TYPE_CHECKING:
     from _pytest.logging import LogCaptureFixture
+    from pytest import MonkeyPatch
 
     from litestar.middleware.session.server_side import ServerSideSessionConfig
     from litestar.types.callable_types import GetLogger
@@ -191,6 +196,25 @@ def post_handler(data: Dict[str, str]) -> Dict[str, str]:
         assert res.json() == {"foo": "bar"}
 
 
+async def test_logging_middleware_post_binary_file_without_structlog(monkeypatch: "MonkeyPatch") -> None:
+    # https://github.com/litestar-org/litestar/issues/2529
+
+    @post("/")
+    async def post_handler(data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> str:
+        content = await data.read()
+        return f"{len(content)} bytes"
+
+    # force LoggingConfig to not parse body data
+    monkeypatch.setattr(middleware_logging, "structlog_installed", False)
+
+    with create_test_client(
+        route_handlers=[post_handler], middleware=[LoggingMiddlewareConfig().middleware], logging_config=LoggingConfig()
+    ) as client:
+        res = client.post("/", files={"foo": b"\xfa\xfb"})
+        assert res.status_code == 201
+        assert res.text == "2 bytes"
+
+
 @pytest.mark.parametrize("logger_name", ("litestar", "other"))
 def test_logging_messages_are_not_doubled(
     get_logger: "GetLogger", logger_name: str, caplog: "LogCaptureFixture"