"""Logging structuré — même convention que `LoggerService` côté `apps/api` (`apps/api/src/lib/logger.service.ts`) : une ligne JSON par évènement (`timestamp`, `level`, `message`, + le reste des champs fournis fusionné), jamais du texte libre, pour rester grep/parse-able par `docker logs`/ Portainer ou un agrégateur de logs — cohérent avec le reste du repo plutôt qu'un format propre à ce seul service. Configuré une fois au démarrage (`main.py`) plutôt que par un `print()` ad hoc dans chaque route — `routes/process.py`/`pipeline_registry.py` appellent `logging.getLogger(__name__)` normalement, ce module ne fait que brancher le formateur JSON sur la racine du logging Python. """ import json import logging import sys from datetime import UTC, datetime from typing import Any class _JsonFormatter(logging.Formatter): """Sérialise chaque `LogRecord` en une ligne JSON. Les champs supplémentaires passés via `logger.info(msg, extra={...})` sont fusionnés tels quels dans l'objet — c'est ce que `routes/process.py` utilise pour joindre `locale`/`text`/`entities`/`intent`/`score` à la ligne.""" # Attributs standards de `LogRecord` — tout le reste posé sur le record # (via `extra=`) est un champ métier ajouté par l'appelant, à fusionner # dans la sortie JSON. _STANDARD_ATTRS = frozenset(logging.LogRecord("", 0, "", 0, "", None, None).__dict__.keys()) def format(self, record: logging.LogRecord) -> str: payload: dict[str, Any] = { "timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(), "level": record.levelname.lower(), "message": record.getMessage(), } extra_fields = { key: value for key, value in record.__dict__.items() if key not in self._STANDARD_ATTRS } payload.update(extra_fields) if record.exc_info: payload["error"] = self.formatException(record.exc_info) return json.dumps(payload, ensure_ascii=False, default=str) def configure_logging(level: str) -> None: """Branche le formateur JSON sur la racine du logging Python — appelé une fois au démarrage (`main.py`), avant que `routes/*` ne journalisent quoi que ce soit.""" # L'encodage par défaut de `sys.stdout` suit la locale de l'OS/console, # pas forcément UTF-8 — sur Windows en particulier, garder ce défaut # produit de vrais octets invalides (pas juste un affichage terminal # trompeur) pour tout texte accentué journalisé par `routes/process.py` # (le texte réel des étapes de recette, en français) — trouvé en # vérifiant les octets bruts d'un log réel, pas juste son affichage. # `reconfigure` existe sur `sys.stdout` dans toute exécution Python # normale (pas dans certains contextes embarqués/redirigés exotiques) — # protégé par `hasattr` pour ne jamais faire planter le démarrage pour un # souci de confort d'affichage. if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") handler = logging.StreamHandler(sys.stdout) handler.setFormatter(_JsonFormatter()) root = logging.getLogger() root.handlers = [handler] root.setLevel(level) # spaCy/thinc journalisent leur propre chatter interne ("Created # vocabulary", "Finished initializing nlp object"...) sur le logger # `"spacy"`, qui propage jusqu'à la racine et se retrouverait donc # mélangé aux lignes input/output de `routes/process.py`/l'entraînement # journalisé par `pipeline_registry.py` # — ce sont ces dernières que ce service existe pour rendre visibles, pas # le détail interne de spaCy. `WARNING` laisse quand même remonter un # vrai problème (dépréciation, échec partiel) sans le bruit `INFO`. logging.getLogger("spacy").setLevel(logging.WARNING)