Logging en Python: configuración para producción

1. Descripción general

print() es suficiente cuando escribes scripts pequeños. En producción es diferente: necesitas filtrar logs por nivel, concentrar logs en un sistema centralizado, rastrear una request a través de múltiples líneas de log, y la escritura de logs no debe ralentizar la aplicación.

Este artículo te guía en la configuración del módulo logging estándar de Python para producción. Después de leerlo, habrás construido una configuración que incluye logger por módulo, dictConfig, logs en JSON, request_id adjunto automáticamente, escritura asincrónica de logs con QueueHandler y testing de logs con pytest. Todos los ejemplos usan Python 3.12 y solo necesitan la librería estándar. La sección de testing requiere pytest adicional.

2. Por qué print y basicConfig no son suficientes

Observa un servicio de procesamiento de órdenes escrito al estilo “rápido y listo”:

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

Este código tiene tres problemas:

  • print no tiene nivel, no tiene timestamp y no puede desactivarse a través de la configuración.
  • logging.info(...) escribe en el root logger. No sabes de qué módulo viene la línea de log y no puedes establecer el nivel por separado para cada parte.
  • basicConfig solo tiene efecto en la primera llamada, si el root logger aún no tiene un handler. Si alguna librería lo llama antes que tú, tu configuración se ignora silenciosamente sin advertencia alguna.

3. Logger por módulo con getLogger(__name__)

Primera regla: cada módulo crea su propio logger, nombrado según __name__.

# shop/orders.py
import logging

logger = logging.getLogger(__name__)  # nombre del 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)

Los nombres de logger forman un árbol jerárquico separado por puntos. shop.orders es hijo de shop, y shop es hijo de root. Un registro fluye desde el logger hijo hacia el logger padre (llamado propagation). Por lo tanto, solo necesitas adjuntar un handler en la raíz o en shop, luego establecer el nivel por separado para cada rama. Por ejemplo, puedes establecer shop.payments a DEBUG y mantener el resto en INFO.

También observa la manera de pasar parámetros: usa %s con argumentos en lugar de f-strings. Cuando un nivel está deshabilitado, la cadena no se formateará, así que no gastas recursos formateando logs que nunca se imprimirán. Además, las herramientas de agregación de logs pueden agrupar líneas por la misma plantilla de mensaje.

Nota: En librerías (packages que lanzas para que otros usen), no configures handlers. Solo llama a getLogger(__name__) y deja que la aplicación decida. Si quieres, añade logging.getLogger("your_lib").addHandler(logging.NullHandler()).

4. Configuración centralizada con dictConfig

En producción, deberías consolidar toda la configuración en un lugar y llamarla una única vez cuando la aplicación inicia. logging.config.dictConfig es perfecto para esto.

# 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"]},
    })

Puntos clave en la configuración:

  • "disable_existing_loggers": False es obligatorio en la práctica. El valor por defecto True deshabilitará todos los loggers creados antes de que dictConfig se ejecute, incluyendo el logger al inicio de tus módulos que ya han sido importados. El resultado es que los logs desaparecen sin error alguno.
  • Los handlers solo se adjuntan a root. El logger shop solo establece el nivel; los registros se propagan hacia arriba a root para ser impresos.
  • Root se establece a WARNING para suprimir logs ruidosos a nivel INFO de librerías de terceros. Tu código en la rama shop aún registra a nivel INFO.
  • El nivel se lee desde la variable de entorno LOG_LEVEL. Puedes habilitar DEBUG al investigar problemas sin modificar el código.

Llama la función en el punto de entrada:

# 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

Escribir a stdout es la opción correcta cuando ejecutas en un contenedor. Docker o Kubernetes recopilarán logs de este stream, así que no necesitas gestionar archivos tú mismo ni preocuparte por la rotación de logs.

5. Logs estructurados en formato JSON

Las herramientas de agregación de logs como Loki, Elasticsearch o CloudWatch buscan en JSON mucho mejor que en texto libre. Puedes escribir tu propio formatter sin necesidad de librerías externas:

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

Cómo funciona:

  • _RESERVED contiene los atributos predeterminados de un LogRecord, por ejemplo pathname, lineno, args.
  • Cualquier otro atributo se considera un campo personalizado y se añade a JSON. Estos campos suelen provenir del parámetro extra= o de un filter (ver sección 6).
  • default=str ayuda a json.dumps a evitar errores al encontrar tipos como Decimal o UUID.

Declara el formatter en dictConfig usando la clave especial "()":

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

Añade campos de negocio usando 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}

Nota: Las claves en extra no deben conflictuar con atributos existentes de LogRecord. Por ejemplo, extra={"name": "..."} o extra={"message": "..."} levantarán KeyError: "Attempt to overwrite 'name' in LogRecord".

6. Adjuntar request_id automáticamente con contextvars

