""" Translation logging for i18n system. This module provides: - TranslationLogger for recording missing translations - Missing translation report generation - Integration with existing logging system - Structured logging for translation events """ import logging from typing import Dict, List, Optional, Set from datetime import datetime from collections import defaultdict from pathlib import Path import json from app.core.logging_config import get_logger logger = get_logger(__name__) class TranslationLogger: """ Logger for translation events and missing translations. Features: - Records missing translations with context - Generates missing translation reports - Integrates with existing logging system - Provides structured logging for analysis """ def __init__(self, log_file: Optional[str] = None): """ Initialize translation logger. Args: log_file: Optional custom log file path for missing translations """ self.log_file = log_file or "logs/i18n/missing_translations.log" self._missing_translations: Dict[str, Set[str]] = defaultdict(set) self._missing_with_context: List[Dict] = [] self._max_context_entries = 10000 # Keep last 10k entries # Ensure log directory exists log_path = Path(self.log_file) log_path.parent.mkdir(parents=True, exist_ok=True) # Create dedicated file handler for missing translations self._file_handler = logging.FileHandler( self.log_file, encoding='utf-8' ) self._file_handler.setLevel(logging.WARNING) # Create formatter formatter = logging.Formatter( fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) self._file_handler.setFormatter(formatter) # Create dedicated logger for missing translations self._logger = logging.getLogger("i18n.missing_translations") self._logger.setLevel(logging.WARNING) self._logger.addHandler(self._file_handler) self._logger.propagate = False # Don't propagate to root logger logger.info(f"TranslationLogger initialized with log file: {self.log_file}") def log_missing_translation( self, key: str, locale: str, context: Optional[Dict] = None ): """ Log a missing translation. Args: key: Translation key that was not found locale: Locale code context: Optional context information (e.g., request path, user info) """ # Add to missing set self._missing_translations[locale].add(key) # Create context entry entry = { "timestamp": datetime.now().isoformat(), "key": key, "locale": locale, "context": context or {} } # Keep only recent entries to avoid memory bloat if len(self._missing_with_context) >= self._max_context_entries: self._missing_with_context.pop(0) self._missing_with_context.append(entry) # Log to file context_str = f" (context: {context})" if context else "" self._logger.warning( f"Missing translation: key='{key}', locale='{locale}'{context_str}" ) def log_translation_error( self, error_type: str, message: str, key: Optional[str] = None, locale: Optional[str] = None, context: Optional[Dict] = None ): """ Log a translation error. Args: error_type: Type of error (e.g., "format_error", "parameter_missing") message: Error message key: Translation key (optional) locale: Locale code (optional) context: Optional context information """ error_data = { "error_type": error_type, "message": message, "key": key, "locale": locale, "context": context or {}, "timestamp": datetime.now().isoformat() } self._logger.error( f"Translation error: {error_type} - {message} " f"(key: {key}, locale: {locale})" ) def log_translation_success( self, key: str, locale: str, duration_ms: Optional[float] = None ): """ Log a successful translation (debug level). Args: key: Translation key locale: Locale code duration_ms: Optional duration in milliseconds """ duration_str = f" ({duration_ms:.3f}ms)" if duration_ms else "" logger.debug( f"Translation success: key='{key}', locale='{locale}'{duration_str}" ) def get_missing_translations( self, locale: Optional[str] = None ) -> Dict[str, List[str]]: """ Get missing translations. Args: locale: Specific locale (optional, returns all if None) Returns: Dictionary of missing translations by locale """ if locale: return {locale: sorted(list(self._missing_translations.get(locale, set())))} return { loc: sorted(list(keys)) for loc, keys in self._missing_translations.items() } def get_missing_with_context( self, locale: Optional[str] = None, limit: Optional[int] = None ) -> List[Dict]: """ Get missing translations with context. Args: locale: Filter by locale (optional) limit: Maximum number of entries to return (optional) Returns: List of missing translation entries with context """ entries = self._missing_with_context # Filter by locale if specified if locale: entries = [e for e in entries if e["locale"] == locale] # Apply limit if specified if limit: entries = entries[-limit:] return entries def generate_report( self, locale: Optional[str] = None, output_file: Optional[str] = None ) -> Dict: """ Generate a missing translation report. Args: locale: Specific locale (optional, generates for all if None) output_file: Optional file path to save report as JSON Returns: Report dictionary """ missing = self.get_missing_translations(locale) report = { "generated_at": datetime.now().isoformat(), "total_missing": sum(len(keys) for keys in missing.values()), "missing_by_locale": { loc: { "count": len(keys), "keys": keys } for loc, keys in missing.items() }, "recent_context": self.get_missing_with_context(locale, limit=100) } # Save to file if specified if output_file: output_path = Path(output_file) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, 'w', encoding='utf-8') as f: json.dump(report, f, indent=2, ensure_ascii=False) logger.info(f"Missing translation report saved to: {output_file}") return report def get_statistics(self) -> Dict: """ Get statistics about missing translations. Returns: Dictionary with statistics """ total_missing = sum(len(keys) for keys in self._missing_translations.values()) # Count by namespace namespace_counts = defaultdict(int) for locale, keys in self._missing_translations.items(): for key in keys: namespace = key.split('.')[0] if '.' in key else 'unknown' namespace_counts[namespace] += 1 return { "total_missing": total_missing, "locales_affected": len(self._missing_translations), "missing_by_locale": { loc: len(keys) for loc, keys in self._missing_translations.items() }, "missing_by_namespace": dict(namespace_counts), "total_context_entries": len(self._missing_with_context) } def clear(self, locale: Optional[str] = None): """ Clear missing translation records. Args: locale: Specific locale to clear (optional, clears all if None) """ if locale: self._missing_translations.pop(locale, None) self._missing_with_context = [ e for e in self._missing_with_context if e["locale"] != locale ] logger.info(f"Cleared missing translations for locale: {locale}") else: self._missing_translations.clear() self._missing_with_context.clear() logger.info("Cleared all missing translations") def export_to_json(self, output_file: str): """ Export all missing translations to JSON file. Args: output_file: Output file path """ data = { "exported_at": datetime.now().isoformat(), "missing_translations": self.get_missing_translations(), "statistics": self.get_statistics(), "recent_context": self.get_missing_with_context(limit=1000) } output_path = Path(output_file) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, 'w', encoding='utf-8') as f: json.dump(data, f, indent=2, ensure_ascii=False) logger.info(f"Missing translations exported to: {output_file}") def __del__(self): """Cleanup file handler on deletion.""" try: if hasattr(self, '_file_handler'): self._file_handler.close() self._logger.removeHandler(self._file_handler) except Exception: pass # Global translation logger instance _translation_logger: Optional[TranslationLogger] = None def get_translation_logger() -> TranslationLogger: """ Get the global translation logger instance. Returns: TranslationLogger singleton """ global _translation_logger if _translation_logger is None: _translation_logger = TranslationLogger() return _translation_logger def log_missing_translation( key: str, locale: str, context: Optional[Dict] = None ): """ Log a missing translation (convenience function). Args: key: Translation key locale: Locale code context: Optional context information """ translation_logger = get_translation_logger() translation_logger.log_missing_translation(key, locale, context) def log_translation_error( error_type: str, message: str, key: Optional[str] = None, locale: Optional[str] = None, context: Optional[Dict] = None ): """ Log a translation error (convenience function). Args: error_type: Type of error message: Error message key: Translation key (optional) locale: Locale code (optional) context: Optional context information """ translation_logger = get_translation_logger() translation_logger.log_translation_error( error_type, message, key, locale, context )