El paquete log/slog incorporado a la biblioteca estándar aporta una capa de logging estructurado que reemplaza al paquete log clásico en proyectos profesionales. A diferencia de las líneas de texto planas que produce log.Printf, slog genera entradas con pares clave-valor tipados que cualquier agregador (Loki, Datadog, CloudWatch) puede indexar sin necesidad de parsear expresiones regulares.
Loggers y handlers
Un logger en slog es un objeto fino que delega el trabajo real en un handler. El handler decide el formato de salida (texto o JSON), aplica filtros de nivel y se encarga de volcar los bytes al destino. Esa separación permite cambiar el formato de producción sin tocar las llamadas al logger.
package main
import (
"log/slog"
"os"
)
func main() {
// Handler JSON escribiendo a stdout
handler := slog.NewJSONHandler(os.Stdout, nil)
logger := slog.New(handler)
logger.Info("servicio arrancado", "puerto", 8080, "entorno", "prod")
}
La salida anterior es una línea de JSON con las claves time, level, msg, puerto y entorno. Este formato es idóneo para producción porque es parseable por máquina y conserva los tipos originales de los valores.
Para desarrollo local suele preferirse una salida legible por humanos. El handler de texto renderiza la misma información con pares clave=valor:
handler := slog.NewTextHandler(os.Stderr, nil)
logger := slog.New(handler)
logger.Info("petición recibida", "método", "GET", "ruta", "/api/users")
// time=2026-04-17T10:15:30.000+02:00 level=INFO msg="petición recibida" método=GET ruta=/api/users
El patrón habitual es usar
slog.NewJSONHandleren producción yslog.NewTextHandleren desarrollo, seleccionando uno u otro por variable de entorno al arrancar el servicio.
Niveles y filtrado dinámico
Slog ofrece cuatro niveles estándar: LevelDebug, LevelInfo, LevelWarn y LevelError. Cada método del logger corresponde a uno de ellos. El filtrado se configura en el handler a través de HandlerOptions:
opts := &slog.HandlerOptions{
Level: slog.LevelWarn, // descarta Debug e Info
}
handler := slog.NewJSONHandler(os.Stdout, opts)
logger := slog.New(handler)
logger.Debug("esto no aparece")
logger.Info("tampoco")
logger.Warn("aviso importante", "usuario_id", 42)
Para cambiar el nivel en caliente sin reiniciar el servicio, se usa un slog.LevelVar. Ese tipo implementa slog.Leveler y permite mutar el umbral en tiempo de ejecución:
var nivel slog.LevelVar
nivel.Set(slog.LevelInfo)
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &nivel})
logger := slog.New(handler)
// Más tarde, en un handler de admin:
nivel.Set(slog.LevelDebug) // los próximos logs incluyen Debug
Este mecanismo resulta útil para activar trazas detalladas durante una incidencia sin redesplegar.
Atributos tipados y grupos
Pasar pares clave, valor posicionales es conveniente pero propenso a errores si olvidas un valor. Para llamadas críticas conviene usar atributos tipados con slog.String, slog.Int, slog.Duration u otros constructores. El compilador y el linter pueden detectar más errores y el coste de asignación es menor:
logger.Info("pedido creado",
slog.String("cliente", "acme"),
slog.Int("pedido_id", 9123),
slog.Duration("latencia", 42*time.Millisecond),
)
Cuando varios atributos pertenecen a la misma entidad, se agrupan con slog.Group. El handler los serializa como un objeto anidado en JSON, lo que facilita filtros como pedido.id:9123 en el buscador del agregador:
logger.Info("pedido creado",
slog.Group("pedido",
slog.Int("id", 9123),
slog.String("estado", "pendiente"),
slog.Float64("importe", 120.50),
),
slog.String("cliente", "acme"),
)
Los atributos tipados son recomendables en bucles calientes o en código de librería reutilizable. Para trazas puntuales, el estilo posicional
clave, valorsigue siendo válido y más corto.
Enriquecer el logger con With
Un patrón frecuente consiste en derivar loggers hijos que llevan atributos fijos. Eso evita repetir los mismos pares en cada llamada y garantiza coherencia. El método With devuelve un nuevo logger que preserva el estado del padre:
base := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Logger del subsistema de pagos
pagos := base.With("subsistema", "pagos", "version", "v3")
pagos.Info("cobro iniciado", "pedido_id", 9123)
pagos.Warn("tarjeta expirada", "pedido_id", 9124)
Todas las entradas emitidas por pagos incluyen automáticamente subsistema=pagos y version=v3, lo que simplifica el filtrado por servicio.
Integración con context
El patrón idiomático en servicios HTTP consiste en inyectar un logger por petición en el contexto, con atributos como request_id, user_id y ruta. Los métodos terminados en Context (InfoContext, ErrorContext, etc.) pasan el contexto al handler para que pueda extraer información:
type ctxKey struct{}
var loggerKey ctxKey
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey, l)
}
func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return l
}
return slog.Default()
}
El middleware HTTP genera un logger derivado por cada petición y lo propaga al resto de la cadena:
func middlewareLogger(base *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = uuid.NewString()
}
l := base.With(
slog.String("request_id", reqID),
slog.String("método", r.Method),
slog.String("ruta", r.URL.Path),
)
ctx := WithLogger(r.Context(), l)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Desde cualquier handler de negocio, basta con recuperar el logger del contexto para emitir trazas que ya llevan los datos de la petición:
func listarUsuarios(w http.ResponseWriter, r *http.Request) {
l := FromContext(r.Context())
l.InfoContext(r.Context(), "consultando base de datos")
// ...
l.InfoContext(r.Context(), "usuarios devueltos", slog.Int("total", 42))
}
flowchart LR
A[Petición HTTP] --> B[Middleware slog]
B -- logger base + request_id --> C[Contexto de la petición]
C --> D[Handler de negocio]
C --> E[Capa repositorio]
D -- InfoContext --> F[Handler JSON]
E -- ErrorContext --> F
F --> G[stdout o archivo]
Logger global y configuración del default
Para código legado o utilidades sueltas conviene disponer de un logger por defecto. La función slog.SetDefault instala un logger global que también redirige las llamadas del paquete log clásico:
func InicializarLogger(entorno string) *slog.Logger {
var h slog.Handler
opts := &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: entorno == "dev", // incluye fichero:línea en dev
}
if entorno == "dev" {
h = slog.NewTextHandler(os.Stderr, opts)
} else {
h = slog.NewJSONHandler(os.Stdout, opts)
}
logger := slog.New(h).With("servicio", "api-pedidos")
slog.SetDefault(logger)
return logger
}
A partir de ese punto, cualquier slog.Info(...) o log.Printf(...) pasa por el handler configurado. La opción AddSource incorpora automáticamente la ruta del fichero y la línea, lo que acelera la depuración en entornos no productivos.
Handlers personalizados
La interfaz slog.Handler tiene cuatro métodos y permite implementaciones propias: redirigir a un servicio remoto, enmascarar datos sensibles o enriquecer cada entrada con datos de trazado distribuido. Un patrón sencillo consiste en envolver un handler existente:
type handlerConTraza struct {
slog.Handler
}
func (h handlerConTraza) Handle(ctx context.Context, r slog.Record) error {
if span := trace.SpanFromContext(ctx); span.SpanContext().IsValid() {
r.AddAttrs(
slog.String("trace_id", span.SpanContext().TraceID().String()),
slog.String("span_id", span.SpanContext().SpanID().String()),
)
}
return h.Handler.Handle(ctx, r)
}
Este wrapper añade el trace_id de OpenTelemetry a cada registro cuando la petición forma parte de una traza distribuida, sin modificar las llamadas en el resto del código.
Cuando mantengas tu propio handler, delega siempre en un handler base y limita tu implementación a enriquecer atributos o filtrar entradas. De ese modo preservas el formato de serialización y el filtrado de niveles sin reescribirlos.
Errores estructurados
Registrar un error con slog es cuestión de incluirlo como atributo. Si usas slog.Any o simplemente pasas la pareja "err", err, slog serializa el mensaje del error. Para mantener la cadena de envolturas con fmt.Errorf y %w, es habitual acompañar el error con el atributo que aporte más contexto:
if err := repo.Actualizar(ctx, pedido); err != nil {
l.ErrorContext(ctx, "actualizar pedido falló",
slog.String("pedido_id", pedido.ID),
slog.Any("err", err),
)
return fmt.Errorf("actualizar pedido %s: %w", pedido.ID, err)
}
La combinación de logs estructurados, error wrapping y request id cubre la mayoría de incidencias en microservicios sin necesidad de herramientas externas adicionales.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Go es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Go
Explora más contenido relacionado con Go y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Configurar handlers JSON y Text con niveles personalizados, enriquecer registros con atributos y grupos, inyectar valores contextuales con slog.LoggerFn y construir un logger global compartido con context para trazabilidad completa en servidores HTTP.