From 3c17c1de785d07e3f6c5d869fb457c03fe27f4e6 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 2 Aug 2026 08:02:58 +0900 Subject: [PATCH] fix(exceptions): log the full stack trace in the global handler report_exception() built its log context via _build_context(), which only appended the traceback when the app was in debug mode. In production the ERROR log carried just 'ExcType: message' (e.g. 'ModuleNotFoundError: No module named app.agents.chat') with no stack trace, making failures hard to diagnose. Always include the full traceback in the logged/stderr context. Exposing the trace to the HTTP client stays a separate, debug-gated decision in the FastAPI render layer, so this doesn't leak internals to users. Add tests asserting the traceback is logged in both non-debug and debug modes, and printed to stderr when no logger is bound. --- .../fastapi_startkit/exceptions/handler.py | 8 +-- fastapi_startkit/tests/exceptions/__init__.py | 0 .../tests/exceptions/test_handler.py | 66 +++++++++++++++++++ fastapi_startkit/uv.lock | 2 +- 4 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 fastapi_startkit/tests/exceptions/__init__.py create mode 100644 fastapi_startkit/tests/exceptions/test_handler.py 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" },