Logging en Python : configuration pour production

1. Vue d’ensemble

print() suffit quand vous écrivez un petit script. En production, c’est différent : vous devez filtrer les logs par niveau, regrouper les logs dans un système centralisé, tracer une requête à travers plusieurs lignes de log, et la journalisation ne doit pas ralentir l’application.

Cet article vous guide sur la configuration du module logging standard de Python pour la production. Après l’avoir lu, vous pourrez construire une configuration comportant des loggers par module, dictConfig, des logs en JSON, un request_id ajouté automatiquement, une journalisation asynchrone avec QueueHandler et des tests de logs avec pytest. Tous les exemples utilisent Python 3.12 et ne nécessitent que la bibliothèque standard. La section test nécessite pytest.

2. Pourquoi print et basicConfig ne suffisent pas

Regardez un service de traitement de commandes écrit à la hâte :

import logging

logging.basicConfig(level=logging.INFO)

def place_order(order_id: str, amount: float) -> None:
    print(f"Placing order {order_id}")
    logging.info(f"Order {order_id} amount={amount}")

place_order("ORD-1001", 250_000)
Placing order ORD-1001
INFO:root:Order ORD-1001 amount=250000

Ce code a trois problèmes :

  • print n’a pas de niveau, pas de timestamp et ne peut pas être désactivé via la configuration.
  • logging.info(...) écrit dans le logger racine. Vous ne savez pas d’où provient la ligne de log et vous ne pouvez pas ajuster le niveau pour chaque partie.
  • basicConfig n’a d’effet que lors du premier appel, si le logger racine n’a pas encore de gestionnaire. Si une bibliothèque l’appelle avant vous, votre configuration sera ignorée sans aucun avertissement.

3. Logger par module avec getLogger(__name__)

Première règle : chaque module crée son propre logger, nommé selon __name__.

# shop/orders.py
import logging

logger = logging.getLogger(__name__)  # nom du logger : « shop.orders »

class OrderService:
    def place_order(self, order_id: str, quantity: int) -> None:
        logger.info("Placing order %s with quantity %d", order_id, quantity)
        if quantity > 50:
            logger.warning("Large order %s: quantity=%d", order_id, quantity)

Le nom du logger forme une hiérarchie arborescente avec des points. shop.orders est un enfant de shop, et shop est un enfant de root. Un enregistrement remonte du logger enfant au logger parent (c’est ce qu’on appelle la propagation). Vous n’avez qu’à attacher un gestionnaire à root ou à shop, puis ajuster le niveau pour chaque branche. Par exemple, vous pouvez définir shop.payments à DEBUG et laisser le reste à INFO.

Notez également la façon de passer les paramètres : utiliser %s avec des arguments au lieu de f-string. Quand le niveau est désactivé, la chaîne ne sera pas formatée, vous n’aurez donc pas de frais pour les logs qui ne seront jamais imprimés. De plus, les outils d’agrégation de logs peuvent regrouper les lignes par le même modèle de message.

Note : Dans une bibliothèque (package que vous distribuez), ne configurez pas de gestionnaire. Appelez simplement getLogger(__name__) et laissez l’application décider. Si vous le souhaitez, ajoutez logging.getLogger("your_lib").addHandler(logging.NullHandler()).

4. Configuration centralisée avec dictConfig

En production, vous devez regrouper toute la configuration en un seul endroit, appelé une seule fois au démarrage de l’application. logging.config.dictConfig est approprié pour cela.

# shop/logging_config.py
import logging.config
import os

def setup_logging() -> None:
    level = os.getenv("LOG_LEVEL", "INFO")
    logging.config.dictConfig({
        "version": 1,
        "disable_existing_loggers": False,
        "formatters": {
            "plain": {
                "format": "%(asctime)s %(levelname)-8s %(name)s - %(message)s",
            },
        },
        "handlers": {
            "console": {
                "class": "logging.StreamHandler",
                "stream": "ext://sys.stdout",
                "formatter": "plain",
            },
        },
        "loggers": {
            "shop": {"level": level},
            "urllib3": {"level": "WARNING"},
        },
        "root": {"level": "WARNING", "handlers": ["console"]},
    })

Points clés de la configuration :

  • "disable_existing_loggers": False est obligatoire en pratique. La valeur par défaut True désactive tous les loggers créés avant que dictConfig s’exécute, y compris le logger au début des modules que vous avez importés. En conséquence, les logs disparaissent sans aucune erreur.
  • Le gestionnaire est uniquement attaché à root. Le logger shop ne fait que définir le niveau, tandis que l’enregistrement se propagera vers root pour être affiché.
  • Root est laissé à WARNING pour bloquer les logs INFO bruyants des bibliothèques tierces. Le code de votre branche shop continue de journaliser à INFO.
  • Le niveau est lu à partir de la variable d’environnement LOG_LEVEL. Vous pouvez activer DEBUG lors de l’investigation de problèmes sans avoir à modifier le code.

Appelez la fonction au point d’entrée :

# main.py
from shop.logging_config import setup_logging
from shop.orders import OrderService

