Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,47 @@ MAIL_STARTTLS=True
MAIL_SSL_TLS=False
MAIL_USE_CREDENTIALS=True
MAIL_VALIDATE_CERTS=True

# Logging Configuration
# Directory where log files will be written (must be writable by the application)
LOG_PATH=./logs

# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
# DEBUG shows all messages, INFO shows normal operations, WARNING and above show only issues
LOG_LEVEL=INFO

# When to rotate log files: "500 MB", "1 day", "1 week", etc.
LOG_ROTATION=1 day

# How long to keep old log files: "7 days", "30 days", "1 month", etc.
LOG_RETENTION=30 days

# Compression format for rotated logs: "zip", "gz", "bz2", or empty for no compression
LOG_COMPRESSION=zip

# What to log - choose one or combine with commas:
# ALL - Log everything (recommended for development/debugging)
# NONE - Disable logging
# REQUESTS - HTTP request/response logging
# AUTH - Authentication, login, token operations
# DATABASE - Database CRUD operations
# EMAIL - Email sending operations
# ERRORS - Error conditions (always recommended)
# ADMIN - Admin panel operations
# API_KEYS - API key operations
#
# Examples:
# LOG_CATEGORIES=ALL # Log everything
# LOG_CATEGORIES=ERRORS,AUTH,DATABASE # Only log errors, auth, and database operations
# LOG_CATEGORIES=REQUESTS,ERRORS # Only log requests and errors
LOG_CATEGORIES=ALL

# Set the log filename (default: api.log)
# Useful for separating logs in different environments (e.g., test_api.log for tests)
LOG_FILENAME=api.log

# Enable console logging (default: false)
# Set to true if you want loguru to also log to console
# Note: FastAPI/Uvicorn already logs to console, so this is usually not needed
# and will cause duplicate console output
LOG_CONSOLE_ENABLED=false
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,7 @@ docs/CNAME
.cursorrules
.windsurfrules
users.seed

# Application logs
logs/
*.log.*
17 changes: 10 additions & 7 deletions app/config/helpers.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""Helper classes and functions for config use."""

import logging
import sys
from dataclasses import dataclass
from importlib import resources
from pathlib import Path

import rtoml

from app.logs import logger
logger = logging.getLogger("uvicorn")


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

except KeyError:
logger.error("Cannot find the API version in the pyproject.toml file")
logger.error( # noqa: TRY400
"Cannot find the API version in the pyproject.toml file"
)
sys.exit(2)

except OSError as exc:
logger.error(f"Cannot read the pyproject.toml file : {exc}")
except OSError:
logger.exception("Cannot read the pyproject.toml file")
sys.exit(2)

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

except KeyError:
logger.error(
logger.error( # noqa: TRY400
"Missing name/description or authors in the pyproject.toml file"
)
sys.exit(2)

except OSError as exc:
logger.error(f"Cannot read the pyproject.toml file : {exc}")
except OSError:
logger.exception("Cannot read the pyproject.toml file")
sys.exit(2)
else:
return (name, desc, authors)
Expand Down
133 changes: 133 additions & 0 deletions app/config/log_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Logging configuration using loguru with category-based control."""

from __future__ import annotations

import sys
from enum import Flag, auto
from pathlib import Path

from loguru import logger


class LogCategory(Flag):
"""Bit flags for logging categories.

Allows combining multiple categories.
"""

NONE = 0
REQUESTS = auto() # HTTP request/response logging
AUTH = auto() # Authentication, login, token operations
DATABASE = auto() # Database CRUD operations
EMAIL = auto() # Email sending operations
ERRORS = auto() # Error conditions (always recommended)
ADMIN = auto() # Admin panel operations
API_KEYS = auto() # API key operations
ALL = REQUESTS | AUTH | DATABASE | EMAIL | ERRORS | ADMIN | API_KEYS


class LogConfig:
"""Logging configuration from environment variables."""

def __init__(self) -> None:
"""Initialize logging configuration from settings."""
# Import here to avoid circular dependency
from app.config.settings import get_settings # noqa: PLC0415

settings = get_settings()

# Get configuration from .env
self.log_path = Path(getattr(settings, "log_path", "./logs"))
self.log_level = getattr(settings, "log_level", "INFO")
self.log_rotation = getattr(settings, "log_rotation", "1 day")
self.log_retention = getattr(settings, "log_retention", "30 days")
self.log_compression = getattr(settings, "log_compression", "zip")
self.log_filename = getattr(settings, "log_filename", "api.log")
self.console_enabled = getattr(settings, "log_console_enabled", False)

# Validate filename doesn't contain path separators
if "/" in self.log_filename or "\\" in self.log_filename:
msg = (
"log_filename cannot contain path separators. "
"Use log_path to set the directory."
)
raise ValueError(msg)

