Arquitectura de slog: Logger, Handler y Record

La arquitectura de log/slog [disponible desde Go 1.21] se basa en un diseño de tres capas que separa la intención del log de su representación final. El slog.Logger actúa como el frontend de la API; es la interfaz tipada que utilizas en tu lógica de negocio para emitir eventos. Sin embargo, el Logger no escribe nada en el sistema de archivos o la consola; su única responsabilidad es empaquetar la información (timestamp, nivel de severidad, mensaje y atributos) en un objeto slog.Record. Este Record es una instantánea inmutable del evento que se pasa al slog.Handler. El Handler es el motor del sistema: es la interfaz que implementa la lógica de backend, decidiendo si el log se formatea como JSON, texto plano, o si se envía a un sistema de observabilidad externo.

Este desacoplamiento permite que tu aplicación sea agnóstica al destino del log. Puedes usar slog.NewTextHandler para una salida legible durante el desarrollo local y cambiar a slog.NewJSONHandler en producción para que herramientas como Loki o ELK puedan parsear los atributos sin expresiones regulares costosas. Si intentas usar el logger por defecto (slog.Default()) en servicios de alta concurrencia, corres el riesgo de perder la trazabilidad, ya que este apunta al logger estándar de la librería log, el cual no es estructurado por defecto. La clave es la inyección de dependencias: pasar el *slog.Logger como parte de la estructura de tus servicios en lugar de depender de variables globales, lo que garantiza que cada goroutine o request pueda tener su propio contexto de atributos (como un request_id) sin colisiones.

package main

import (
	"context"
	"log/slog"
	"os"
)

// Database simula un componente que depende de un logger estructurado.
type Database struct {
	// Inyectamos el logger para permitir testing y control de salida.
	logger *slog.Logger
	name   string
}

func (db *Database) ExecuteQuery(ctx context.Context, query string) error {
	// Usamos InfoContext para pasar el contexto de la solicitud,
	// permitiendo que el handler extraiga valores de trazabilidad si fuera necesario.
	db.logger.InfoContext(ctx, "ejecutando consulta",
		slog.String("query", query),
		slog.String("db_name", db.name),
	)
	return nil
}

func main() {
	// En producción, preferimos JSON para facilitar el parseo en sistemas de logs.
	// El Handler es el que realmente hace el trabajo de serialización.
	jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelDebug,
	})

	// Creamos un logger base a partir del handler.
	baseLogger := slog.New(jsonHandler)

	// .With() crea un nuevo Logger que comparte el mismo Handler pero
	// añade atributos que estarán presentes en todos sus logs (contexto persistente).
	// Esto es extremadamente eficiente porque no duplica el handler, solo el receptor.
	requestLogger := baseLogger.With(slog.String("request_id", "req-99b2-af41"))

	db := &Database{
		logger: requestLogger,
		name:   "prod_replica_01",
	}

	// Simulamos una operación
	_ = db.ExecuteQuery(context.Background(), "SELECT id FROM users WHERE active = true")
}

En el ejemplo anterior, observa cómo baseLogger.With no modifica el logger original, sino que devuelve una nueva instancia de *slog.Logger que encapsula el atributo request_id. Esta es una operación de bajo costo diseñada para el flujo de una petición. Cuando db.ExecuteQuery llama a db.logger.InfoContext, el runtime crea un slog.Record que ya contiene el request_id y el mensaje “ejecutando consulta”. Este Record es entregado al JSONHandler, que toma los atributos adicionales (query y db_name) y los serializa en un único objeto JSON plano. El uso de slog.String (o cualquier función slog.Attr) es crucial: evita el uso de interface{} genéricos y permite que el Handler reciba tipos ya conocidos, optimizando la asignación de memoria y evitando la reflexión pesada en el path crítico.

El error frecuente

Un error común en arquitecturas de microservicios es utilizar slog.Info (el logger global) dentro de librerías o componentes internos.

// MAL: Una librería que usa el logger global
func (s *Service) DoWork() {
    slog.Info("trabajo realizado") // Esto usa el logger por defecto
}

Si tu librería usa slog.Info, el desarrollador que consuma tu librería no tiene control sobre cómo se ven esos logs. No puede decidir que esos logs de tu librería aparezcan en formato JSON, ni puede inyectarles un request_id de forma automática. Si el consumidor está usando un JSONHandler en su main pero tu librería usa el logger global, los logs de tu librería saldrán como texto plano (o no saldrán si se ha reconfigurado el logger por defecto), rompiendo la consistencia del stream de logs en producción. La regla de oro: las librerías deben recibir el logger como parámetro o mediante una interfaz, nunca deben decidir el destino de la salida.

201

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio