¿Cómo es en la práctica un buen structured logging, y por qué le gana a los logs estilo printf a gran escala?
Los logs estilo printf funcionan con un servicio y diez usuarios; con una docena de servicios y un incidente real, solo los logs JSON estructurados y correlacionables permiten reconstruir lo ocurrido.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo es en la práctica un buen structured logging, y por qué le gana a los logs estilo printf a gran escala?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#El problema de "imprimir y buscar con grep"
Todo backend engineer empieza igual: print(f"procesando pedido {order_id}") o console.log("usuario autenticado", userId). Funciona. Podés hacer grep sobre la salida, leerla a simple vista en una terminal, y para un solo proceso con tráfico modesto es genuinamente suficiente. En el momento en que tenés más de una instancia de un servicio —ni hablar de una docena de microservicios detrás de un balanceador— ese enfoque se derrumba, no porque el logging haya dejado de funcionar, sino porque la forma de los datos dejó de coincidir con cómo realmente necesitás usarlos.
Los logs en texto libre están optimizados para que una persona los lea en vivo, línea por línea, en la misma terminal donde se escribieron. Son pésimos para las preguntas que realmente hacés durante un incidente: "mostrame cada línea de log de este pedido específico, en todos los servicios por los que pasó, en orden." Una línea de printf no tiene campos confiables sobre los que filtrar — order_id puede aparecer como order=123, orderId: 123 o #123 según qué ingeniero escribió ese log el año pasado. Extraerlo implica escribir una expresión regular, y cada formato inconsistente es una regex más que se rompe en producción a las dos de la mañana.
#Qué significa "estructurado" en la práctica
Structured logging significa que cada entrada de log se emite como un registro autodescriptivo —casi siempre JSON en la práctica— con un conjunto de campos consistente y parseable por máquina, en lugar de una oración de formato libre. Como mínimo, una buena línea de log estructurado lleva:
timestamp— siempre en UTC, siempre en un formato fijo (ISO-8601 / RFC3339), nunca "como lo formatee el reloj local del servidor de turno."level—debug/info/warn/error, como un enum real, no incrustado en el texto del mensaje.serviceylogger— qué proceso y qué componente emitió la línea.message— un string corto, legible y estable (evitá interpolar datos variables directamente ahí — ese campo debería leerse igual en cada ocurrencia del mismo evento).trace_id(ocorrelation_id) — el campo que justifica tener los otros cuatro.- Campos contextuales arbitrarios —
order_id,user_id,amount_cents,latency_ms, lo que sea relevante para ese evento puntual — como claves propias, no interpolados dentro del mensaje.
Una vez que cada línea de log es un registro con campos nombrados en lugar de una oración, tu backend de logs (Elasticsearch, Loki, CloudWatch Logs Insights, Datadog, lo que uses) puede indexar y filtrar directamente sobre esos campos. "Mostrame cada log error donde service=payments y order_id=4471" pasa de ser arqueología de expresiones regulares a ser una consulta real.
#El correlation id es el punto central de todo esto
Un solo campo hace más trabajo que todos los demás juntos: un trace id o correlation id generado una única vez, en el borde del sistema, y propagado a través de cada llamada descendente y cada línea de log que ese pedido toca. Ya sea que lo implementes como un header custom X-Request-Id, adoptes el formato traceparent de W3C, o dejes que un SDK de OpenTelemetry lo genere por vos, el mecanismo es el mismo: generarlo si todavía no viene en el request entrante, guardarlo en un objeto de contexto (thread-local, contextvars, AsyncLocalStorage, un contexto acotado a la goroutine, lo que tu lenguaje ofrezca), propagarlo en cada llamada saliente que haga tu servicio, y estampárselo automáticamente a cada línea de log, para que ningún ingeniero se olvide de incluirlo.
Con ese único campo en su lugar, "qué le pasó a este pedido" deja de ser una excavación de varias horas entre los logs de seis servicios distintos, y pasa a ser: filtrar los logs de todos los servicios por trace_id=abc123, ordenar por timestamp, y leer la historia de arriba hacia abajo.
#Una implementación real
Acá va un ejemplo mínimo pero con forma de producción en Python: un formatter JSON para el módulo estándar logging, más un trace id basado en contextvars que sobrevive a los límites de async sin tener que pasarlo explícitamente por cada firma de función.
import loggingimport sysimport jsonimport uuidfrom contextvars import ContextVartrace_id_var: ContextVar[str] = ContextVar("trace_id", default="-")class JsonFormatter(logging.Formatter):def format(self, record: logging.LogRecord) -> str:payload = {"timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),"level": record.levelname.lower(),"logger": record.name,"service": "checkout-api","message": record.getMessage(),"trace_id": trace_id_var.get(),}for key, value in getattr(record, "extra_fields", {}).items():payload[key] = valueif record.exc_info:payload["exception"] = self.formatException(record.exc_info)return json.dumps(payload)handler = logging.StreamHandler(sys.stdout)handler.setFormatter(JsonFormatter())logger = logging.getLogger("checkout")logger.addHandler(handler)logger.setLevel(logging.INFO)def handle_checkout(order_id: str, amount_cents: int) -> None:trace_id_var.set(str(uuid.uuid4()))logger.info("order created",extra={"extra_fields": {"order_id": order_id, "amount_cents": amount_cents}},)try:charge_card(order_id, amount_cents)except PaymentDeclined as exc:logger.error("payment declined",extra={"extra_fields": {"order_id": order_id, "reason": str(exc)}},)raise
Que produce líneas de log como esta:
{"timestamp":"2026-08-21T14:03:11+0000","level":"error","logger":"checkout","service":"checkout-api","message":"payment declined","trace_id":"5f2b9e1a-3c1d-4b8a-9c2e-1a2b3c4d5e6f","order_id":"ORD-4471","reason":"insufficient_funds"}
Cada campo de esa línea es consultable por sí solo. Esa es toda la propuesta de valor del structured logging: cambiaste un poco de legibilidad en una terminal cruda por una cantidad enorme de capacidad de consulta en un agregador, y a cualquier escala real, ese intercambio no tiene competencia.
#Printf vs. logs estructurados
| estilo printf | estructurado (JSON) | |
|---|---|---|
| Filtrable por campo | No — necesita regex, frágil | Sí — consulta nativa sobre cualquier clave |
| Correlación entre servicios | Manual, propenso a errores | Automática vía trace_id |
Agregación ("contar errores por order_id") | Poco práctico | Trivial en Loki/ES/Datadog |
| Costo de parseo por máquina | Alto (una regex por formato) | Cero (ya es un registro) |
| Legibilidad humana, en crudo | Mejor | Levemente peor (mitigado por visores de logs) |
| Control de cardinalidad | Ad hoc | Reforzado por el esquema y convenciones de nombres |
Un buen structured logging también es lo que hace que logs y trazas trabajen juntos en vez de ser dos herramientas desconectadas: si tu trace id es el mismo valor en tus logs que en tu backend de tracing (Jaeger, Tempo, X-Ray), podés saltar de "esta línea de log se ve sospechosa" directamente a "acá está la traza distribuida completa de ese pedido exacto", que suele ser el camino más corto dentro de un incidente de producción real.