Skip to content

Commit 860ecd6

Browse files
committed
πŸ—„ [chore][backend] Logging setup
1 parent 38c7d59 commit 860ecd6

5 files changed

Lines changed: 120 additions & 3 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
paths:
3+
- "backend/**/*.py"
4+
---
5+
6+
# Backend logging
7+
8+
## All logging goes through loguru
9+
10+
- Import as `from loguru import logger`. Do not `import logging` or `getLogger(...)` in app code.
11+
- `setup_logger()` in `kayman/util.py` is called once from `main.py` at import time. It removes the default handler, adds the env-appropriate sink, and installs an `InterceptHandler` so stdlib logging (uvicorn, FastAPI, SQLAlchemy, alembic) is bridged to loguru. Don't add sinks or call `logger.remove()` elsewhere.
12+
- Library-style `logger = logging.getLogger(__name__)` per module is unnecessary in loguru. Use the single global `logger` and rely on loguru's auto-captured `name`/`function`/`line`.
13+
14+
## Level per environment
15+
16+
`settings.ENVIRONMENT` is the single source of truth. Pytest runs auto-coerce it to `testing` (see `_force_testing_under_pytest` in `core/config.py`). The level map lives in `_LOG_LEVELS` in `util.py`:
17+
18+
| Environment | Level |
19+
| ------------- | --------- |
20+
| `local` | `DEBUG` |
21+
| `development` | `DEBUG` |
22+
| `testing` | `WARNING` |
23+
| `production` | `INFO` |
24+
25+
- Tests stay quiet by default because pytest forces `testing`. Use loguru's `caplog` shim when a test needs to assert on log output.
26+
- Don't rely on `LOGURU_LEVEL` env var, it doesn't override the explicit `level=` passed to `logger.add(...)`.
27+
28+
## When to use which level
29+
30+
- `DEBUG`: noisy diagnostics useful during development (query params, computed intermediates). Never assume DEBUG is enabled at the call site.
31+
- `INFO`: normal lifecycle events worth keeping in production (app started, migration applied, scheduled job ran).
32+
- `WARNING`: unexpected but recoverable (retry kicked in, falling back to default, deprecated path hit).
33+
- `ERROR`: a request or job failed with user-visible impact. Include the exception with `logger.exception(...)` or `logger.opt(exception=True).error(...)`.
34+
- `CRITICAL`: process can't continue (DB unreachable at startup, required secret missing).
35+
- Don't log at `INFO` inside hot loops. If you're tempted, it's `DEBUG`.
36+
37+
## Structured output
38+
39+
- Production sink uses `serialize=True` (JSON lines) so the log aggregator can parse fields directly.
40+
- Local sink stays human-readable with colors.
41+
- Attach request-scoped context via `logger.bind(request_id=..., user_id=...)` in a FastAPI middleware, not by string-formatting into the message.
42+
43+
## Safety
44+
45+
- In `production`, configure the sink with `diagnose=False` and `backtrace=False`. Loguru's diagnose mode dumps local variables on exception, which can leak secrets, tokens, or PII.
46+
- This is a personal finance app: never log full account numbers, balances tied to an identifiable user, raw auth tokens, password hashes, or `Authorization` headers. Redact or use a stable surrogate (`user_id`, `account_id`).
47+
- Use `enqueue=True` on the production sink if uvicorn runs with multiple workers, to keep writes process-safe.
48+
49+
## Exceptions
50+
51+
- Prefer `logger.exception("what failed")` inside `except` blocks over manual traceback formatting.
52+
- For background tasks and event handlers where an unhandled exception would otherwise be swallowed, wrap the entry point with `@logger.catch(reraise=True)`.
53+
- Don't `except Exception: logger.error(e)` and continue, either re-raise or handle it deliberately.
54+
55+
## Don'ts
56+
57+
- No `print()` for diagnostics, ever. It bypasses sinks, levels, and JSON formatting.
58+
- Don't f-string into the message when the value comes from user input that could include format placeholders. Pass values as `extra=` or as positional args to loguru's `{}`-style template.
59+
- Don't log inside import-time code in `__init__.py`. Sinks aren't necessarily configured yet.

