TCPDashboard/utils/logger.py

341 lines
13 KiB
Python
Raw Normal View History

"""
Unified logging system for the TCP Dashboard project.
Provides centralized logging with:
- Component-specific log directories
- Date-based file rotation
- Unified log format: [YYYY-MM-DD HH:MM:SS - LEVEL - message]
- Thread-safe operations
- Automatic directory creation
- Verbose console logging with proper level handling
- Automatic old log cleanup
Usage:
from utils.logger import get_logger
logger = get_logger('bot_manager')
logger.info("This is an info message")
logger.error("This is an error message")
# With verbose console output
logger = get_logger('bot_manager', verbose=True)
# With custom cleanup settings
logger = get_logger('bot_manager', clean_old_logs=True, max_log_files=7)
"""
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
import threading
class DateRotatingFileHandler(logging.FileHandler):
"""
Custom file handler that rotates log files based on date changes.
Creates new log files when the date changes to ensure daily separation.
"""
def __init__(self, log_dir: Path, component_name: str, cleanup_callback=None, max_files=30):
self.log_dir = log_dir
self.component_name = component_name
self.current_date = None
self.cleanup_callback = cleanup_callback
self.max_files = max_files
self._lock = threading.Lock()
# Initialize with today's file
self._update_filename()
super().__init__(self.current_filename, mode='a', encoding='utf-8')
def _update_filename(self):
"""Update the filename based on current date."""
today = datetime.now().strftime('%Y-%m-%d')
if self.current_date != today:
self.current_date = today
self.current_filename = self.log_dir / f"{today}.txt"
# Ensure the directory exists
self.log_dir.mkdir(parents=True, exist_ok=True)
# Cleanup old logs if callback is provided
if self.cleanup_callback:
self.cleanup_callback(self.component_name, self.max_files)
def emit(self, record):
"""Emit a log record, rotating file if date has changed."""
with self._lock:
# Check if we need to rotate to a new file
today = datetime.now().strftime('%Y-%m-%d')
if self.current_date != today:
# Close current file
if hasattr(self, 'stream') and self.stream:
self.stream.close()
# Update filename and reopen (this will trigger cleanup)
self._update_filename()
self.baseFilename = str(self.current_filename)
self.stream = self._open()
super().emit(record)
class UnifiedLogger:
"""
Unified logger class that manages component-specific loggers with consistent formatting.
"""
_loggers: Dict[str, logging.Logger] = {}
_lock = threading.Lock()
@classmethod
def get_logger(cls, component_name: str, log_level: str = "INFO",
verbose: Optional[bool] = None, clean_old_logs: bool = True,
max_log_files: int = 30) -> logging.Logger:
"""
Get or create a logger for the specified component.
Args:
component_name: Name of the component (e.g., 'bot_manager', 'data_collector')
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
verbose: Enable console logging. If None, uses VERBOSE_LOGGING from .env
clean_old_logs: Automatically clean old log files when creating new ones
max_log_files: Maximum number of log files to keep (default: 30)
Returns:
Configured logger instance for the component
"""
# Create a unique key for logger configuration
logger_key = f"{component_name}_{log_level}_{verbose}_{clean_old_logs}_{max_log_files}"
with cls._lock:
if logger_key in cls._loggers:
return cls._loggers[logger_key]
# Create new logger
logger = logging.getLogger(f"tcp_dashboard.{component_name}.{hash(logger_key) % 10000}")
logger.setLevel(getattr(logging, log_level.upper()))
# Prevent duplicate handlers if logger already exists
if logger.handlers:
logger.handlers.clear()
# Create log directory for component
log_dir = Path("logs") / component_name
try:
# Setup cleanup callback if enabled
cleanup_callback = cls._cleanup_old_logs if clean_old_logs else None
# Add date-rotating file handler
file_handler = DateRotatingFileHandler(
log_dir, component_name, cleanup_callback, max_log_files
)
file_handler.setLevel(logging.DEBUG)
# Create unified formatter
formatter = logging.Formatter(
'[%(asctime)s - %(levelname)s - %(message)s]',
datefmt='%Y-%m-%d %H:%M:%S'
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Add console handler based on verbose setting
should_log_to_console = cls._should_enable_console_logging(verbose)
if should_log_to_console:
console_handler = logging.StreamHandler()
# Set console log level based on log_level with proper type handling
console_level = cls._get_console_log_level(log_level)
console_handler.setLevel(console_level)
# Use colored formatter for console if available
console_formatter = cls._get_console_formatter()
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
# Prevent propagation to root logger
logger.propagate = False
cls._loggers[logger_key] = logger
# Log initialization
logger.info(f"Logger initialized for component: {component_name} "
f"(verbose={should_log_to_console}, cleanup={clean_old_logs}, "
f"max_files={max_log_files})")
except Exception as e:
# Fallback to console logging if file logging fails
print(f"Warning: Failed to setup file logging for {component_name}: {e}")
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
formatter = logging.Formatter('[%(asctime)s - %(levelname)s - %(message)s]')
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
logger.propagate = False
cls._loggers[logger_key] = logger
return logger
@classmethod
def _should_enable_console_logging(cls, verbose: Optional[bool]) -> bool:
"""
Determine if console logging should be enabled.
Args:
verbose: Explicit verbose setting, or None to use environment variable
Returns:
True if console logging should be enabled
"""
if verbose is not None:
return verbose
# Check environment variables
env_verbose = os.getenv('VERBOSE_LOGGING', 'false').lower()
env_console = os.getenv('LOG_TO_CONSOLE', 'false').lower()
return env_verbose in ('true', '1', 'yes') or env_console in ('true', '1', 'yes')
@classmethod
def _get_console_log_level(cls, log_level: str) -> int:
"""
Get appropriate console log level based on file log level.
Args:
log_level: File logging level
Returns:
Console logging level (integer)
"""
# Map file log levels to console log levels
# Generally, console should be less verbose than file
level_mapping = {
'DEBUG': logging.DEBUG, # Show all debug info on console too
'INFO': logging.INFO, # Show info and above
'WARNING': logging.WARNING, # Show warnings and above
'ERROR': logging.ERROR, # Show errors and above
'CRITICAL': logging.CRITICAL # Show only critical
}
return level_mapping.get(log_level.upper(), logging.INFO)
@classmethod
def _get_console_formatter(cls) -> logging.Formatter:
"""
Get formatter for console output with potential color support.
Returns:
Configured formatter for console output
"""
# Basic formatter - could be enhanced with colors in the future
return logging.Formatter(
'[%(asctime)s - %(levelname)s - %(message)s]',
datefmt='%Y-%m-%d %H:%M:%S'
)
@classmethod
def _cleanup_old_logs(cls, component_name: str, max_files: int = 30):
"""
Clean up old log files for a component, keeping only the most recent files.
Args:
component_name: Name of the component
max_files: Maximum number of log files to keep
"""
log_dir = Path("logs") / component_name
if not log_dir.exists():
return
# Get all log files sorted by modification time (newest first)
log_files = sorted(
log_dir.glob("*.txt"),
key=lambda f: f.stat().st_mtime,
reverse=True
)
# Keep only the most recent max_files
files_to_delete = log_files[max_files:]
for log_file in files_to_delete:
try:
log_file.unlink()
# Only log to console to avoid recursive logging
if cls._should_enable_console_logging(None):
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - INFO - "
f"Deleted old log file: {log_file}]")
except Exception as e:
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - WARNING - "
f"Failed to delete old log file {log_file}: {e}]")
@classmethod
def cleanup_old_logs(cls, component_name: str, days_to_keep: int = 30):
"""
Clean up old log files for a component based on age.
Args:
component_name: Name of the component
days_to_keep: Number of days of logs to retain
"""
log_dir = Path("logs") / component_name
if not log_dir.exists():
return
cutoff_date = datetime.now().timestamp() - (days_to_keep * 24 * 60 * 60)
for log_file in log_dir.glob("*.txt"):
if log_file.stat().st_mtime < cutoff_date:
try:
log_file.unlink()
print(f"Deleted old log file: {log_file}")
except Exception as e:
print(f"Failed to delete old log file {log_file}: {e}")
# Convenience function for easy import
def get_logger(component_name: str, log_level: str = "INFO",
verbose: Optional[bool] = None, clean_old_logs: bool = True,
max_log_files: int = 30) -> logging.Logger:
"""
Get a logger instance for the specified component.
Args:
component_name: Name of the component (e.g., 'bot_manager', 'data_collector')
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
verbose: Enable console logging. If None, uses VERBOSE_LOGGING from .env
clean_old_logs: Automatically clean old log files when creating new ones
max_log_files: Maximum number of log files to keep (default: 30)
Returns:
Configured logger instance
Example:
from utils.logger import get_logger
# Basic usage
logger = get_logger('bot_manager')
# With verbose console output
logger = get_logger('bot_manager', verbose=True)
# With custom cleanup settings
logger = get_logger('bot_manager', clean_old_logs=True, max_log_files=7)
logger.info("Bot started successfully")
logger.error("Connection failed", exc_info=True)
"""
return UnifiedLogger.get_logger(component_name, log_level, verbose, clean_old_logs, max_log_files)
def cleanup_old_logs(component_name: str, days_to_keep: int = 30):
"""
Clean up old log files for a component based on age.
Args:
component_name: Name of the component
days_to_keep: Number of days of logs to retain (default: 30)
"""
UnifiedLogger.cleanup_old_logs(component_name, days_to_keep)