# Parse enabled categories (comma-separated string or ALL)
categories_str = getattr(settings, "log_categories", "ALL")
self.enabled_categories = self._parse_categories(categories_str)

def _parse_categories(self, categories_str: str) -> LogCategory:
"""Parse comma-separated category string into LogCategory flags."""
if categories_str.upper() == "ALL":
return LogCategory.ALL
if categories_str.upper() == "NONE":
return LogCategory.NONE

result = LogCategory.NONE
for cat_str in categories_str.split(","):
cat_name = cat_str.strip().upper()
if hasattr(LogCategory, cat_name):
result |= getattr(LogCategory, cat_name)
return result

def is_enabled(self, category: LogCategory) -> bool:
"""Check if a logging category is enabled."""
return bool(self.enabled_categories & category)


def setup_logging() -> LogConfig:
"""Configure loguru with rotation, retention, and formatting."""
config = LogConfig()

# Remove default handler
logger.remove()

# Add console handler only if enabled
if config.console_enabled:
logger.add(
sys.stderr,
format="<level>{level: <8}</level> <level>{message}</level>",
level=config.log_level,
colorize=True,
)

# Add file handler with rotation - more detail for file logs
log_file = config.log_path / config.log_filename
config.log_path.mkdir(parents=True, exist_ok=True)

logger.add(
str(log_file),
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {message}",
level=config.log_level,
rotation=config.log_rotation,
retention=config.log_retention,
compression=config.log_compression,
enqueue=True, # Async logging
)

return config


# Global logger instance - lazy initialization to avoid circular imports
_log_config: LogConfig | None = None


def get_log_config() -> LogConfig:
"""Get or initialize the logging configuration."""
global _log_config # noqa: PLW0603
if _log_config is None:
_log_config = setup_logging()
return _log_config


# For backwards compatibility, create a property-like object
class _LogConfigProxy:
"""Proxy object that lazily initializes log config."""

def is_enabled(self, category: LogCategory) -> bool:
"""Check if a logging category is enabled."""
return get_log_config().is_enabled(category)


log_config = _LogConfigProxy()
12 changes: 12 additions & 0 deletions app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@ class Settings(BaseSettings):
)
admin_pages_timeout: int = 86400

# Logging settings
log_path: str = "./logs"
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
log_rotation: str = "1 day" # "500 MB", "1 week", etc.
log_retention: str = "30 days"
log_compression: str = "zip"
log_categories: str = (
"ALL" # ALL, NONE, or comma-separated: REQUESTS,AUTH,DATABASE
)
log_filename: str = "api.log"
log_console_enabled: bool = False

# gatekeeper settings!
# this is to ensure that people read the damn instructions and changelogs
i_read_the_damn_docs: bool = False
Expand Down
50 changes: 47 additions & 3 deletions app/logs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,49 @@
"""Get the 'uvicorn' logger so we can use it in our own logger."""
"""Application logging using loguru with category-based control."""

import logging
from __future__ import annotations

logger = logging.getLogger("uvicorn")
from typing import TYPE_CHECKING

from loguru import logger

from app.config.log_config import LogCategory, log_config

if TYPE_CHECKING:
from loguru import Logger


class CategoryLogger:
"""Logger wrapper that checks categories before logging.

This eliminates the need for if-statements in calling code, reducing
cyclomatic complexity.
"""

def __init__(self, logger: Logger) -> None:
"""Initialize with a loguru logger instance."""
self._logger = logger

def info(self, message: str, category: LogCategory) -> None:
"""Log an info message if the category is enabled."""
if log_config.is_enabled(category):
self._logger.info(message)

def error(self, message: str, category: LogCategory) -> None:
"""Log an error message if the category is enabled."""
if log_config.is_enabled(category):
self._logger.error(message)

def warning(self, message: str, category: LogCategory) -> None:
"""Log a warning message if the category is enabled."""
if log_config.is_enabled(category):
self._logger.warning(message)

def debug(self, message: str, category: LogCategory) -> None:
"""Log a debug message if the category is enabled."""
if log_config.is_enabled(category):
self._logger.debug(message)


category_logger = CategoryLogger(logger)

__all__ = ["LogCategory", "category_logger", "log_config", "logger"]
13 changes: 10 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Main file for the FastAPI Template."""

import logging
import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
Expand All @@ -15,10 +16,13 @@
from app.config.helpers import get_api_version, get_project_root
from app.config.settings import get_settings
from app.database.db import async_session
from app.logs import logger
from app.middleware.logging_middleware import LoggingMiddleware
from app.resources import config_error
from app.resources.routes import api_router

# Use standard logging for startup messages (before loguru is initialized)
logger = logging.getLogger("uvicorn")

BLIND_USER_ERROR = 66

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

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

# Add logging middleware
app.add_middleware(LoggingMiddleware)

# Add pagination support
add_pagination(app)
Loading