β€Žbackend/kayman/core/config.pyβ€Ž

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
1+
import os
12
import secrets
2-
from typing import Literal
3+
from typing import Literal, Self
34

4-
from pydantic import computed_field
5+
from pydantic import computed_field, model_validator
56
from pydantic_settings import BaseSettings, SettingsConfigDict
67
from sqlalchemy import URL
78

89

910
class Settings(BaseSettings):
1011
model_config = SettingsConfigDict()
1112
SECRET_KEY: str = secrets.token_urlsafe(32)
12-
ENVIRONMENT: Literal["local", "development", "production"] = "production"
13+
ENVIRONMENT: Literal["local", "development", "testing", "production"] = "production"
14+
15+
@model_validator(mode="after")
16+
def _force_testing_under_pytest(self) -> Self:
17+
if "PYTEST_VERSION" in os.environ:
18+
self.ENVIRONMENT = "testing"
19+
return self
1320

1421
PROJECT_NAME: str = "Kayman"
1522
POSTGRES_HOST: str = "kayman-db"

β€Žbackend/kayman/main.pyβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111
SPAStaticFiles,
1212
custom_generate_unique_id,
1313
lifespan,
14+
setup_logger,
1415
)
1516

17+
setup_logger()
18+
1619
cors_middleware = Middleware(
1720
CORSMiddleware,
1821
allow_origins=["*"],

β€Žbackend/kayman/util.pyβ€Ž

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import logging
2+
import sys
13
from collections.abc import AsyncGenerator
24
from contextlib import asynccontextmanager
35
from datetime import datetime
@@ -9,14 +11,57 @@
911
from fastapi.responses import JSONResponse
1012
from fastapi.routing import APIRoute
1113
from fastapi.staticfiles import StaticFiles
14+
from loguru import logger
1215
from pydantic import BaseModel
1316
from starlette.exceptions import HTTPException
1417
from starlette.responses import Response
1518
from starlette.types import Scope
1619

1720
from kayman.auth import setup_clients
21+
from kayman.core.config import settings
1822
from kayman.core.db import alembic_upgrade
1923

24+
_LOG_LEVELS = {
25+
"local": "DEBUG",
26+
"development": "DEBUG",
27+
"testing": "WARNING",
28+
"production": "INFO",
29+
}
30+
31+
32+
class InterceptHandler(logging.Handler):
33+
def emit(self, record: logging.LogRecord) -> None:
34+
try:
35+
level: str | int = logger.level(record.levelname).name
36+
except ValueError:
37+
level = record.levelno
38+
frame, depth = logging.currentframe(), 2
39+
while frame and frame.f_code.co_filename == logging.__file__:
40+
frame = frame.f_back
41+
depth += 1
42+
logger.opt(depth=depth, exception=record.exc_info).log(
43+
level, record.getMessage()
44+
)
45+
46+
47+
def setup_logger() -> None:
48+
level = _LOG_LEVELS[settings.ENVIRONMENT]
49+
logger.remove()
50+
if settings.ENVIRONMENT == "production":
51+
logger.add(
52+
sys.stderr,
53+
level=level,
54+
serialize=True,
55+
diagnose=False,
56+
backtrace=False,
57+
enqueue=True,
58+
)
59+
else:
60+
logger.add(sys.stderr, level=level)
61+
62+
# Bridge stdlib logging (uvicorn, FastAPI, SQLAlchemy, alembic) to loguru
63+
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
64+
2065

2166
# Register startup and shutdown events
2267
@asynccontextmanager

β€Žbackend/scripts/start/backend.shβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ set -euo pipefail
33
#MISE description="Start backend server with hot reload"
44
#MISE dir="backend"
55

6+
# TODO: when we upgrade to use FastAPI CLI, check
7+
# 1. if logging can be integrated with loguru
8+
# 2. Mise task support colorful output for backend + frontend start task
69
uv run uvicorn kayman.main:app \
710
--host 0.0.0.0 \
811
--port 8000 \

0 commit comments

Comments
Β (0)