399 lines
13 KiB
Python
399 lines
13 KiB
Python
"""
|
||
Centralized Logging System for Miku Discord Bot
|
||
|
||
This module provides a robust, component-based logging system with:
|
||
- Configurable log levels per component
|
||
- Emoji-based log formatting
|
||
- Multiple output handlers (console, separate log files per component)
|
||
- Runtime configuration updates
|
||
- API request filtering
|
||
- Docker-compatible output
|
||
|
||
Usage:
|
||
from utils.logger import get_logger
|
||
|
||
logger = get_logger('bot')
|
||
logger.info("Bot started successfully")
|
||
logger.error("Failed to connect", exc_info=True)
|
||
"""
|
||
|
||
import logging
|
||
import sys
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Optional, Dict
|
||
from logging.handlers import RotatingFileHandler
|
||
import json
|
||
|
||
# Log level emojis
|
||
LEVEL_EMOJIS = {
|
||
'DEBUG': '🔍',
|
||
'INFO': 'ℹ️',
|
||
'WARNING': '⚠️',
|
||
'ERROR': '❌',
|
||
'CRITICAL': '🔥',
|
||
'API': '🌐',
|
||
}
|
||
|
||
# Custom API log level (between INFO and WARNING)
|
||
API_LEVEL = 25
|
||
logging.addLevelName(API_LEVEL, 'API')
|
||
|
||
# Component definitions
|
||
COMPONENTS = {
|
||
'bot': 'Main bot lifecycle and events',
|
||
'api': 'FastAPI endpoints (non-HTTP)',
|
||
'api.requests': 'HTTP request/response logs',
|
||
'autonomous': 'Autonomous messaging system',
|
||
'persona': 'Bipolar/persona dialogue system',
|
||
'vision': 'Image and video processing',
|
||
'llm': 'LLM API calls and interactions',
|
||
'conversation': 'Conversation history management',
|
||
'mood': 'Mood system and state changes',
|
||
'dm': 'Direct message handling',
|
||
'scheduled': 'Scheduled tasks and cron jobs',
|
||
'gpu': 'GPU routing and model management',
|
||
'media': 'Media processing (audio, video, images)',
|
||
'server': 'Server management and configuration',
|
||
'commands': 'Command handling and routing',
|
||
'sentiment': 'Sentiment analysis',
|
||
'core': 'Core utilities and helpers',
|
||
'apscheduler': 'Job scheduler logs (APScheduler)',
|
||
'voice_manager': 'Voice channel session management',
|
||
'voice_commands': 'Voice channel commands',
|
||
'voice_audio': 'Voice audio streaming and TTS',
|
||
}
|
||
|
||
# Global configuration
|
||
_log_config: Optional[Dict] = None
|
||
_loggers: Dict[str, logging.Logger] = {}
|
||
_handlers_initialized = False
|
||
|
||
# Log directory (in mounted volume so logs persist)
|
||
LOG_DIR = Path(os.getenv('LOG_DIR', '/app/memory/logs'))
|
||
|
||
|
||
class EmojiFormatter(logging.Formatter):
|
||
"""Custom formatter that adds emojis and colors to log messages."""
|
||
|
||
def __init__(self, use_emojis=True, use_colors=False, timestamp_format='datetime', *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self.use_emojis = use_emojis
|
||
self.use_colors = use_colors
|
||
self.timestamp_format = timestamp_format
|
||
|
||
def format(self, record):
|
||
# Add emoji prefix
|
||
if self.use_emojis:
|
||
emoji = LEVEL_EMOJIS.get(record.levelname, '')
|
||
record.levelname_emoji = f"{emoji} {record.levelname}"
|
||
else:
|
||
record.levelname_emoji = record.levelname
|
||
|
||
# Format timestamp based on settings
|
||
if self.timestamp_format == 'off':
|
||
record.timestamp_formatted = ''
|
||
elif self.timestamp_format == 'time':
|
||
record.timestamp_formatted = self.formatTime(record, '%H:%M:%S') + ' '
|
||
elif self.timestamp_format == 'date':
|
||
record.timestamp_formatted = self.formatTime(record, '%Y-%m-%d') + ' '
|
||
elif self.timestamp_format == 'datetime':
|
||
record.timestamp_formatted = self.formatTime(record, '%Y-%m-%d %H:%M:%S') + ' '
|
||
else:
|
||
# Default to datetime if invalid option
|
||
record.timestamp_formatted = self.formatTime(record, '%Y-%m-%d %H:%M:%S') + ' '
|
||
|
||
# Format the message
|
||
return super().format(record)
|
||
|
||
|
||
class ComponentFilter(logging.Filter):
|
||
"""Filter logs based on component configuration with individual level toggles."""
|
||
|
||
def __init__(self, component_name: str):
|
||
super().__init__()
|
||
self.component_name = component_name
|
||
|
||
def filter(self, record):
|
||
"""Check if this log should be output based on enabled levels."""
|
||
config = get_log_config()
|
||
|
||
if not config:
|
||
return True
|
||
|
||
component_config = config.get('components', {}).get(self.component_name, {})
|
||
|
||
# Check if component is enabled
|
||
if not component_config.get('enabled', True):
|
||
return False
|
||
|
||
# Check if specific log level is enabled
|
||
enabled_levels = component_config.get('enabled_levels', ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL', 'API'])
|
||
|
||
# Get the level name for this record
|
||
level_name = logging.getLevelName(record.levelno)
|
||
|
||
return level_name in enabled_levels
|
||
|
||
|
||
def get_log_config() -> Optional[Dict]:
|
||
"""Get current log configuration."""
|
||
global _log_config
|
||
|
||
if _log_config is None:
|
||
# Try to load from file
|
||
config_path = Path('/app/memory/log_settings.json')
|
||
if config_path.exists():
|
||
try:
|
||
with open(config_path, 'r') as f:
|
||
_log_config = json.load(f)
|
||
except Exception:
|
||
_log_config = get_default_config()
|
||
else:
|
||
_log_config = get_default_config()
|
||
|
||
return _log_config
|
||
|
||
|
||
def get_default_config() -> Dict:
|
||
"""Get default logging configuration."""
|
||
# Read from environment variables
|
||
# Enable api.requests by default (now that uvicorn access logs are disabled)
|
||
enable_api_requests = os.getenv('LOG_ENABLE_API_REQUESTS', 'true').lower() == 'true'
|
||
use_emojis = os.getenv('LOG_USE_EMOJIS', 'true').lower() == 'true'
|
||
|
||
config = {
|
||
'version': '1.0',
|
||
'formatting': {
|
||
'use_emojis': use_emojis,
|
||
'use_colors': False,
|
||
'timestamp_format': 'datetime' # Options: 'off', 'time', 'date', 'datetime'
|
||
},
|
||
'components': {}
|
||
}
|
||
|
||
# Set defaults for each component
|
||
for component in COMPONENTS.keys():
|
||
if component == 'api.requests':
|
||
# API requests component defaults to only ERROR and CRITICAL
|
||
default_levels = ['ERROR', 'CRITICAL'] if not enable_api_requests else ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL', 'API']
|
||
config['components'][component] = {
|
||
'enabled': enable_api_requests,
|
||
'enabled_levels': default_levels,
|
||
'filters': {
|
||
'exclude_paths': ['/health', '/static/*'],
|
||
'exclude_status': [200, 304] if not enable_api_requests else [],
|
||
'include_slow_requests': True,
|
||
'slow_threshold_ms': 1000
|
||
}
|
||
}
|
||
elif component == 'apscheduler':
|
||
# APScheduler defaults to WARNING and above (lots of INFO noise)
|
||
config['components'][component] = {
|
||
'enabled': True,
|
||
'enabled_levels': ['WARNING', 'ERROR', 'CRITICAL']
|
||
}
|
||
else:
|
||
# All other components default to all levels enabled
|
||
config['components'][component] = {
|
||
'enabled': True,
|
||
'enabled_levels': ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||
}
|
||
|
||
return config
|
||
|
||
|
||
def reload_config():
|
||
"""Reload configuration from file."""
|
||
global _log_config
|
||
_log_config = None
|
||
get_log_config()
|
||
|
||
# Update all existing loggers
|
||
for component_name, logger in _loggers.items():
|
||
_configure_logger(logger, component_name)
|
||
|
||
|
||
def save_config(config: Dict):
|
||
"""Save configuration to file."""
|
||
global _log_config
|
||
_log_config = config
|
||
|
||
config_path = Path('/app/memory/log_settings.json')
|
||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
with open(config_path, 'w') as f:
|
||
json.dump(config, f, indent=2)
|
||
|
||
# Reload all loggers
|
||
reload_config()
|
||
|
||
|
||
def _setup_handlers():
|
||
"""Set up log handlers (console and file)."""
|
||
global _handlers_initialized
|
||
|
||
if _handlers_initialized:
|
||
return
|
||
|
||
# Create log directory
|
||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
_handlers_initialized = True
|
||
|
||
|
||
def _configure_logger(logger: logging.Logger, component_name: str):
|
||
"""Configure a logger with handlers and filters."""
|
||
config = get_log_config()
|
||
formatting = config.get('formatting', {})
|
||
|
||
# Clear existing handlers
|
||
logger.handlers.clear()
|
||
|
||
# Set logger level to DEBUG so handlers can filter
|
||
logger.setLevel(logging.DEBUG)
|
||
logger.propagate = False
|
||
|
||
# Create formatter
|
||
timestamp_format = formatting.get('timestamp_format', 'datetime') # 'off', 'time', 'date', or 'datetime'
|
||
use_emojis = formatting.get('use_emojis', True)
|
||
use_colors = formatting.get('use_colors', False)
|
||
|
||
# Console handler - goes to Docker logs
|
||
console_handler = logging.StreamHandler(sys.stdout)
|
||
console_formatter = EmojiFormatter(
|
||
fmt='%(timestamp_formatted)s[%(levelname_emoji)s] [%(name)s] %(message)s',
|
||
use_emojis=use_emojis,
|
||
use_colors=use_colors,
|
||
timestamp_format=timestamp_format
|
||
)
|
||
console_handler.setFormatter(console_formatter)
|
||
console_handler.addFilter(ComponentFilter(component_name))
|
||
logger.addHandler(console_handler)
|
||
|
||
# File handler - separate file per component
|
||
log_file = LOG_DIR / f'{component_name.replace(".", "_")}.log'
|
||
file_handler = RotatingFileHandler(
|
||
log_file,
|
||
maxBytes=10 * 1024 * 1024, # 10MB
|
||
backupCount=5,
|
||
encoding='utf-8'
|
||
)
|
||
file_formatter = EmojiFormatter(
|
||
fmt='%(timestamp_formatted)s[%(levelname)s] [%(name)s] %(message)s',
|
||
use_emojis=False, # No emojis in file logs
|
||
use_colors=False,
|
||
timestamp_format=timestamp_format
|
||
)
|
||
file_handler.setFormatter(file_formatter)
|
||
file_handler.addFilter(ComponentFilter(component_name))
|
||
logger.addHandler(file_handler)
|
||
|
||
|
||
def get_logger(component: str) -> logging.Logger:
|
||
"""
|
||
Get a logger for a specific component.
|
||
|
||
Args:
|
||
component: Component name (e.g., 'bot', 'api', 'autonomous')
|
||
|
||
Returns:
|
||
Configured logger instance
|
||
|
||
Example:
|
||
logger = get_logger('bot')
|
||
logger.info("Bot started")
|
||
logger.error("Connection failed", exc_info=True)
|
||
"""
|
||
if component not in COMPONENTS:
|
||
raise ValueError(
|
||
f"Unknown component '{component}'. "
|
||
f"Available: {', '.join(COMPONENTS.keys())}"
|
||
)
|
||
|
||
if component in _loggers:
|
||
return _loggers[component]
|
||
|
||
# Setup handlers if not done
|
||
_setup_handlers()
|
||
|
||
# Create logger
|
||
logger = logging.Logger(component)
|
||
|
||
# Add custom API level method
|
||
def api(self, message, *args, **kwargs):
|
||
if self.isEnabledFor(API_LEVEL):
|
||
self._log(API_LEVEL, message, args, **kwargs)
|
||
|
||
logger.api = lambda msg, *args, **kwargs: api(logger, msg, *args, **kwargs)
|
||
|
||
# Configure logger
|
||
_configure_logger(logger, component)
|
||
|
||
# Cache it
|
||
_loggers[component] = logger
|
||
|
||
return logger
|
||
|
||
|
||
def list_components() -> Dict[str, str]:
|
||
"""Get list of all available components with descriptions."""
|
||
return COMPONENTS.copy()
|
||
|
||
|
||
def get_component_stats() -> Dict[str, Dict]:
|
||
"""Get statistics about each component's logging."""
|
||
stats = {}
|
||
|
||
for component in COMPONENTS.keys():
|
||
log_file = LOG_DIR / f'{component.replace(".", "_")}.log'
|
||
|
||
stats[component] = {
|
||
'enabled': True, # Will be updated from config
|
||
'log_file': str(log_file),
|
||
'file_exists': log_file.exists(),
|
||
'file_size': log_file.stat().st_size if log_file.exists() else 0,
|
||
}
|
||
|
||
# Update from config
|
||
config = get_log_config()
|
||
component_config = config.get('components', {}).get(component, {})
|
||
stats[component]['enabled'] = component_config.get('enabled', True)
|
||
stats[component]['level'] = component_config.get('level', 'INFO')
|
||
stats[component]['enabled_levels'] = component_config.get('enabled_levels', ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'])
|
||
|
||
return stats
|
||
|
||
|
||
def intercept_external_loggers():
|
||
"""
|
||
Intercept logs from external libraries (APScheduler, etc.) and route them through our system.
|
||
Call this after initializing your application.
|
||
"""
|
||
# Intercept APScheduler loggers
|
||
apscheduler_loggers = [
|
||
'apscheduler',
|
||
'apscheduler.scheduler',
|
||
'apscheduler.executors',
|
||
'apscheduler.jobstores',
|
||
]
|
||
|
||
our_logger = get_logger('apscheduler')
|
||
|
||
for logger_name in apscheduler_loggers:
|
||
ext_logger = logging.getLogger(logger_name)
|
||
# Remove existing handlers
|
||
ext_logger.handlers.clear()
|
||
ext_logger.propagate = False
|
||
|
||
# Add our handlers
|
||
for handler in our_logger.handlers:
|
||
ext_logger.addHandler(handler)
|
||
|
||
# Set level
|
||
ext_logger.setLevel(logging.DEBUG)
|
||
|
||
|
||
# Initialize on import
|
||
_setup_handlers()
|