setup_logging()
OrderService().place_order("ORD-1001", quantity=80)
2026-10-18 09:15:02,114 INFO     shop.orders - Placing order ORD-1001 with quantity 80
2026-10-18 09:15:02,114 WARNING  shop.orders - Large order ORD-1001: quantity=80

Écrire vers stdout est le bon choix lors de l’exécution dans un conteneur. Docker ou Kubernetes collecteront les logs de ce flux, vous n’avez donc pas besoin de gérer les fichiers vous-même ou la rotation des fichiers.

5. Logs structurés en JSON

Les systèmes d’agrégation de logs comme Loki, Elasticsearch ou CloudWatch recherchent mieux sur JSON que sur du texte libre. Vous pouvez écrire votre propre formateur sans bibliothèque externe :

# shop/json_formatter.py
import json
import logging
from datetime import datetime, timezone

_RESERVED = set(vars(logging.LogRecord("", 0, "", 0, "", None, None))) | {"message", "asctime"}

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        for key, value in record.__dict__.items():
            if key not in _RESERVED and not key.startswith("_"):
                payload[key] = value
        if record.exc_info:
            payload["exc"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False, default=str)

Fonctionnement :

  • _RESERVED contient les attributs par défaut d’un LogRecord, par exemple pathname, lineno, args.
  • Tous les autres attributs sont considérés comme des champs personnalisés et ajoutés au JSON. Ces champs proviennent généralement du paramètre extra= ou d’un filtre (voir section 6).
  • default=str aide json.dumps à ne pas échouer quand il rencontre des types comme Decimal ou UUID.

Déclarez le formateur dans dictConfig en utilisant la clé spéciale "()" :

"formatters": {
    "json": {"()": "shop.json_formatter.JsonFormatter"},
},
"handlers": {
    "console": {
        "class": "logging.StreamHandler",
        "stream": "ext://sys.stdout",
        "formatter": "json",
    },
},

Ajoutez des champs métier en utilisant extra :

logger.info("Payment captured", extra={"order_id": "ORD-1001", "amount": 250000})
{"ts": "2026-10-18T02:15:02.114+00:00", "level": "INFO", "logger": "shop.payments", "message": "Payment captured", "order_id": "ORD-1001", "amount": 250000}

Note : Les clés dans extra ne doivent pas avoir le même nom que les attributs existants de LogRecord. Par exemple, extra={"name": "..."} ou extra={"message": "..."} lèveront KeyError: "Attempt to overwrite 'name' in LogRecord".

6. Ajouter request_id automatiquement avec contextvars

Passer extra={"request_id": ...} à chaque appel de log, c’est à la fois verbeux et facile à oublier. Une meilleure approche consiste à stocker request_id dans une ContextVar, puis avoir un Filter l’ajouter à tous les enregistrements.

# shop/request_context.py
import logging
from contextvars import ContextVar

request_id_var: ContextVar[str] = ContextVar("request_id", default="-")

class RequestIdFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        record.request_id = request_id_var.get()
        return True  # n'interdit aucun enregistrement, enrichit juste les données

ContextVar est sûr avec les threads et asyncio. Chaque tâche ou requête conserve sa propre valeur, donc il n’y a pas de risque qu’une requête « emprunte » l’ID d’une autre.

Attachez le filtre au gestionnaire, pas au logger. Un filtre attaché à un logger ne s’exécute que pour les enregistrements créés directement par ce logger, et non pour les enregistrements propagés de logger enfant vers le haut.

"filters": {
    "request_id": {"()": "shop.request_context.RequestIdFilter"},
},
"handlers": {
    "console": {
        "class": "logging.StreamHandler",
        "stream": "ext://sys.stdout",
        "formatter": "json",
        "filters": ["request_id"],
    },
},

Définissez la valeur au début de chaque requête, par exemple dans un middleware :

import uuid
from shop.request_context import request_id_var

def handle_request(order_id: str) -> None:
    token = request_id_var.set(uuid.uuid4().hex[:12])
    try:
        OrderService().place_order(order_id, quantity=3)
    finally:
        request_id_var.reset(token)
{"ts": "...", "level": "INFO", "logger": "shop.orders", "message": "Placing order ORD-1002 with quantity 3", "request_id": "9f2c1ab07d3e"}

Maintenant, vous n’avez qu’à filtrer sur request_id pour voir le trajet complet d’une requête.

7. Enregistrer les exceptions correctement

logger = logging.getLogger("shop.payments")

class PaymentService:
    def charge(self, order_id: str, amount: int) -> bool:
        try:
            raise ConnectionError("gateway timeout")
        except ConnectionError:
            logger.exception("Charge failed for order %s", order_id)
            return False

logger.exception(...) enregistre au niveau ERROR et joint automatiquement la pile d’appels. Vous ne l’appelez que dans un bloc except. Si vous voulez enregistrer à un autre niveau tout en ayant la pile d’appels, utilisez logger.warning("...", exc_info=True).

Deux erreurs courantes :

  • logger.error(f"Charge failed: {e}") perd la pile d’appels. Lors de l’investigation, vous ne voyez qu’une seule ligne « gateway timeout » mais sans savoir où c’est arrivé.
  • Enregistrer ET relancer l’exception à tous les niveaux. La même exception apparaîtra cinq fois dans le log. Enregistrez au niveau où vous gérez l’exception, c’est-à-dire où vous décidez de ne pas la relancer.

