- Réécrire logger.py avec format cohérent: "timestamp | level | component | message" - Ajouter ANSIStripFilter pour nettoyer les codes couleur des logs - Implémenter _clean_component_name() pour éviter les noms de fichiers corrompus - Configurer rotation des fichiers (10MB, 5 backups) - Rendre imports Prometheus optionnels dans __init__.py - Réduire bruit des librairies externes (werkzeug, urllib3, etc.) Corrige les problèmes de fichiers logs corrompus (!0.log, #0.log, %0.log, etc.) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
440 lines
14 KiB
Python
440 lines
14 KiB
Python
"""
|
|
Système de logging centralisé pour RPA Vision V3
|
|
|
|
Ce module fournit un logging unifié pour tous les composants avec:
|
|
- Format structuré et cohérent
|
|
- Filtrage des codes ANSI
|
|
- Nettoyage des noms de composants
|
|
- Rotation des fichiers
|
|
- Intégration Prometheus
|
|
"""
|
|
|
|
import logging
|
|
import logging.handlers
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from datetime import datetime
|
|
from typing import Dict, Any, Optional, List
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
# Import des métriques Prometheus
|
|
try:
|
|
from core.monitoring.metrics import increment_log_entry
|
|
METRICS_ENABLED = True
|
|
except ImportError:
|
|
METRICS_ENABLED = False
|
|
|
|
|
|
# =============================================================================
|
|
# Configuration
|
|
# =============================================================================
|
|
|
|
# Répertoire des logs
|
|
LOG_DIR = Path(__file__).parent.parent.parent / "logs"
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Format unifié
|
|
LOG_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)-20s | %(message)s"
|
|
LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
|
|
# Rotation
|
|
MAX_LOG_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
BACKUP_COUNT = 5 # Garder 5 fichiers de backup
|
|
|
|
# Pattern pour supprimer les codes ANSI
|
|
ANSI_ESCAPE_PATTERN = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
|
|
|
|
|
# =============================================================================
|
|
# Filtres et Formatters
|
|
# =============================================================================
|
|
|
|
class ANSIStripFilter(logging.Filter):
|
|
"""Filtre qui supprime les codes ANSI des messages."""
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
if hasattr(record, 'msg') and isinstance(record.msg, str):
|
|
record.msg = ANSI_ESCAPE_PATTERN.sub('', record.msg)
|
|
return True
|
|
|
|
|
|
class CleanFormatter(logging.Formatter):
|
|
"""Formatter qui nettoie les messages et assure un format cohérent."""
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
# Nettoyer les codes ANSI du message
|
|
if hasattr(record, 'msg') and isinstance(record.msg, str):
|
|
record.msg = ANSI_ESCAPE_PATTERN.sub('', record.msg)
|
|
|
|
# Formater
|
|
return super().format(record)
|
|
|
|
|
|
# =============================================================================
|
|
# LogEntry Dataclass
|
|
# =============================================================================
|
|
|
|
@dataclass
|
|
class LogEntry:
|
|
"""Entrée de log structurée."""
|
|
timestamp: datetime
|
|
level: str
|
|
component: str
|
|
message: str
|
|
workflow_id: Optional[str] = None
|
|
node_id: Optional[str] = None
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convertit l'entrée en dictionnaire."""
|
|
return {
|
|
'timestamp': self.timestamp.isoformat(),
|
|
'level': self.level,
|
|
'component': self.component,
|
|
'message': self.message,
|
|
'workflow_id': self.workflow_id,
|
|
'node_id': self.node_id,
|
|
'metadata': self.metadata
|
|
}
|
|
|
|
def to_json(self) -> str:
|
|
"""Convertit l'entrée en JSON."""
|
|
return json.dumps(self.to_dict())
|
|
|
|
|
|
# =============================================================================
|
|
# RPALogger Class
|
|
# =============================================================================
|
|
|
|
class RPALogger:
|
|
"""Logger centralisé pour le système RPA."""
|
|
|
|
# Liste des composants valides (whitelist)
|
|
VALID_COMPONENTS = {
|
|
'api', 'workflow', 'execution', 'detection', 'healing',
|
|
'monitoring', 'dashboard', 'automation_scheduler', 'chain_manager',
|
|
'trigger_manager', 'vwb_backend', 'vwb_frontend', 'main_backend',
|
|
'log_exporter', 'analytics', 'security', 'embedding', 'faiss',
|
|
'coaching', 'correction_packs', 'system', 'backup', 'version',
|
|
'test', 'test_component'
|
|
}
|
|
|
|
@staticmethod
|
|
def _clean_component_name(component: str) -> str:
|
|
"""
|
|
Nettoie le nom du composant pour éviter les caractères corrompus.
|
|
|
|
Args:
|
|
component: Nom du composant brut
|
|
|
|
Returns:
|
|
Nom nettoyé sans caractères problématiques
|
|
"""
|
|
if not component:
|
|
return "unknown"
|
|
|
|
# Convertir en minuscules et supprimer les espaces
|
|
component = component.lower().strip()
|
|
|
|
# Ne garder que les caractères alphanumériques et underscores
|
|
cleaned = ""
|
|
for char in component:
|
|
if char.isalnum() or char == '_':
|
|
cleaned += char
|
|
elif char in ['-', '.', ' ']:
|
|
cleaned += '_'
|
|
|
|
# Limiter la longueur
|
|
cleaned = cleaned[:30].strip('_')
|
|
|
|
# Si le résultat est vide ou trop court, utiliser "unknown"
|
|
if not cleaned or len(cleaned) < 2:
|
|
return "unknown"
|
|
|
|
return cleaned
|
|
|
|
def __init__(self, component: str, log_file: Optional[str] = None):
|
|
"""
|
|
Initialise le logger.
|
|
|
|
Args:
|
|
component: Nom du composant
|
|
log_file: Chemin du fichier de log (optionnel)
|
|
"""
|
|
# Nettoyer le nom du composant
|
|
self.component = self._clean_component_name(component)
|
|
|
|
# Chemin du fichier de log
|
|
if log_file:
|
|
self.log_file = Path(log_file)
|
|
else:
|
|
self.log_file = LOG_DIR / f"{self.component}.log"
|
|
|
|
# Créer le répertoire parent si nécessaire
|
|
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Stocker les entrées en mémoire (limité)
|
|
self.entries: List[LogEntry] = []
|
|
self._max_entries = 1000
|
|
|
|
# Configuration du logger Python standard
|
|
self.logger = logging.getLogger(f"rpa.{self.component}")
|
|
self.logger.setLevel(logging.DEBUG)
|
|
|
|
# Éviter les handlers dupliqués
|
|
if not self.logger.handlers:
|
|
self._setup_handlers()
|
|
|
|
def _setup_handlers(self):
|
|
"""Configure les handlers de logging."""
|
|
# Formatter commun
|
|
formatter = CleanFormatter(LOG_FORMAT, LOG_DATE_FORMAT)
|
|
|
|
# Handler fichier avec rotation
|
|
try:
|
|
file_handler = logging.handlers.RotatingFileHandler(
|
|
self.log_file,
|
|
maxBytes=MAX_LOG_SIZE,
|
|
backupCount=BACKUP_COUNT,
|
|
encoding='utf-8'
|
|
)
|
|
file_handler.setFormatter(formatter)
|
|
file_handler.setLevel(logging.DEBUG)
|
|
file_handler.addFilter(ANSIStripFilter())
|
|
self.logger.addHandler(file_handler)
|
|
except Exception as e:
|
|
print(f"Warning: Could not create file handler for {self.component}: {e}")
|
|
|
|
# Handler console (seulement si pas en mode silencieux)
|
|
if os.getenv('RPA_LOG_CONSOLE', 'false').lower() == 'true':
|
|
console_handler = logging.StreamHandler(sys.stdout)
|
|
console_handler.setFormatter(formatter)
|
|
console_handler.setLevel(logging.INFO)
|
|
console_handler.addFilter(ANSIStripFilter())
|
|
self.logger.addHandler(console_handler)
|
|
|
|
def _log(self, level: str, message: str, workflow_id: Optional[str] = None,
|
|
node_id: Optional[str] = None, **metadata):
|
|
"""
|
|
Log interne avec métriques.
|
|
|
|
Args:
|
|
level: Niveau de log (INFO, WARNING, ERROR, DEBUG)
|
|
message: Message de log
|
|
workflow_id: ID du workflow (optionnel)
|
|
node_id: ID du nœud (optionnel)
|
|
**metadata: Métadonnées additionnelles
|
|
"""
|
|
# Nettoyer le message des codes ANSI
|
|
clean_message = ANSI_ESCAPE_PATTERN.sub('', str(message))
|
|
|
|
# Créer l'entrée
|
|
entry = LogEntry(
|
|
timestamp=datetime.now(),
|
|
level=level,
|
|
component=self.component,
|
|
message=clean_message,
|
|
workflow_id=workflow_id,
|
|
node_id=node_id,
|
|
metadata=metadata
|
|
)
|
|
|
|
# Stocker en mémoire (avec limite)
|
|
self.entries.append(entry)
|
|
if len(self.entries) > self._max_entries:
|
|
self.entries = self.entries[-self._max_entries:]
|
|
|
|
# Incrémenter le compteur Prometheus
|
|
if METRICS_ENABLED:
|
|
try:
|
|
increment_log_entry(level, self.component)
|
|
except Exception:
|
|
pass
|
|
|
|
# Construire le message formaté
|
|
log_msg = clean_message
|
|
if workflow_id:
|
|
log_msg += f" | workflow={workflow_id}"
|
|
if node_id:
|
|
log_msg += f" | node={node_id}"
|
|
if metadata:
|
|
# Formater les métadonnées de manière compacte
|
|
meta_str = " | ".join(f"{k}={v}" for k, v in metadata.items())
|
|
log_msg += f" | {meta_str}"
|
|
|
|
# Log via le logger Python
|
|
log_method = getattr(self.logger, level.lower(), self.logger.info)
|
|
log_method(log_msg)
|
|
|
|
def info(self, message: str, workflow_id: Optional[str] = None,
|
|
node_id: Optional[str] = None, **metadata):
|
|
"""Log niveau INFO."""
|
|
self._log('INFO', message, workflow_id, node_id, **metadata)
|
|
|
|
def warning(self, message: str, workflow_id: Optional[str] = None,
|
|
node_id: Optional[str] = None, **metadata):
|
|
"""Log niveau WARNING."""
|
|
self._log('WARNING', message, workflow_id, node_id, **metadata)
|
|
|
|
def error(self, message: str, workflow_id: Optional[str] = None,
|
|
node_id: Optional[str] = None, **metadata):
|
|
"""Log niveau ERROR."""
|
|
self._log('ERROR', message, workflow_id, node_id, **metadata)
|
|
|
|
def debug(self, message: str, workflow_id: Optional[str] = None,
|
|
node_id: Optional[str] = None, **metadata):
|
|
"""Log niveau DEBUG."""
|
|
self._log('DEBUG', message, workflow_id, node_id, **metadata)
|
|
|
|
def critical(self, message: str, workflow_id: Optional[str] = None,
|
|
node_id: Optional[str] = None, **metadata):
|
|
"""Log niveau CRITICAL."""
|
|
self._log('CRITICAL', message, workflow_id, node_id, **metadata)
|
|
|
|
def workflow_start(self, workflow_id: str, **metadata):
|
|
"""Log démarrage de workflow."""
|
|
self.info("Workflow started", workflow_id=workflow_id, **metadata)
|
|
|
|
def workflow_end(self, workflow_id: str, success: bool, duration: float, **metadata):
|
|
"""Log fin de workflow."""
|
|
status = 'success' if success else 'failure'
|
|
self.info(
|
|
f"Workflow completed: {status}",
|
|
workflow_id=workflow_id,
|
|
success=success,
|
|
duration_s=round(duration, 3),
|
|
**metadata
|
|
)
|
|
|
|
def action_executed(self, workflow_id: str, node_id: str, action_type: str,
|
|
success: bool, confidence: float = 0.0, **metadata):
|
|
"""Log exécution d'une action."""
|
|
level = 'INFO' if success else 'WARNING'
|
|
self._log(
|
|
level,
|
|
f"Action executed: {action_type}",
|
|
workflow_id=workflow_id,
|
|
node_id=node_id,
|
|
success=success,
|
|
confidence=round(confidence, 3),
|
|
**metadata
|
|
)
|
|
|
|
def get_recent_logs(self, limit: int = 100) -> List[LogEntry]:
|
|
"""
|
|
Récupère les logs récents.
|
|
|
|
Args:
|
|
limit: Nombre maximum de logs à retourner
|
|
|
|
Returns:
|
|
Liste des entrées de log récentes
|
|
"""
|
|
return self.entries[-limit:]
|
|
|
|
def export_logs(self, start_time: Optional[datetime] = None,
|
|
end_time: Optional[datetime] = None,
|
|
format: str = 'json') -> str:
|
|
"""
|
|
Exporte les logs.
|
|
|
|
Args:
|
|
start_time: Timestamp de début (optionnel)
|
|
end_time: Timestamp de fin (optionnel)
|
|
format: Format d'export ('json' ou 'text')
|
|
|
|
Returns:
|
|
Logs exportés
|
|
"""
|
|
filtered_logs = self.entries
|
|
|
|
if start_time:
|
|
filtered_logs = [log for log in filtered_logs if log.timestamp >= start_time]
|
|
if end_time:
|
|
filtered_logs = [log for log in filtered_logs if log.timestamp <= end_time]
|
|
|
|
if format == 'json':
|
|
return json.dumps([log.to_dict() for log in filtered_logs], indent=2)
|
|
else:
|
|
lines = []
|
|
for log in filtered_logs:
|
|
line = f"{log.timestamp.strftime(LOG_DATE_FORMAT)} | {log.level:7} | {log.component:20} | {log.message}"
|
|
lines.append(line)
|
|
return "\n".join(lines)
|
|
|
|
|
|
# =============================================================================
|
|
# Global Logger Management
|
|
# =============================================================================
|
|
|
|
# Instance globale pour chaque composant
|
|
_loggers: Dict[str, RPALogger] = {}
|
|
|
|
|
|
def get_logger(component: str) -> RPALogger:
|
|
"""
|
|
Récupère ou crée un logger pour un composant.
|
|
|
|
Args:
|
|
component: Nom du composant
|
|
|
|
Returns:
|
|
Logger pour le composant
|
|
"""
|
|
# Nettoyer le nom avant de chercher
|
|
clean_name = RPALogger._clean_component_name(component)
|
|
|
|
if clean_name not in _loggers:
|
|
_loggers[clean_name] = RPALogger(component)
|
|
|
|
return _loggers[clean_name]
|
|
|
|
|
|
def configure_root_logger():
|
|
"""
|
|
Configure le logger racine pour capturer tous les logs Python.
|
|
Appelé une seule fois au démarrage.
|
|
"""
|
|
root_logger = logging.getLogger()
|
|
root_logger.setLevel(logging.INFO)
|
|
|
|
# Formatter
|
|
formatter = CleanFormatter(LOG_FORMAT, LOG_DATE_FORMAT)
|
|
|
|
# Handler fichier principal
|
|
main_log = LOG_DIR / "main.log"
|
|
try:
|
|
handler = logging.handlers.RotatingFileHandler(
|
|
main_log,
|
|
maxBytes=MAX_LOG_SIZE,
|
|
backupCount=BACKUP_COUNT,
|
|
encoding='utf-8'
|
|
)
|
|
handler.setFormatter(formatter)
|
|
handler.addFilter(ANSIStripFilter())
|
|
root_logger.addHandler(handler)
|
|
except Exception:
|
|
pass
|
|
|
|
# Réduire le bruit des bibliothèques externes
|
|
logging.getLogger('urllib3').setLevel(logging.WARNING)
|
|
logging.getLogger('werkzeug').setLevel(logging.WARNING)
|
|
logging.getLogger('socketio').setLevel(logging.WARNING)
|
|
logging.getLogger('engineio').setLevel(logging.WARNING)
|
|
|
|
|
|
# =============================================================================
|
|
# Pre-configured Loggers
|
|
# =============================================================================
|
|
|
|
# Loggers pré-configurés pour les composants principaux
|
|
workflow_logger = get_logger('workflow')
|
|
execution_logger = get_logger('execution')
|
|
detection_logger = get_logger('detection')
|
|
api_logger = get_logger('api')
|
|
monitoring_logger = get_logger('monitoring')
|
|
security_logger = get_logger('security')
|