Logging en Python que realmente ayuda

Casi todos los consejos terminan en 'usá logs estructurados'. La parte difícil es decidir qué vale la pena anotar antes de que un incidente lo decida por vos.

Leí muchos logs que me dijeron que un job falló y nada más. La solución casi nunca fue una librería nueva. Fue decidir, de antemano, quién lee esta línea y qué se supone que tiene que hacer con ella.

Escribí para el que atiende la guardia

Cada línea cuesta leerla después, así que antes de agregar una, respondé para quién es: vos debugueando local, un auditor dentro de seis meses, o quien esté de guardia esta noche. Los tres quieren cosas distintas, y una línea que intenta servirle a los tres no le sirve a ninguno. Las líneas de debug pueden ser charlatanas. Las de guardia tienen que decir qué se rompió y qué revisar.

Estructurado, pero no JSON en todos lados

La librería estándar ya te da extra, y un formatter JSON encima te deja campos consultables sin tocar un solo call site: log.info('batch loaded', extra={'table': 'orders_silver', 'rows': 41233, 'run_id': run_id}). Igual dejá texto plano en local. Nadie quiere pasar sus tests unitarios por jq.

El contexto le gana al evento

Una línea que dice Load failed es un callejón sin salida. Esa misma línea con el run id, la partición de origen y la cantidad de filas que pasaron te dice dónde mirar sin abrir el código del pipeline. LoggerAdapter o contextvars te bajan ese contexto por el stack, así no vas pasando un run_id a mano por nueve funciones.

import contextvars
import logging

# Set once per run; every log line below picks it up without passing it around.
run_id = contextvars.ContextVar("run_id", default="-")


class RunContext(logging.Filter):
    def filter(self, record):
        record.run_id = run_id.get()
        return True


handler = logging.StreamHandler()
handler.addFilter(RunContext())
handler.setFormatter(logging.Formatter("%(levelname)s run=%(run_id)s %(message)s"))

log = logging.getLogger("pipeline")
log.addHandler(handler)
log.setLevel(logging.INFO)


def load_partition(table, partition):
    rows = 41233  # whatever the load actually returned
    # A JSON formatter would turn these extra fields into queryable columns.
    log.info("batch loaded", extra={"table": table, "partition": partition, "rows": rows})


run_id.set("2026-05-05T04:00")
load_partition("orders_silver", "2026-05-04")
# INFO run=2026-05-05T04:00 batch loaded
Ejemplo ilustrativo, no es código de producción.

Los niveles funcionan solo si los defendés

WARNING se desborda. Algo se ve raro una vez, alguien lo sube por las dudas, y un año después el stream de warnings son cuatrocientas líneas por hora que todo el mundo filtra. Vale revisarlo cada tanto: si nadie actuó nunca sobre algo, baja de nivel o se borra. Y los reintentos van en DEBUG hasta que falla el último.