Skip to content

Commit c9e0f71

Browse files
authored
Merge pull request #794 from seapagan/feature/comprehensive-logging
feat(logging): implement comprehensive logging infrastructure with loguru
2 parents f2daeee + 7468186 commit c9e0f71

File tree

21 files changed

+1402
-27
lines changed

21 files changed

+1402
-27
lines changed

.env.example

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,47 @@ MAIL_STARTTLS=True
7272
MAIL_SSL_TLS=False
7373
MAIL_USE_CREDENTIALS=True
7474
MAIL_VALIDATE_CERTS=True
75+
76+
# Logging Configuration
77+
# Directory where log files will be written (must be writable by the application)
78+
LOG_PATH=./logs
79+
80+
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
81+
# DEBUG shows all messages, INFO shows normal operations, WARNING and above show only issues
82+
LOG_LEVEL=INFO
83+
84+
# When to rotate log files: "500 MB", "1 day", "1 week", etc.
85+
LOG_ROTATION=1 day
86+
87+
# How long to keep old log files: "7 days", "30 days", "1 month", etc.
88+
LOG_RETENTION=30 days
89+
90+
# Compression format for rotated logs: "zip", "gz", "bz2", or empty for no compression
91+
LOG_COMPRESSION=zip
92+
93+
# What to log - choose one or combine with commas:
94+
# ALL - Log everything (recommended for development/debugging)
95+
# NONE - Disable logging
96+
# REQUESTS - HTTP request/response logging
97+
# AUTH - Authentication, login, token operations
98+
# DATABASE - Database CRUD operations
99+
# EMAIL - Email sending operations
100+
# ERRORS - Error conditions (always recommended)
101+
# ADMIN - Admin panel operations
102+
# API_KEYS - API key operations
103+
#
104+
# Examples:
105+
# LOG_CATEGORIES=ALL # Log everything
106+
# LOG_CATEGORIES=ERRORS,AUTH,DATABASE # Only log errors, auth, and database operations
107+
# LOG_CATEGORIES=REQUESTS,ERRORS # Only log requests and errors
108+
LOG_CATEGORIES=ALL
109+
110+
# Set the log filename (default: api.log)
111+
# Useful for separating logs in different environments (e.g., test_api.log for tests)
112+
LOG_FILENAME=api.log
113+
114+
# Enable console logging (default: false)
115+
# Set to true if you want loguru to also log to console
116+
# Note: FastAPI/Uvicorn already logs to console, so this is usually not needed
117+
# and will cause duplicate console output
118+
LOG_CONSOLE_ENABLED=false

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,7 @@ docs/CNAME
218218
.cursorrules
219219
.windsurfrules
220220
users.seed
221+
222+
# Application logs
223+
logs/
224+
*.log.*

app/config/helpers.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""Helper classes and functions for config use."""
22

3+
import logging
34
import sys
45
from dataclasses import dataclass
56
from importlib import resources
67
from pathlib import Path
78

89
import rtoml
910

10-
from app.logs import logger
11+
logger = logging.getLogger("uvicorn")
1112

1213

1314
def get_project_root() -> Path:
@@ -32,11 +33,13 @@ def get_api_version() -> str:
3233
version: str = config["project"]["version"]
3334

3435
except KeyError:
35-
logger.error("Cannot find the API version in the pyproject.toml file")
36+
logger.error( # noqa: TRY400
37+
"Cannot find the API version in the pyproject.toml file"
38+
)
3639
sys.exit(2)
3740

38-
except OSError as exc:
39-
logger.error(f"Cannot read the pyproject.toml file : {exc}")
41+
except OSError:
42+
logger.exception("Cannot read the pyproject.toml file")
4043
sys.exit(2)
4144

4245
else:
@@ -55,13 +58,13 @@ def get_api_details() -> tuple[str, str, list[dict[str, str]]]:
5558
authors = [authors]
5659

5760
except KeyError:
58-
logger.error(
61+
logger.error( # noqa: TRY400
5962
"Missing name/description or authors in the pyproject.toml file"
6063
)
6164
sys.exit(2)
6265

63-
except OSError as exc:
64-
logger.error(f"Cannot read the pyproject.toml file : {exc}")
66+
except OSError:
67+
logger.exception("Cannot read the pyproject.toml file")
6568
sys.exit(2)
6669
else:
6770
return (name, desc, authors)

app/config/log_config.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Logging configuration using loguru with category-based control."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
from enum import Flag, auto
7+
from pathlib import Path
8+
9+
from loguru import logger
10+
11+
12+
class LogCategory(Flag):
13+
"""Bit flags for logging categories.
14+
15+
Allows combining multiple categories.
16+
"""
17+
18+
NONE = 0
19+
REQUESTS = auto() # HTTP request/response logging
20+
AUTH = auto() # Authentication, login, token operations
21+
DATABASE = auto() # Database CRUD operations
22+
EMAIL = auto() # Email sending operations
23+
ERRORS = auto() # Error conditions (always recommended)
24+
ADMIN = auto() # Admin panel operations
25+
API_KEYS = auto() # API key operations
26+
ALL = REQUESTS | AUTH | DATABASE | EMAIL | ERRORS | ADMIN | API_KEYS
27+
28+
29+
class LogConfig:
30+
"""Logging configuration from environment variables."""
31+
32+
def __init__(self) -> None:
33+
"""Initialize logging configuration from settings."""
34+
# Import here to avoid circular dependency
35+
from app.config.settings import get_settings # noqa: PLC0415
36+
37+
settings = get_settings()
38+
39+
# Get configuration from .env
40+
self.log_path = Path(getattr(settings, "log_path", "./logs"))
41+
self.log_level = getattr(settings, "log_level", "INFO")
42+
self.log_rotation = getattr(settings, "log_rotation", "1 day")
43+
self.log_retention = getattr(settings, "log_retention", "30 days")
44+
self.log_compression = getattr(settings, "log_compression", "zip")
45+
self.log_filename = getattr(settings, "log_filename", "api.log")
46+
self.console_enabled = getattr(settings, "log_console_enabled", False)
47+
48+
# Validate filename doesn't contain path separators
49+
if "/" in self.log_filename or "\\" in self.log_filename:
50+
msg = (
51+
"log_filename cannot contain path separators. "
52+
"Use log_path to set the directory."
53+
)
54+
raise ValueError(msg)
55+
56+
# Parse enabled categories (comma-separated string or ALL)
57+
categories_str = getattr(settings, "log_categories", "ALL")
58+
self.enabled_categories = self._parse_categories(categories_str)
59+
60+
def _parse_categories(self, categories_str: str) -> LogCategory:
61+
"""Parse comma-separated category string into LogCategory flags."""
62+
if categories_str.upper() == "ALL":
63+
return LogCategory.ALL
64+
if categories_str.upper() == "NONE":
65+
return LogCategory.NONE
66+
67+
result = LogCategory.NONE
68+
for cat_str in categories_str.split(","):
69+
cat_name = cat_str.strip().upper()
70+
if hasattr(LogCategory, cat_name):
71+
result |= getattr(LogCategory, cat_name)
72+
return result
73+
74+
def is_enabled(self, category: LogCategory) -> bool:
75+
"""Check if a logging category is enabled."""
76+
return bool(self.enabled_categories & category)
77+
78+
79+
def setup_logging() -> LogConfig:
80+
"""Configure loguru with rotation, retention, and formatting."""
81+
config = LogConfig()
82+
83+
# Remove default handler
84+
logger.remove()
85+
86+
# Add console handler only if enabled
87+
if config.console_enabled:
88+
logger.add(
89+
sys.stderr,
90+
format="<level>{level: <8}</level> <level>{message}</level>",
91+
level=config.log_level,
92+
colorize=True,
93+
)
94+
95+
# Add file handler with rotation - more detail for file logs
96+
log_file = config.log_path / config.log_filename
97+
config.log_path.mkdir(parents=True, exist_ok=True)
98+
99+
logger.add(
100+
str(log_file),
101+
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {message}",
102+
level=config.log_level,
103+
rotation=config.log_rotation,
104+
retention=config.log_retention,
105+
compression=config.log_compression,
106+
enqueue=True, # Async logging
107+
)
108+
109+
return config
110+
111+
112+
# Global logger instance - lazy initialization to avoid circular imports
113+
_log_config: LogConfig | None = None
114+
115+
116+
def get_log_config() -> LogConfig:
117+
"""Get or initialize the logging configuration."""
118+
global _log_config # noqa: PLW0603
119+
if _log_config is None:
120+
_log_config = setup_logging()
121+
return _log_config
122+
123+
124+
# For backwards compatibility, create a property-like object
125+
class _LogConfigProxy:
126+
"""Proxy object that lazily initializes log config."""
127+
128+
def is_enabled(self, category: LogCategory) -> bool:
129+
"""Check if a logging category is enabled."""
130+
return get_log_config().is_enabled(category)
131+
132+
133+
log_config = _LogConfigProxy()

app/config/settings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ class Settings(BaseSettings):
111111
)
112112
admin_pages_timeout: int = 86400
113113

114+
# Logging settings
115+
log_path: str = "./logs"
116+
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
117+
log_rotation: str = "1 day" # "500 MB", "1 week", etc.
118+
log_retention: str = "30 days"
119+
log_compression: str = "zip"
120+
log_categories: str = (
121+
"ALL" # ALL, NONE, or comma-separated: REQUESTS,AUTH,DATABASE
122+
)
123+
log_filename: str = "api.log"
124+
log_console_enabled: bool = False
125+
114126
# gatekeeper settings!
115127
# this is to ensure that people read the damn instructions and changelogs
116128
i_read_the_damn_docs: bool = False

app/logs.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,49 @@
1-
"""Get the 'uvicorn' logger so we can use it in our own logger."""
1+
"""Application logging using loguru with category-based control."""
22

3-
import logging
3+
from __future__ import annotations
44

5-
logger = logging.getLogger("uvicorn")
5+
from typing import TYPE_CHECKING
6+
7+
from loguru import logger
8+
9+
from app.config.log_config import LogCategory, log_config
10+
11+
if TYPE_CHECKING:
12+
from loguru import Logger
13+
14+
15+
class CategoryLogger:
16+
"""Logger wrapper that checks categories before logging.
17+
18+
This eliminates the need for if-statements in calling code, reducing
19+
cyclomatic complexity.
20+
"""
21+
22+
def __init__(self, logger: Logger) -> None:
23+
"""Initialize with a loguru logger instance."""
24+
self._logger = logger
25+
26+
def info(self, message: str, category: LogCategory) -> None:
27+
"""Log an info message if the category is enabled."""
28+
if log_config.is_enabled(category):
29+
self._logger.info(message)
30+
31+
def error(self, message: str, category: LogCategory) -> None:
32+
"""Log an error message if the category is enabled."""
33+
if log_config.is_enabled(category):
34+
self._logger.error(message)
35+
36+
def warning(self, message: str, category: LogCategory) -> None:
37+
"""Log a warning message if the category is enabled."""
38+
if log_config.is_enabled(category):
39+
self._logger.warning(message)
40+
41+
def debug(self, message: str, category: LogCategory) -> None:
42+
"""Log a debug message if the category is enabled."""
43+
if log_config.is_enabled(category):
44+
self._logger.debug(message)
45+
46+
47+
category_logger = CategoryLogger(logger)
48+
49+
__all__ = ["LogCategory", "category_logger", "log_config", "logger"]

app/main.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Main file for the FastAPI Template."""
22

