|
1 | 1 | import logging |
| 2 | +import logging.config |
2 | 3 | import os |
3 | | -from logging.handlers import RotatingFileHandler |
| 4 | +from datetime import UTC, datetime |
| 5 | +from pathlib import Path |
| 6 | +from typing import Any |
4 | 7 |
|
5 | | -LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs") |
6 | | -if not os.path.exists(LOG_DIR): |
7 | | - os.makedirs(LOG_DIR) |
| 8 | +from pythonjsonlogger.json import JsonFormatter |
8 | 9 |
|
9 | | -LOG_FILE_PATH = os.path.join(LOG_DIR, "app.log") |
| 10 | +from .config import EnvironmentOption, settings |
10 | 11 |
|
11 | | -LOGGING_LEVEL = logging.INFO |
12 | | -LOGGING_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" |
13 | 12 |
|
14 | | -logging.basicConfig(level=LOGGING_LEVEL, format=LOGGING_FORMAT) |
| 13 | +class ColoredFormatter(logging.Formatter): |
| 14 | + """Colored formatter for development console output.""" |
15 | 15 |
|
16 | | -file_handler = RotatingFileHandler(LOG_FILE_PATH, maxBytes=10485760, backupCount=5) |
17 | | -file_handler.setLevel(LOGGING_LEVEL) |
18 | | -file_handler.setFormatter(logging.Formatter(LOGGING_FORMAT)) |
| 16 | + COLORS = { |
| 17 | + "DEBUG": "\033[36m", # Cyan |
| 18 | + "INFO": "\033[32m", # Green |
| 19 | + "WARNING": "\033[33m", # Yellow |
| 20 | + "ERROR": "\033[31m", # Red |
| 21 | + "CRITICAL": "\033[35m", # Magenta |
| 22 | + } |
| 23 | + RESET = "\033[0m" |
19 | 24 |
|
20 | | -logging.getLogger("").addHandler(file_handler) |
| 25 | + def format(self, record: logging.LogRecord) -> str: |
| 26 | + # Create a copy of the record to avoid modifying the original |
| 27 | + record_copy = logging.makeLogRecord(record.__dict__) |
| 28 | + log_color = self.COLORS.get(record_copy.levelname, "") |
| 29 | + record_copy.levelname = f"{log_color}{record_copy.levelname}{self.RESET}" |
| 30 | + return super().format(record_copy) |
| 31 | + |
| 32 | + |
| 33 | +def get_log_level() -> int: |
| 34 | + """Get log level from environment with validation.""" |
| 35 | + log_level_name = os.getenv("LOG_LEVEL", "INFO").upper() |
| 36 | + |
| 37 | + level = logging.getLevelNamesMapping().get(log_level_name) |
| 38 | + if level is None: |
| 39 | + raise ValueError(f"Invalid log level '{log_level_name}'") |
| 40 | + |
| 41 | + return level |
| 42 | + |
| 43 | + |
| 44 | +def log_directory() -> Path: |
| 45 | + """Ensure log directory exists and return the path.""" |
| 46 | + log_dir = Path(__file__).parent.parent.parent / "logs" |
| 47 | + log_dir.mkdir(parents=True, exist_ok=True) |
| 48 | + return log_dir |
| 49 | + |
| 50 | + |
| 51 | +def get_logging_config() -> dict[str, Any]: |
| 52 | + """Get logging configuration based on environment.""" |
| 53 | + log_level = get_log_level() |
| 54 | + |
| 55 | + # Base configuration |
| 56 | + config: dict[str, Any] = { |
| 57 | + "version": 1, |
| 58 | + "disable_existing_loggers": False, |
| 59 | + "formatters": { |
| 60 | + "development": { |
| 61 | + "()": ColoredFormatter, |
| 62 | + "format": "%(asctime)s- %(levelname)s - %(name)s - %(message)s", |
| 63 | + "datefmt": "%Y-%m-%d %H:%M:%S", |
| 64 | + }, |
| 65 | + "file": { |
| 66 | + "format": "%(asctime)s- %(levelname)s - %(name)s - %(message)s", |
| 67 | + "datefmt": "%Y-%m-%d %H:%M:%S", |
| 68 | + }, |
| 69 | + "json": { |
| 70 | + "()": JsonFormatter, |
| 71 | + "format": "%(asctime)s %(levelname)s %(name)s %(message)s %(pathname)s %(lineno)d", |
| 72 | + }, |
| 73 | + }, |
| 74 | + "handlers": { |
| 75 | + "console": {"class": "logging.StreamHandler", "level": log_level, "stream": "ext://sys.stdout"}, |
| 76 | + }, |
| 77 | + "root": {"level": log_level, "handlers": []}, |
| 78 | + "loggers": { |
| 79 | + "uvicorn.access": { |
| 80 | + "level": "INFO", |
| 81 | + "handlers": [], |
| 82 | + "propagate": False, # Don't propagate to root logger to avoid double logging |
| 83 | + }, |
| 84 | + "uvicorn.error": {"level": "INFO"}, |
| 85 | + "sqlalchemy.engine": {"level": "WARNING"}, # Hide SQL queries unless warning/error |
| 86 | + "sqlalchemy.pool": {"level": "WARNING"}, |
| 87 | + "httpx": {"level": "WARNING"}, # External HTTP client logs |
| 88 | + "httpcore": {"level": "WARNING"}, |
| 89 | + }, |
| 90 | + } |
| 91 | + |
| 92 | + # Environment-specific configuration |
| 93 | + if settings.ENVIRONMENT == EnvironmentOption.LOCAL: |
| 94 | + # Create file handler only when needed |
| 95 | + log_dir = log_directory() |
| 96 | + # Keeping filename timestamp granularity to minutes to avoid too |
| 97 | + # many log files during development and reloding. Keeping it human |
| 98 | + # readable for easier debugging using 3 letter month, in AM/PM format |
| 99 | + # and without the year. It has to be in UTC as it runs in containers. |
| 100 | + timestamp = datetime.now(UTC).strftime("%d-%b_%I-%M%p_UTC") |
| 101 | + log_file = log_dir / f"web_{timestamp}.log" |
| 102 | + |
| 103 | + config["handlers"]["file"] = { |
| 104 | + "class": "logging.handlers.RotatingFileHandler", |
| 105 | + "level": log_level, |
| 106 | + "filename": str(log_file), |
| 107 | + "maxBytes": 10485760, # 10MB |
| 108 | + "backupCount": 5, |
| 109 | + "formatter": "file", |
| 110 | + } |
| 111 | + |
| 112 | + # Plain colored text + file logging |
| 113 | + config["handlers"]["console"]["formatter"] = "development" |
| 114 | + config["root"]["handlers"] = ["console", "file"] |
| 115 | + config["loggers"]["uvicorn.access"]["handlers"] = ["console", "file"] |
| 116 | + else: |
| 117 | + # As JSON messages to console |
| 118 | + config["handlers"]["console"]["formatter"] = "json" |
| 119 | + config["root"]["handlers"] = ["console"] |
| 120 | + config["loggers"]["uvicorn.access"]["handlers"] = ["console"] |
| 121 | + |
| 122 | + return config |
| 123 | + |
| 124 | + |
| 125 | +def setup_logging() -> None: |
| 126 | + """Setup logging configuration based on environment.""" |
| 127 | + config = get_logging_config() |
| 128 | + logging.config.dictConfig(config) |
| 129 | + |
| 130 | + # Log startup information |
| 131 | + logger = logging.getLogger(__name__) |
| 132 | + logger.info(f"Logging configured for {settings.ENVIRONMENT.value} environment") |
| 133 | + logger.info(f"Log level set to {logging.getLevelName(get_log_level())}") |
| 134 | + if "file" in config["root"]["handlers"]: |
| 135 | + logger.info(f"Logs will be written to the file {config['handlers']['file']['filename']}") |
| 136 | + if "console" in config["root"]["handlers"]: |
| 137 | + extra = "" |
| 138 | + if config["handlers"]["console"]["formatter"] == "json": |
| 139 | + extra = " in JSON format" |
| 140 | + logger.info(f"Logs will be written to the console{extra}") |
0 commit comments