8. Empêcher le logging de bloquer l’application : QueueHandler

StreamHandler qui écrit sur stdout est généralement assez rapide. Mais un gestionnaire qui envoie sur le réseau (syslog, HTTP, SMTP) ou écrit sur un disque lent bloquera le thread qui traite la requête. Pour éviter cela, mettez l’enregistrement dans une file d’attente et laissez un thread d’arrière-plan écrire vers la destination réelle :

import atexit
import logging
import logging.handlers
import queue

def setup_async_logging(target: logging.Handler) -> None:
    log_queue: queue.Queue = queue.Queue(-1)
    queue_handler = logging.handlers.QueueHandler(log_queue)

    listener = logging.handlers.QueueListener(
        log_queue, target, respect_handler_level=True
    )
    listener.start()
    atexit.register(listener.stop)  # vide le reste à l'arrêt

    root = logging.getLogger()
    root.handlers = [queue_handler]

Le flux de traitement est le suivant :

  • Le thread d’application ne fait qu’une chose : mettre l’enregistrement dans la queue. Cette opération est quasi instantanée.
  • QueueListener s’exécute sur son propre thread, récupère l’enregistrement et le passe à target, par exemple un SysLogHandler.
  • atexit.register(listener.stop) garantit que le dernier log est écrit avant que le processus se termine.

Note : Un filtre comme RequestIdFilter doit être attaché à QueueHandler, pas au gestionnaire cible. Le gestionnaire cible s’exécute sur le thread du listener, et la ContextVar sur ce thread ne porte pas la valeur de la requête d’origine.

9. Tester les logs avec pytest caplog

Les logs d’avertissement métier, par exemple une commande de grande quantité, sont un comportement qui devrait être testé. Ajoutez pytest aux dépendances de développement :

pip install pytest
# tests/test_orders.py
import logging
from shop.orders import OrderService

def test_large_order_logs_warning(caplog):
    with caplog.at_level(logging.INFO, logger="shop.orders"):
        OrderService().place_order("ORD-2001", quantity=80)

    warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
    assert len(warnings) == 1
    assert warnings[0].name == "shop.orders"
    assert warnings[0].getMessage() == "Large order ORD-2001: quantity=80"

def test_small_order_does_not_warn(caplog):
    with caplog.at_level(logging.INFO, logger="shop.orders"):
        OrderService().place_order("ORD-2002", quantity=2)

    assert all(r.levelno < logging.WARNING for r in caplog.records)
tests/test_orders.py ..                                  [100%]
2 passed in 0.03s

caplog.at_level abaisse temporairement le niveau du logger shop.orders dans la portée du bloc with. caplog.records contient les LogRecord d’origine, vous pouvez donc vérifier le niveau, le nom du logger et les champs extra s’il y en a.

Note : caplog attache un gestionnaire au logger racine. Si votre configuration définit "propagate": False pour le logger shop, le test ne capturera aucun enregistrement.

10. Erreurs courantes et ce qu’il ne faut pas faire

  • Configurer le logging au niveau du module (appeler basicConfig ou dictConfig lors de l’importation). Ne configurez qu’au point d’entrée, une seule fois.
  • Gestionnaire attaché deux fois, par exemple attaché à la fois à shop et root tandis que propagate est toujours True. Chaque ligne de log sera imprimée deux fois. Vous ne devez attacher le gestionnaire qu’à un niveau.
  • Enregistrer des données sensibles : mots de passe, tokens, numéros de carte. Ne loggez pas l’objet de la requête en entier. Si c’est nécessaire, écrivez un Filter pour masquer ces champs.
  • Utiliser RotatingFileHandler dans une application multi-processus (Gunicorn avec plusieurs workers). Plusieurs processus effectuant la rotation d’un même fichier entraînera une perte de logs. Écrivez sur stdout, ou laissez chaque worker pousser via QueueHandler vers un processus d’écriture unique.
  • Enregistrer trop de choses à INFO dans une boucle serrée. Le coût de stockage et le bruit augmentent très rapidement. Ces logs devraient être au niveau DEBUG.
  • Utiliser JSON lors du développement local. JSON est difficile à lire à l’œil nu, choisissez donc le formateur en fonction de la variable d’environnement : plain pour le dev, json pour la production.

11. Conclusion

Une bonne configuration de logging pour la production n’est pas complexe, elle doit juste être cohérente. Utilisez getLogger(__name__) dans tous les modules. Regroupez la configuration en un seul appel à dictConfig avec disable_existing_loggers: False. Écrivez du JSON sur stdout, joignez request_id via ContextVar et Filter, et utilisez logger.exception pour conserver la pile d’appels. Quand le gestionnaire cible est lent, passez à QueueHandler et QueueListener.

Enfin, considérez les logs comme faisant partie du comportement du système et testez-les avec caplog. Quand il y a un problème à 2 heures du matin, ces lignes de log structurées vous aideront à trouver la cause beaucoup plus rapidement.

pythonloggingproductionobservability