Custom Handlers en slog: Observabilidad y Contexto

Cuando implementas un slog.Handler, estás definiendo el motor de salida de tus logs. Mientras que slog.Logger es la interfaz que usas en tu código (el “qué”), el Handler es el que decide cómo se procesan esos datos y hacia dónde van (el “cómo”). Implementar tu propio handler es la forma definitiva de integrar el logging con sistemas de observabilidad como Datadog, Honeycomb o sistemas de tracing internos.

Esta arquitectura de separación de responsabilidades permite que la lógica de negocio no sepa si un log se está escribiendo en la terminal, en un archivo JSON o se está enviando vía UDP a un agente de recolección. Al implementar slog.Handler, puedes interceptar el slog.Record para extraer información crítica del context.Context —como un trace_id de OpenTelemetry— y enriquecer cada log automáticamente sin que el desarrollador tenga que pasarlo manualmente en cada llamada a logger.Info().

Debes usar esta técnica cuando necesites que tus logs dejen de ser simples líneas de texto y se conviertan en eventos estructurados con metadatos de telemetría. Si implementas mal la lógica de Enabled (por ejemplo, realizando operaciones costosas allí), penalizarás el rendimiento de toda la aplicación; si fallas al gestionar los atributos en WithAttrs, podrías sufrir mutaciones de estado inesperadas que corrompan el contexto de tus logs en entornos concurrentes.

A diferencia de logrus, que es altamente flexible pero con una asignación de memoria (allocation) considerable, o zap/zerolog, que buscan el zero-allocation mediante un uso intensivo de tipos estáticos y buffers complejos, slog busca un punto de equilibrio: una API estandarizada y moderna que sea significativamente más rápida que las librerías legacy, pero lo suficientemente flexible para integrarse en cualquier ecosistema sin las restricciones de diseño extremas de las opciones de máximo rendimiento.

package main

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

// traceKey es una clave privada para evitar colisiones en el context.Context.
type traceKey struct{}

// ObservabilityHandler es un handler personalizado que extrae un trace_id
// del contexto y lo inyecta en la salida estructurada.
type ObservabilityHandler struct {
	attrs  []slog.Attr
	group  string
	logger *slog.Logger // Usamos un logger base para delegar la escritura
}

// NewObservabilityHandler inicializa nuestro handler decorador.
func NewObservabilityHandler() *ObservabilityHandler {
	return &ObservabilityHandler{
		// Usamos el handler JSON estándar como base para la escritura real.
		// En producción, podrías usar un handler que envíe datos por UDP/gRPC.
		attrs: []slog.Attr{},
	}
}

func (h *ObservabilityHandler) Enabled(ctx context.Context, level slog.Level) bool {
	// Delegamos la decisión de si el nivel está habilitado al handler base.
	return true
}

func (h *ObservabilityHandler) Handle(ctx context.Context, r slog.Record) error {
	// Extraemos el trace_id del contexto si existe.
	traceID, _ := ctx.Value(traceKey).(string)

	// Creamos un nuevo registro basado en el original para no mutar el original.
	// En un escenario de alto rendimiento, usaríamos un buffer de objetos.
	r.AddContext(ctx)

	// Simulamos la salida estructurada. En un handler real, aquí construirías
	// el JSON incluyendo el traceID y los atributos acumulados.
	fmt.Fprintf(os.Stdout, "time=%s level=%s msg=%q trace_id=%q ",
		r.Time.Format(time.RFC3339),
		r.Level,
		r.Message,
		traceID,
	)

	// Imprimir atributos del registro (r.Attrs)
	r.Attrs(func(a slog.Attr) bool {
		fmt.Printf(" %s=%v", a.Key, a.Value)
		return true
	})
	fmt.Fprint(os.Stdout, "\n")

	return nil
}

// WithAttrs es crucial: devuelve un nuevo handler con los atributos integrados.
// Esto es lo que permite que logger.With("user_id", 123) funcione correctamente.
func (h *ObservabilityHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs))
	copy(newAttrs, h.attrs)
	copy(newAttrs[len(h.attrs):], attrs)

	return &ObservabilityHandler{
		attrs: newAttrs,
		group: h.group,
	}
}

func (h *ObservabilityHandler) WithGroup(name string) slog.Handler {
	return &ObservabilityHandler{
		attrs: h.attrs,
		group: h.group + "." + name,
	}
}

func main() {
	// 1. Configuración del handler y el logger
	handler := NewObservabilityHandler()
	logger := slog.New(handler)

	// 2. Inyectamos un Trace ID en el contexto
	ctx := context.WithValue(context.Background(), traceKey{}, "abc-123-xyz")

	// 3. Log estándar
	logger.InfoContext(ctx, "procesando solicitud")

	// 4. Log con atributos adicionales (usa WithAttrs internamente)
	subLogger := logger.With("request_id", "req-999")
	subLogger.InfoContext(ctx, "validando permisos")

	// 5. Log con grupos (usa WithGroup internamente)
	groupedLogger := subLogger.WithGroup("database")
	groupedLogger.WarnContext(ctx, "latencia alta detectada", "ms", 450)
}

Análisis del funcionamiento

El corazón de esta implementación es la capacidad de interceptar el flujo de datos en el método Handle. Fíjate cómo, mediante ctx.Value(traceKey), extraemos el trace_id directamente del context.Context. Esto es vital para la observabilidad moderna: el log deja de ser una entrada aislada y se convierte en un eslabón de una traza distribuida, permitiéndote correlacionar errores en el frontend con logs específicos en el backend.

Un punto crítico de rendimiento y diseño es el método WithAttrs. Cuando llamas a logger.With(...), slog no modifica el logger actual; en su lugar, llama a WithAttrs para crear una nueva instancia del handler. En nuestro ejemplo, newAttrs se crea mediante un make y un copy. Esto garantiza la inmutabilidad: el logger original permanece intacto, permitiendo que diferentes goroutines usen sub-loggers con contextos distintos sin interferir entre sí.

El método WithGroup implementa la jerarquía de logs. Al concatenar h.group + "." + name, estamos construyendo un prefijo lógico. Aunque en este ejemplo simplificado lo imprimimos como un string, en un handler de producción (como uno que genere JSON para Datadog), esto se traduce en un objeto anidado, facilitando enormemente la creación de dashboards y la filtración de logs por componentes.

El error frecuente

Un error clásico al implementar WithAttrs es mutar el slice de atributos original en lugar de devolver uno nuevo.

// ERROR: Mutación de estado (No usar en producción)
func (h *ObservabilityHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    h.attrs = append(h.attrs, attrs...) // ¡ERROR! Estás modificando el handler original
    return h
}

Si cometes este error, el comportamiento del logger se vuelve impredecible y propenso a race conditions. Si una goroutine crea un sub-logger con un request_id, ese atributo se filtrará inesperadamente en todas las demás goroutines que compartan el logger base, contaminando los logs de otros usuarios y rompiendo la trazabilidad. Siempre, sin excepción, debes retornar una nueva instancia de tu struct con una copia de los datos.

203

Dejar un comentario

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

Scroll al inicio