From 7e5c372833c640f6fabfb3694b9c13955784f9d1 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 21 Jul 2026 10:46:47 +0200 Subject: [PATCH] Log to a file instead of only stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All app and uvicorn logging now goes to logs/app.log (rotating, 10MB x5), and a catch-all exception handler logs full tracebacks there before returning a generic 500 — so an error is traceable to its cause without depending on how the process was launched. logs/ is gitignored, like the other runtime artifacts. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + app/logging_config.py | 28 ++++++++++++++++++++++++++++ app/main.py | 17 +++++++++++++++-- 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 app/logging_config.py diff --git a/.gitignore b/.gitignore index ac30ea9..f8268b5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__/ master.xprv.enc .pytest_cache/ *.egg-info/ +logs/ diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000..c872f1c --- /dev/null +++ b/app/logging_config.py @@ -0,0 +1,28 @@ +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + +LOG_DIR = Path(__file__).resolve().parent.parent / "logs" +LOG_FILE = LOG_DIR / "app.log" + +_FORMAT = "%(asctime)s %(levelname)s [%(name)s] %(message)s" + + +def setup_logging() -> None: + """Route all app + uvicorn logging to logs/app.log so errors are traceable + without depending on however the process happens to be launched.""" + LOG_DIR.mkdir(exist_ok=True) + + handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5) + handler.setFormatter(logging.Formatter(_FORMAT)) + + root = logging.getLogger() + root.setLevel(logging.INFO) + root.addHandler(handler) + + # "uvicorn.error"/"uvicorn.access" have propagate=False in uvicorn's default + # logging config, so they never reach the root handler above and need their + # own. The parent "uvicorn" logger is deliberately skipped: uvicorn.error + # already bubbles into it, so attaching there too would double every line. + for logger_name in ("uvicorn.error", "uvicorn.access"): + logging.getLogger(logger_name).addHandler(handler) diff --git a/app/main.py b/app/main.py index 80e8963..511987a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,15 @@ import asyncio +import logging from contextlib import asynccontextmanager -from fastapi import FastAPI -from fastapi.responses import FileResponse +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles +from app.logging_config import setup_logging + +setup_logging() + import app.bets.confirmation # noqa: F401 (registers the "bet" confirmation handler) import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmation handler) import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler) @@ -22,6 +27,8 @@ from app.tx.broadcast import RbfBumper from app.tx.confirmation import ConfirmationPoller from app.tx.locks import UserLocks +logger = logging.getLogger(__name__) + def _make_electrum_client() -> ElectrumClient: return ElectrumClient(settings.electrum_host, settings.electrum_port, settings.electrum_use_ssl) @@ -60,6 +67,12 @@ app.include_router(withdrawals_router) app.include_router(admin_router) +@app.exception_handler(Exception) +async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse: + logger.exception("Unhandled error on %s %s", request.method, request.url.path) + return JSONResponse(status_code=500, content={"detail": "internal server error"}) + + @app.get("/health") async def health() -> dict[str, str]: return {"status": "ok"}