3+
import logging
34
import sys
45
from collections.abc import AsyncGenerator
56
from contextlib import asynccontextmanager
@@ -15,10 +16,13 @@
1516
from app.config.helpers import get_api_version, get_project_root
1617
from app.config.settings import get_settings
1718
from app.database.db import async_session
18-
from app.logs import logger
19+
from app.middleware.logging_middleware import LoggingMiddleware
1920
from app.resources import config_error
2021
from app.resources.routes import api_router
2122

23+
# Use standard logging for startup messages (before loguru is initialized)
24+
logger = logging.getLogger("uvicorn")
25+
2226
BLIND_USER_ERROR = 66
2327

2428
# gatekeeper to ensure the user has read the docs and noted the major changes
@@ -49,8 +53,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, None]:
4953
await session.connection()
5054

5155
logger.info("Database configuration Tested.")
52-
except SQLAlchemyError as exc:
53-
logger.error(f"Have you set up your .env file?? ({exc})")
56+
except SQLAlchemyError:
57+
logger.exception("Have you set up your .env file??")
5458
logger.warning("Clearing routes and enabling error message.")
5559
app.routes.clear()
5660
app.include_router(config_error.router)
@@ -96,5 +100,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, None]:
96100
allow_headers=["*"],
97101
)
98102

103+
# Add logging middleware
104+
app.add_middleware(LoggingMiddleware)
105+
99106
# Add pagination support
100107
add_pagination(app)

0 commit comments

Comments
 (0)