Pasar extra={"request_id": ...} a cada llamada de log es verboso y fácil de olvidar. Una forma mejor es almacenar request_id en una ContextVar, luego usar un Filter para adjuntarlo a cada registro.

# 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  # no bloquear ningún registro, solo añadir datos

ContextVar es segura tanto con threads como con asyncio. Cada tarea o request mantiene su propio valor, así que no hay posibilidad de que una request “pida prestado” el ID de otra.

Adjunta el filter al handler, no al logger. Un filter adjunto a un logger solo se ejecuta para registros creados directamente desde ese logger, no para registros que se propagan desde loggers hijos hacia arriba.

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

Establece el valor al inicio de cada request, por ejemplo en 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"}

Ahora solo necesitas filtrar por request_id para ver el viaje completo de una request.

7. Registrar excepciones correctamente

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(...) registra a nivel ERROR y adjunta automáticamente el stack trace. Solo llámalo dentro de un bloque except. Si quieres registrar en un nivel diferente pero aún incluir el stack trace, usa logger.warning("...", exc_info=True).

Dos errores comunes:

  • logger.error(f"Charge failed: {e}") pierde el stack trace. Al investigar, solo ves la línea “gateway timeout” sin saber dónde ocurrió.
  • Registrar y re-lanzar en cada nivel. La misma excepción aparecerá cinco veces en el log. Registra en la capa de manejo de excepciones, donde decides no lanzarla más adelante.

8. No dejes que logging bloquee tu aplicación: QueueHandler

StreamHandler escribiendo a stdout es usualmente lo suficientemente rápido. Pero los handlers que envían por la red (syslog, HTTP, SMTP) o escriben en disco lento bloquearán el thread manejando la request. Para evitar esto, pon los registros en una cola y deja que un thread de fondo escriba al destino real:

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)  # vaciar lo restante al salir

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

El flujo funciona así:

  • El thread de la aplicación solo hace una cosa: put de registros en la queue. Esta operación es casi instantánea.
  • QueueListener se ejecuta en un thread separado, extrae registros y los pasa al target, por ejemplo un SysLogHandler.
  • atexit.register(listener.stop) asegura que los últimos logs se escriban antes de que el proceso salga.

Nota: Los filters como RequestIdFilter deben adjuntarse a QueueHandler, no al handler destino. El handler destino se ejecuta en el thread del listener, donde ContextVar no lleva el valor de la request original.

9. Testear logs con pytest caplog

Registrar advertencias de negocio, como órdenes grandes, es comportamiento digno de testing. Añade pytest a las dependencias de desarrollo:

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 baja temporalmente el nivel del logger shop.orders dentro del bloque with. caplog.records contiene los objetos LogRecord originales, así que puedes verificar el nivel, nombre del logger y cualquier campo extra si existe.

Nota: caplog adjunta un handler al root logger. Si tu configuración establece "propagate": False para el logger shop, el test no capturará ningún registro.

10. Errores comunes y qué no hacer

  • Configurar logging a nivel de módulo (llamar a basicConfig o dictConfig al importar). Solo configura en el punto de entrada, una única vez.
  • Handlers adjuntos dos veces, por ejemplo adjunto tanto a shop como a root mientras propagate es aún True. Cada línea de log se imprimirá dos veces. Solo deberías adjuntar handlers en un nivel.
  • Registrar datos sensibles: contraseñas, tokens, números de tarjeta. No registres objetos request completos. Si es necesario, escribe un Filter para enmascarar estos campos.
  • Usar RotatingFileHandler en aplicaciones multi-proceso (Gunicorn con múltiples workers). Múltiples procesos rotando el mismo archivo causarán pérdida de logs. Escribe a stdout en su lugar, o deja que cada worker empuje a través de QueueHandler hacia un único proceso que escriba.
  • Registrar demasiado a nivel INFO en loops calientes. El costo de almacenamiento y ruido aumentan muy rápidamente. Esos logs deberían estar a nivel DEBUG.
  • Usar JSON durante desarrollo local. JSON es difícil de leer a simple vista, así que elige el formatter basado en variable de entorno: plain para dev, json para producción.

11. Conclusión

Una buena configuración de logging para producción no es compleja, solo consistente. Usa getLogger(__name__) en cada módulo. Consolida la configuración en una única llamada dictConfig con disable_existing_loggers: False. Escribe JSON a stdout, adjunta request_id a través de ContextVar y Filter, y usa logger.exception para preservar stack traces. Cuando el handler destino es lento, cambia a QueueHandler y QueueListener.

Finalmente, trata los logs como parte del comportamiento de tu sistema y pruébalos con caplog. Cuando algo falla a las 2 de la mañana, estas líneas de log estructuradas te ayudarán a encontrar la causa raíz mucho más rápido.

pythonloggingproductionobservability