Registro Estructurado en Go con slog para Observabilidad y Alertas
Registros JSON consultables que se conectan a trazas.
Los registros (logs) son una interfaz de depuración que puedes seguir utilizando incluso cuando el sistema está en llamas. El problema es que los registros en texto plano envejecen mal: en cuanto necesitas filtrado, agregación y alertas, empiezas a analizar oraciones.

La registro estructurada es el antídoto. Convierte cada línea de registro en un pequeño evento con campos estables, para que las herramientas puedan buscar y agregar de forma fiable. Para ver cómo se conectan los registros con las métricas, los paneles de control y las alertas en el stack más amplio, consulta la Guía de Observabilidad: Monitorización, Métricas, Prometheus & Grafana.
Qué es la registro estructurada y por qué escala
La registro estructurada es un registro donde un registro no es solo una cadena de texto, sino un mensaje más atributos de tipo clave-valor. La idea es aburrida de la mejor manera posible: una vez que los registros son legibles por máquina, un incidente deja de ser un concurso de grep.
Una comparación rápida:
Texto plano (prioridad humano, hostil a herramientas)
failed to charge card user=42 amount=19.99 ms=842 err=timeout
Estructurado (prioridad herramienta, sigue siendo legible)
{"msg":"failed to charge card","user_id":42,"amount":19.99,"duration_ms":842,"error":"timeout"}
En producción, ayuda pensar en los registros como un flujo de eventos emitido por el proceso, mientras que el enrutamiento y el almacenamiento viven fuera de la aplicación. Ese modelo mental te empuja a escribir un evento por línea y mantener los eventos fáciles de enviar y reprocesar.
Slog en Go como interfaz de registro compartida
Go ha tenido el paquete log clásico desde siempre, pero los servicios modernos necesitan niveles y campos. El paquete log/slog (Go 1.21 y posteriores) trae la registro estructurada a la biblioteca estándar y formaliza una forma común para los registros: hora, nivel, mensaje y atributos. Para un resumen compacto de lenguaje y comandos junto con esta guía, consulta la Hoja de trucos de Go.
Las partes clave del modelo son:
Registro
Un registro es lo que sucedió. En términos de slog, contiene la hora, el nivel, el mensaje y un conjunto de atributos. Creas registros mediante métodos como Info y Error, o mediante Log cuando quieres proporcionar el nivel explícitamente.
Atributos
Los atributos son los pares clave-valor que hacen que los registros sean consultables. Si registras el mismo concepto bajo tres claves diferentes (user, userId, uid), obtienes tres conjuntos de datos diferentes. Las claves consistentes es donde se esconde el valor real.
Manejador
Un manejador es cómo los registros se convierten en bytes. El TextHandler integrado escribe salida clave=valor, mientras que JSONHandler escribe JSON delimitado por líneas. Los manejadores también son donde suele ocurrir la redacción, el renombrado de claves y el enrutamiento de salida.
Una característica infravalorada es que slog puede situarse delante del código existente. Cuando configuras un registrador slog predeterminado, las funciones slog de nivel superior lo usan, y el paquete log clásico también puede redirigirse a él. Eso hace posible la migración incremental.
Grupos
Los grupos resuelven el problema de “cada subsistema usa id”. Puedes agrupar un conjunto de atributos para una solicitud (request.method, request.path) o dar espacio a todo un subsistema con WithGroup para que las claves no colisionen.
Una configuración de slog moldeada para producción
La siguiente configuración logra los objetivos habituales.
Los ejemplos usan un pequeño paquete logx; para saber dónde suelen vivir paquetes como ese en un módulo real, consulta Estructura de Proyectos Go: Prácticas y Patrones.
- un evento JSON por línea
- registros escritos en stdout para su recolección
- metadatos estables del servicio adjuntos una vez
- registro consciente del contexto para IDs de solicitud y traza
- redacción centralizada para claves sensibles
package logx
import (
"log/slog"
"os"
)
var level slog.LevelVar // predeterminado a INFO
func New() *slog.Logger {
opts := &slog.HandlerOptions{
Level: &level, // puede cambiarse en tiempo de ejecución
AddSource: true, // incluir archivo y línea cuando esté disponible
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Redacción centralizada: consistente y difícil de omitir por accidente.
switch a.Key {
case "password", "token", "authorization", "api_key":
return slog.String(a.Key, "[redacted]")
}
return a
},
}
h := slog.NewJSONHandler(os.Stdout, opts)
return slog.New(h).With(
"service", os.Getenv("SERVICE_NAME"),
"env", os.Getenv("ENV"),
"version", os.Getenv("VERSION"),
)
}
func SetLevel(l slog.Level) { level.Set(l) }
Un detalle pequeño con grandes consecuencias: el manejador JSON integrado usa claves estándar (time, level, msg, source). Cuando tu backend de registros espera un esquema diferente, ReplaceAttr es la válvula de alivio que te permite normalizar claves sin reescribir los sitios de llamada.
El esquema importa más que el registrador
La mayoría de los fallos de “registro estructurado” son fallos de esquema.
Campos esenciales que siguen pagando la renta
Todo backend de registros almacenará una marca de tiempo, nivel y mensaje. En la práctica, un esquema de aplicación útil a menudo añade un pequeño conjunto de campos estables:
- service, env, version
- component (o subsistema)
- event (un nombre estable para lo que sucedió)
- request_id (cuando existe una solicitud)
- trace_id y span_id (cuando existe trazabilidad)
- error (cadena) y error_kind (categoría estable)
Observa el patrón: estos campos responden a preguntas operativas, no a la curiosidad del desarrollador.
Las convenciones semánticas son un hack de consistencia barato
Si ya usas OpenTelemetry, sus convenciones semánticas proporcionan un vocabulario estándar para atributos en todas las señales de telemetría. Aunque no exportes registros vía OpenTelemetry, prestar nombres de atributos reduce el impuesto de “¿cómo llamamos a este campo en el servicio B”.
Alta cardinalidad y por qué los registros se vuelven caros
Alta cardinalidad significa “demasiados valores únicos”. Está bien dentro de una carga JSON, pero se vuelve doloroso cuando un backend trata algunos campos como etiquetas indexadas o claves de flujo. Los IDs de usuario, direcciones IP, tokens de solicitud aleatorios y URLs completas tienden a explotar combinaciones.
El resultado práctico es simple: mantén las etiquetas y claves de índice aburridas (servicio, entorno, región), y mantén los campos de alta cardinalidad dentro de la carga estructurada para filtrar en el momento de la consulta.
Correlación con IDs de solicitud y trazas
La correlación es el punto donde los registros dejan de ser solo texto y empiezan a comportarse como telemetría.
ID de solicitud como clave de correlación con menor fricción
Un ID de solicitud es el puente más simple entre una solicitud entrante y todo lo que sucede a causa de ella. Suele funcionar incluso sin trazabilidad distribuida, y sigue siendo útil cuando las trazas se muestrean.
Un patrón común es adjuntar un registrador por solicitud al contexto:
package logx
import (
"context"
"log/slog"
)
type ctxKey struct{}
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, l)
}
func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok && l != nil {
return l
}
return slog.Default()
}
Correlación de trazas con W3C Trace Context y OpenTelemetry
W3C Trace Context define una forma estándar de propagar la identidad de la traza (para HTTP, vía traceparent y tracestate). OpenTelemetry se basa en eso para que los IDs de traza y span puedan extraerse del contexto.
Este ejemplo de middleware registra tanto request_id como identificadores de traza cuando están disponibles:
package middleware
import (
"crypto/rand"
"encoding/hex"
"net/http"
"go.opentelemetry.io/otel/trace"
"log/slog"
"example.com/project/logx"
)
func requestID() string {
var b [16]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func WithRequestLogger(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) {
rid := r.Header.Get("X-Request-Id")
if rid == "" {
rid = requestID()
}
l := base.With(
"request_id", rid,
"method", r.Method,
"path", r.URL.Path,
)
if sc := trace.SpanContextFromContext(r.Context()); sc.IsValid() {
l = l.With(
"trace_id", sc.TraceID().String(),
"span_id", sc.SpanID().String(),
)
}
ctx := logx.WithLogger(r.Context(), l)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Una vez que existen los campos de correlación, la línea de registro se convierte en un índice hacia otros datos. La diferencia en un incidente en vivo no es sutil.
Convertir registros estructurados en señales de monitorización y alertas
Los registros son excelentes para responder “qué sucedió”. Las alertas suelen tratar sobre “con qué frecuencia y qué tan grave”.
Un enfoque práctico es tratar ciertos eventos de registro como contadores:
- event=payment_failed
- event=db_timeout
- event=cache_miss
Muchas plataformas pueden derivar métricas basadas en registros contando registros coincidentes en una ventana. Los registros estructurados hacen que ese conteo sea resistente, porque se basa en un valor de campo en lugar de una coincidencia de texto frágil. Cuando estés listo para visualizar y explorar esas señales, Instalar y usar Grafana en Ubuntu: Guía completa recorre una configuración completa de Grafana que puedes apuntar a backends de registros y métricas comunes.
Aquí también es donde los niveles de registro empiezan a importar. Los registros de depuración suelen ser valiosos, pero también es donde se esconde el costo y el ruido. Usar un nivel dinámico (LevelVar) permite que el sistema permanezca silencioso por defecto, mientras sigue permitiendo detalles dirigidos cuando es necesario.
Pensamientos finales
La registro estructurada en Go ya no es un debate de librerías. La parte interesante es si tus registros son consistentes, correlacionables y rentables de almacenar.
Cuando tus registros transportan campos estables como event, request_id y trace_id, dejan de ser “cadenas que alguien escribió” y se convierten en un conjunto de datos con el que puedes operar.
Notas
El equipo de Go introdujo log/slog en Go 1.21 y enfatizó que los registros estructurados usan pares clave-valor para que puedan analizarse, filtrarse, buscarse y analizarse de forma fiable, y también señaló la motivación de proporcionar un marco común compartido en todo el ecosistema.
La documentación del paquete log/slog define el modelo de registro (hora, nivel, mensaje, pares clave-valor) y los manejadores integrados (TextHandler para clave=valor y JSONHandler para JSON delimitado por líneas), y documenta la integración SetDefault con el paquete log clásico.
Para la correlación distribuida, la especificación W3C Trace Context estandariza la propagación de traceparent y tracestate, y OpenTelemetry especifica que su SpanContext cumple con W3C Trace Context y expone TraceId y SpanId, haciendo la correlación de registro-traza sencilla cuando está presente un span.
Para el costo y rendimiento del almacenamiento de registros, la documentación de Grafana Loki recomienda encarecidamente etiquetas acotadas y estáticas y advierte sobre las etiquetas de alta cardinalidad que crean demasiados flujos e un índice enorme, lo cual es directamente relevante al decidir qué se convierte en etiqueta y qué permanece como un campo JSON no indexado.