diff --git a/fastapi_startkit/src/fastapi_startkit/exceptions/handler.py b/fastapi_startkit/src/fastapi_startkit/exceptions/handler.py index 0f3132c5..06f39aa9 100644 --- a/fastapi_startkit/src/fastapi_startkit/exceptions/handler.py +++ b/fastapi_startkit/src/fastapi_startkit/exceptions/handler.py @@ -71,10 +71,10 @@ def report_exception(self, exception: Exception): def _build_context(self, exception: Exception) -> str: import traceback - context = f"{type(exception).__name__}: {exception}" - if self.app and self.app.is_debug(): - context += "\n" + "".join(traceback.format_exception(type(exception), exception, exception.__traceback__)) - return context + # Always log the full stack trace — a bare "ExcType: message" is not + # actionable. Whether the trace is exposed to the client is a separate, + # debug-gated decision handled in the FastAPI render layer. + return "".join(traceback.format_exception(type(exception), exception, exception.__traceback__)) async def handle(self, exception: Exception, context: Optional[Dict] = None) -> Any: """Main entry point called from the FastAPI exception_handler hook.""" diff --git a/fastapi_startkit/tests/exceptions/__init__.py b/fastapi_startkit/tests/exceptions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi_startkit/tests/exceptions/test_handler.py b/fastapi_startkit/tests/exceptions/test_handler.py new file mode 100644 index 00000000..c27dd9f2 --- /dev/null +++ b/fastapi_startkit/tests/exceptions/test_handler.py @@ -0,0 +1,66 @@ +"""The global exception handler must log the full stack trace, not just the +exception message, so failures are actionable in production logs (task #1347).""" + +import contextlib +import io +from unittest import mock + +from fastapi_startkit.exceptions.handler import ExceptionHandler + + +class _FakeApp: + """Minimal stand-in for the Application used by ExceptionHandler.report().""" + + def __init__(self, *, debug: bool = False, has_logger: bool = True) -> None: + self._debug = debug + self._has_logger = has_logger + + def has(self, key: str) -> bool: + return self._has_logger and key == "logger" + + def is_debug(self) -> bool: + return self._debug + + +def _raise(message: str) -> Exception: + """Return a caught exception carrying a real traceback.""" + try: + raise ModuleNotFoundError(message) + except ModuleNotFoundError as exc: + return exc + + +class TestExceptionHandlerLogsTraceback: + def test_logs_full_traceback_even_when_not_in_debug(self): + handler = ExceptionHandler(_FakeApp(debug=False)) + exc = _raise("No module named 'app.agents.chat'") + + with mock.patch("fastapi_startkit.logging.logger.Logger.error") as log: + handler.report(exc) + + log.assert_called_once() + logged = log.call_args.args[0] + assert "Traceback (most recent call last):" in logged + assert "ModuleNotFoundError: No module named 'app.agents.chat'" in logged + assert "test_handler.py" in logged # the frame where it was raised + + def test_logs_full_traceback_in_debug_too(self): + handler = ExceptionHandler(_FakeApp(debug=True)) + exc = _raise("boom") + + with mock.patch("fastapi_startkit.logging.logger.Logger.error") as log: + handler.report(exc) + + assert "Traceback (most recent call last):" in log.call_args.args[0] + + def test_prints_full_traceback_to_stderr_when_no_logger(self): + handler = ExceptionHandler(_FakeApp(has_logger=False)) + exc = _raise("no logger here") + + buffer = io.StringIO() + with contextlib.redirect_stderr(buffer): + handler.report(exc) + + printed = buffer.getvalue() + assert "Traceback (most recent call last):" in printed + assert "ModuleNotFoundError: no logger here" in printed diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index 5808c35b..c9ea4cba 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -527,7 +527,7 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.48.0" +version = "0.50.0" source = { editable = "." } dependencies = [ { name = "cleo" },