Files
MemoryBear/api/app/i18n/logger.py
2026-03-11 10:45:07 +08:00

383 lines
12 KiB
Python

"""
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
)