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:
printno 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.basicConfigsolo 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ñadelogging.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": Falsees obligatorio en la práctica. El valor por defectoTruedeshabilitará todos los loggers creados antes de quedictConfigse ejecute, incluyendo elloggeral 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 loggershopsolo establece el nivel; los registros se propagan hacia arriba a root para ser impresos. - Root se establece a
WARNINGpara suprimir logs ruidosos a nivelINFOde librerías de terceros. Tu código en la ramashopaún registra a nivelINFO. - El nivel se lee desde la variable de entorno
LOG_LEVEL. Puedes habilitarDEBUGal 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:
_RESERVEDcontiene los atributos predeterminados de unLogRecord, por ejemplopathname,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=strayuda ajson.dumpsa evitar errores al encontrar tipos comoDecimaloUUID.
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
extrano deben conflictuar con atributos existentes deLogRecord. Por ejemplo,extra={"name": "..."}oextra={"message": "..."}levantaránKeyError: "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:
putde registros en laqueue. Esta operación es casi instantánea. QueueListenerse ejecuta en un thread separado, extrae registros y los pasa altarget, por ejemplo unSysLogHandler.atexit.register(listener.stop)asegura que los últimos logs se escriban antes de que el proceso salga.
Nota: Los filters como
RequestIdFilterdeben adjuntarse aQueueHandler, no al handler destino. El handler destino se ejecuta en el thread del listener, dondeContextVarno 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:
caplogadjunta un handler al root logger. Si tu configuración establece"propagate": Falsepara el loggershop, el test no capturará ningún registro.
10. Errores comunes y qué no hacer
- Configurar logging a nivel de módulo (llamar a
basicConfigodictConfigal importar). Solo configura en el punto de entrada, una única vez. - Handlers adjuntos dos veces, por ejemplo adjunto tanto a
shopcomo a root mientraspropagatees aúnTrue. 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
Filterpara enmascarar estos campos. - Usar
RotatingFileHandleren 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 deQueueHandlerhacia un único proceso que escriba. - Registrar demasiado a nivel
INFOen loops calientes. El costo de almacenamiento y ruido aumentan muy rápidamente. Esos logs deberían estar a nivelDEBUG. - Usar JSON durante desarrollo local. JSON es difícil de leer a simple vista, así que elige el formatter basado en variable de entorno:
plainpara dev,jsonpara 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.