Skip to content
Draft
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
81 changes: 81 additions & 0 deletions logrotate.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Logrotate Configuration for FastAPI Application Logs
# =====================================================
#
# This configuration file manages log rotation for the FastAPI application.
# It should be placed in /etc/logrotate.d/ on Linux systems.
#
# Installation:
# sudo cp logrotate.conf /etc/logrotate.d/fastapi-app
#
# Test configuration:
# sudo logrotate -d /etc/logrotate.d/fastapi-app
#
# Force rotation:
# sudo logrotate -f /etc/logrotate.d/fastapi-app

# API Server Logs
/var/log/fastapi/*.log /app/logs/*.log {
# Rotate logs daily
daily

# Keep 14 days of logs
rotate 14

# Compress old logs with gzip
compress

# Don't compress the most recent rotated file (allows apps to keep writing)
delaycompress

# Don't error if log file is missing
missingok

# Don't rotate if empty
notifempty

# Create new log file with same permissions
create 0644 root root

# Add date extension to rotated files
dateext
dateformat -%Y%m%d

# Truncate original file after copying (for apps that don't handle SIGHUP)
copytruncate

# Run scripts only once even if multiple logs match
sharedscripts

# Optional: Notify application after rotation
# postrotate
# systemctl reload fastapi-app 2>/dev/null || true
# endscript
}

# Access Logs (if using nginx reverse proxy)
/var/log/nginx/fastapi-access.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0644 www-data www-data
dateext

postrotate
[ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid)
endscript
}

# Error Logs (keep longer for debugging)
/var/log/fastapi/error.log {
weekly
rotate 52
compress
delaycompress
missingok
notifempty
create 0644 root root
dateext
}
43 changes: 41 additions & 2 deletions src/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ class FirstUserSettings(BaseSettings):
ADMIN_PASSWORD: str = "!Ch4ng3Th1sP4ssW0rd!"


class TestSettings(BaseSettings):
...
class TestSettings(BaseSettings): ...


class RedisCacheSettings(BaseSettings):
Expand Down Expand Up @@ -149,6 +148,45 @@ class CORSSettings(BaseSettings):
CORS_HEADERS: list[str] = ["*"]


class LoggingSettings(BaseSettings):
"""Settings for application logging configuration.

Attributes
----------
LOG_LEVEL : str
Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Default: INFO
LOG_DIR : str
Directory for log files. Default: ./logs
LOG_FILE : str
Log file name. Default: api.log
LOG_MAX_BYTES : int
Maximum size of each log file in bytes before rotation. Default: 10MB
LOG_BACKUP_COUNT : int
Number of backup files to keep. Default: 5
LOG_FORMAT : str
Log message format string.
LOG_DATE_FORMAT : str
Date format for log timestamps.
LOG_REQUEST_BODY : bool
Whether to log request bodies (use with caution in production). Default: False
LOG_RESPONSE_BODY : bool
Whether to log response bodies (use with caution in production). Default: False
LOG_EXCLUDE_PATHS : list[str]
Paths to exclude from request/response logging (e.g., health checks).
"""

LOG_LEVEL: str = "INFO"
LOG_DIR: str = "./logs"
LOG_FILE: str = "api.log"
LOG_MAX_BYTES: int = 10485760 # 10MB
LOG_BACKUP_COUNT: int = 5
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
LOG_DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S"
LOG_REQUEST_BODY: bool = True
LOG_RESPONSE_BODY: bool = True
LOG_EXCLUDE_PATHS: list[str] = ["/health", "/metrics", "/favicon.ico"]


class Settings(
AppSettings,
SQLiteSettings,
Expand All @@ -164,6 +202,7 @@ class Settings(
CRUDAdminSettings,
EnvironmentSettings,
CORSSettings,
LoggingSettings,
):
model_config = SettingsConfigDict(
env_file=os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", ".env"),
Expand Down
139 changes: 133 additions & 6 deletions src/app/core/logger.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,147 @@
"""
Enhanced logging configuration with file rotation and configurable settings.

This module provides a centralized logging setup that:
- Supports console and file logging
- Uses RotatingFileHandler for automatic log rotation
- Respects environment-based configuration via LoggingSettings
- Provides colored console output for development
"""

import logging
import os
import sys
from logging.handlers import RotatingFileHandler
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from .config import LoggingSettings


# ANSI color codes for console output
class LogColors:
"""ANSI color codes for log level highlighting."""

RESET = "\033[0m"
DEBUG = "\033[36m" # Cyan
INFO = "\033[32m" # Green
WARNING = "\033[33m" # Yellow
ERROR = "\033[31m" # Red
CRITICAL = "\033[35m" # Magenta


class ColoredFormatter(logging.Formatter):
"""Custom formatter that adds colors to log levels for console output."""

LEVEL_COLORS = {
logging.DEBUG: LogColors.DEBUG,
logging.INFO: LogColors.INFO,
logging.WARNING: LogColors.WARNING,
logging.ERROR: LogColors.ERROR,
logging.CRITICAL: LogColors.CRITICAL,
}

def format(self, record: logging.LogRecord) -> str:
# Add color to the level name
color = self.LEVEL_COLORS.get(record.levelno, LogColors.RESET)
record.levelname = f"{color}{record.levelname}{LogColors.RESET}"
return super().format(record)


def setup_logging(settings: "LoggingSettings") -> logging.Logger:
"""Configure and return the root logger based on settings.

Parameters
----------
settings : LoggingSettings
Logging configuration settings.

Returns
-------
logging.Logger
Configured root logger instance.
"""
# Create log directory if it doesn't exist
log_dir = settings.LOG_DIR
if not os.path.isabs(log_dir):
# Make relative paths relative to the app directory
log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), log_dir)

os.makedirs(log_dir, exist_ok=True)

log_file_path = os.path.join(log_dir, settings.LOG_FILE)

# Get numeric log level
log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)

# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(log_level)

# Remove existing handlers to avoid duplicates
root_logger.handlers.clear()

# Console handler with colors
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(log_level)
console_formatter = ColoredFormatter(
fmt=settings.LOG_FORMAT,
datefmt=settings.LOG_DATE_FORMAT,
)
console_handler.setFormatter(console_formatter)
root_logger.addHandler(console_handler)

# File handler with rotation
file_handler = RotatingFileHandler(
filename=log_file_path,
maxBytes=settings.LOG_MAX_BYTES,
backupCount=settings.LOG_BACKUP_COUNT,
encoding="utf-8",
)
file_handler.setLevel(log_level)
file_formatter = logging.Formatter(
fmt=settings.LOG_FORMAT,
datefmt=settings.LOG_DATE_FORMAT,
)
file_handler.setFormatter(file_formatter)
root_logger.addHandler(file_handler)

# Reduce noise from third-party libraries
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)

return root_logger


def get_logger(name: str) -> logging.Logger:
"""Get a logger instance for the given name.

Parameters
----------
name : str
Name for the logger (typically __name__).

Returns
-------
logging.Logger
Logger instance.
"""
return logging.getLogger(name)


# Default initialization for backwards compatibility
# This will be overridden when setup_logging is called with settings
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs")
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)

LOG_FILE_PATH = os.path.join(LOG_DIR, "app.log")

LOGGING_LEVEL = logging.INFO
LOGGING_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"

logging.basicConfig(level=LOGGING_LEVEL, format=LOGGING_FORMAT)

file_handler = RotatingFileHandler(LOG_FILE_PATH, maxBytes=10485760, backupCount=5)
file_handler.setLevel(LOGGING_LEVEL)
file_handler.setFormatter(logging.Formatter(LOGGING_FORMAT))

logging.getLogger("").addHandler(file_handler)
_file_handler = RotatingFileHandler(LOG_FILE_PATH, maxBytes=10485760, backupCount=5)
_file_handler.setLevel(LOGGING_LEVEL)
_file_handler.setFormatter(logging.Formatter(LOGGING_FORMAT))
logging.getLogger("").addHandler(_file_handler)
29 changes: 28 additions & 1 deletion src/app/core/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from ..api.dependencies import get_current_superuser
from ..core.utils.rate_limit import rate_limiter
from ..middleware import ExceptionHandlerMiddleware, LoggingMiddleware, setup_exception_handlers
from ..middleware.client_cache_middleware import ClientCacheMiddleware
from ..models import * # noqa: F403
from .config import (
Expand All @@ -23,13 +24,15 @@
DatabaseSettings,
EnvironmentOption,
EnvironmentSettings,
LoggingSettings,
RedisCacheSettings,
RedisQueueSettings,
RedisRateLimiterSettings,
settings,
)
from .db.database import Base
from .db.database import async_engine as engine
from .logger import setup_logging
from .utils import cache, queue


Expand Down Expand Up @@ -86,6 +89,7 @@ def lifespan_factory(
| RedisQueueSettings
| RedisRateLimiterSettings
| EnvironmentSettings
| LoggingSettings
),
create_tables_on_start: bool = True,
) -> Callable[[FastAPI], _AsyncGeneratorContextManager[Any]]:
Expand Down Expand Up @@ -142,6 +146,7 @@ def create_application(
| RedisQueueSettings
| RedisRateLimiterSettings
| EnvironmentSettings
| LoggingSettings
),
create_tables_on_start: bool = True,
lifespan: Callable[[FastAPI], _AsyncGeneratorContextManager[Any]] | None = None,
Expand Down Expand Up @@ -208,8 +213,30 @@ def create_application(
application = FastAPI(lifespan=lifespan, **kwargs)
application.include_router(router)

# Initialize logging if LoggingSettings is provided
if isinstance(settings, LoggingSettings):
setup_logging(settings)

# Register exception handlers (catches validation errors during request parsing)
# This must be done BEFORE adding middlewares
setup_exception_handlers(application)

# Exception handler middleware (catches exceptions during request processing)
if isinstance(settings, EnvironmentSettings):
is_debug = settings.ENVIRONMENT != EnvironmentOption.PRODUCTION
application.add_middleware(ExceptionHandlerMiddleware, debug=is_debug) # type: ignore[arg-type]

# Logging middleware (logs all requests/responses)
if isinstance(settings, LoggingSettings):
application.add_middleware(
LoggingMiddleware, # type: ignore[arg-type]
log_request_body=settings.LOG_REQUEST_BODY,
log_response_body=settings.LOG_RESPONSE_BODY,
exclude_paths=settings.LOG_EXCLUDE_PATHS,
)

if isinstance(settings, ClientSideCacheSettings):
application.add_middleware(ClientCacheMiddleware, max_age=settings.CLIENT_CACHE_MAX_AGE)
application.add_middleware(ClientCacheMiddleware, max_age=settings.CLIENT_CACHE_MAX_AGE) # type: ignore[arg-type]

if isinstance(settings, CORSSettings):
application.add_middleware(
Expand Down
19 changes: 19 additions & 0 deletions src/app/middleware/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""
Middleware package for FastAPI application.

This package contains custom middleware classes for:
- Exception handling with consistent JSON responses
- Request/Response logging
- Client-side cache headers
"""

from .client_cache_middleware import ClientCacheMiddleware
from .exception_handler_middleware import ExceptionHandlerMiddleware, setup_exception_handlers
from .logging_middleware import LoggingMiddleware

__all__ = [
"ClientCacheMiddleware",
"ExceptionHandlerMiddleware",
"LoggingMiddleware",
"setup_exception_handlers",
]
Loading