El logging estructurado consiste en emitir logs como una secuencia de pares clave-valor en lugar de simples cadenas de texto plano. Cuando imprimes fmt.Printf("user %d failed: %v", id, err), estás creando un mensaje diseñado para ser leído por humanos, pero un infierno para ser procesado por máquinas. En un entorno de producción con millones de líneas de log, un sistema de observabilidad (como Elasticsearch, Datadog o Loki) no puede “entender” qué parte de ese string es el ID del usuario y qué parte es el error sin recurrir a expresiones regulares costosas y frágiles.
La arquitectura de los sistemas distribuidos modernos exige que los logs sean datos indexables. Si emites un log en formato JSON como {"user_id": 42, "error": "timeout"}, cualquier motor de búsqueda puede indexar user_id como un campo numérico y permitirte ejecutar consultas inmediatas como SELECT * WHERE user_id = 42.
Históricamente, la comunidad de Go vivió una fragmentación masiva. Empezamos con el paquete log de la biblioteca estándar, que es extremadamente básico y carece de niveles (info, error, etc.) y soporte estructurado. Luego surgieron librerías como logrus, que introdujo el concepto de niveles y estructuras, pero su rendimiento se volvió un cuello de botella en sistemas de alta carga. Esto impulsó la creación de zap (de Uber) y zerolog, enfocadas en el “zero allocation”, optimizando el uso de la memoria y el uso de la CPU. Sin embargo, el problema era la interoperabilidad: si usabas una librería de terceros que dependía de logrus y tu aplicación usaba zap, tus logs terminaban siendo un desorden de formatos inconsistentes.
slog [disponible desde Go 1.21] llega para resolver este dilema. No busca ser necesariamente más rápido que zerolog en un benchmark de micro-optimización, sino proporcionar una interfaz estándar y extensible. Es “suficientemente rápido” para el 99% de los casos y, lo más importante, permite que todos los paquetes de tu ecosistema hablen el mismo idioma de logging. Debes usar logging estructurado siempre que tu aplicación no sea un simple CLI local y necesite integrarse en un stack de observabilidad. Si ignoras esto y sigues usando fmt.Println o log.Printf, tu capacidad para realizar debugging en producción mediante correlación de trace_id se verá drásticamente limitada.
package main
import (
"context"
"log/slog"
"os"
"time"
)
// ProcessOrder simula una operación de negocio compleja.
// Nota cómo recibe un *slog.Logger para permitir inyección de contexto.
func ProcessOrder(ctx context.Context, logger *slog.Logger, orderID string, amount float64) {
// Creamos un logger con contexto específico para este pedido.
// Esto añade automáticamente 'order_id' a todos los logs de esta ejecución.
requestLogger := logger.With(slog.String("order_id", orderID))
requestLogger.Info("iniciando procesamiento de pedido",
slog.Float64("amount", amount),
slog.String("currency", "USD"),
)
// Simulamos una operación que puede fallar
if amount > 1000 {
requestLogger.Error("pedido rechazado por monto excede límite",
slog.String("reason", "limit_exceeded"),
slog.Int("retry_count", 0),
)
return
}
// Usamos un Group para organizar datos jerárquicos en el JSON resultante.
requestLogger.Info("pedido completado con éxito",
slog.Group("metadata",
slog.String("provider", "stripe"),
slog.Duration("latency", 150*time.Millisecond),
),
)
}
func main() {
// Configuramos un handler de JSON para producción.
// En desarrollo, podrías usar slog.NewTextHandler para legibilidad humana.
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
logger := slog.New(handler)
// Caso 1: Pedido normal
ProcessOrder(context.Background(), logger, "ORD-12345", 250.50)
// Caso 2: Pedido que dispara un error
ProcessOrder(context.Background(), logger, "ORD-99999", 5000.00)
}
En el ejemplo anterior, hemos configurado slog.NewJSONHandler, lo que garantiza que la salida sea un JSON válido, ideal para pipelines de ingestión de logs. Fíjate en el uso de logger.With(...) dentro de ProcessOrder; esto es fundamental para el rastreo distribuido, ya que permite “inyectar” metadatos (como un order_id o un request_id) una sola vez y que todos los logs subsiguientes que utilicen ese objeto requestLogger los incluyan sin esfuerzo manual.
La función ProcessOrder utiliza slog.Group para agrupar campos bajo una clave metadata. En el JSON resultante, esto se traduce en un objeto anidado, lo cual es vital para mantener la limpieza cuando el número de atributos crece. Además, en lugar de pasar valores genéricos con slog.Any (que utiliza reflexión y es más lento), preferimos usar funciones de tipado fuerte como slog.String, slog.Float64 o slog.Duration. Esto minimiza las asignaciones en el heap y hace que el serializador sea mucho más eficiente.
El método logger.Error en el caso del pedido rechazado no solo emite un mensaje, sino que estructura el error con claves como reason y retry_count. Esto permite que, si tienes un problema masivo con pagos de Stripe, puedas filtrar en tu dashboard de logs exactamente qué reason está causando la mayoría de los fallos sin tener que parsear texto.
El error frecuente
Un error común al migrar a slog es intentar construir el mensaje del log usando fmt.Sprintf antes de pasarlo al logger.
// MAL: Esto anula el propósito del logging estructurado y es costoso.
userID := "user-123"
slog.Info(fmt.Sprintf("usuario %s ha iniciado sesión", userID), "user_id", userID)
En el ejemplo de arriba, el valor de user_id queda “quemado” dentro de la cadena de texto del mensaje. Si quieres buscar todos los inicios de sesión de ese usuario, tendrás que usar una búsqueda de texto completo (regex) en lugar de una búsqueda exacta de campo en tu base de datos de logs. El mensaje debe ser una constante estática y los datos deben ser siempre atributos adicionales.
La eficiencia de un sistema de observabilidad depende de la calidad de los datos que sus aplicaciones emiten.
N° 200