Sistema de logs: de printf a observabilidad real
DevOps

Sistema de logs: de printf a observabilidad real

Aprende a diseñar un sistema de logs estructurado en producción: qué loggear, cómo correlacionar requests, dónde agregar y cuándo alertar con ejemplos en Go y Node.js.

Por Omar Flores
#devops #backend #go #docker #best-practices

La caja negra de un avión no graba todo lo que sucede a bordo. Graba los parámetros que los ingenieros de aeronáutica determinaron que son necesarios para reconstruir cualquier falla posible — altitud, velocidad, posición de los controles, temperatura de los motores. No graba las conversaciones de los pasajeros ni la música de la cabina. Lo que se graba fue diseñado. Lo que se omite también.

Los sistemas de logs en software rara vez son así. Lo más común es que cada servicio escriba a su propio archivo con su propio formato, que los mensajes sean strings descriptivos sin estructura, que producción tenga niveles de debug activados porque “alguna vez sirvió para algo”, y que cuando algo falla a las 2 de la madrugada nadie pueda correlacionar qué pasó en qué orden en qué servicio.

Un sistema de logs bien diseñado no es una colección de prints organizados. Es una fuente de verdad sobre el comportamiento de tu sistema que funciona en el momento que más la necesitas.

Por qué la mayoría de los sistemas de logs fallan

El problema no es técnico. Es de diseño. Los logs se agregan reactivamente — alguien necesita debuggear algo, agrega un console.log, lo sube a producción, y ahí se queda. Con el tiempo, el sistema acumula miles de líneas de log que nadie lee en condiciones normales y que no tienen suficiente contexto cuando hay un incidente.

El segundo problema es el formato. Un string como "Error procesando pago para usuario 12345" parece informativo. Pero cuando necesitas saber cuántos errores de ese tipo ocurrieron en la última hora, o qué usuarios afectó en las últimas 24 horas, o si hay correlación con una versión de deploy específica — ese string no te dice nada que una herramienta pueda procesar automáticamente.

El tercer problema es la ausencia de correlación. En un sistema con tres servicios, una petición HTTP puede pasar por el API Gateway, el servicio de autenticación y el servicio de pagos. Si algo falla en el medio, tienes logs en tres lugares distintos, sin ningún identificador que los conecte. Reconstruir la cadena de eventos es trabajo manual y propenso a error.

Estos tres problemas tienen solución. Y la solución empieza por entender qué tipo de información produce un sistema de software.

Logs estructurados: el cambio que lo hace todo posible

Un log estructurado no es un string. Es un objeto con campos definidos que cualquier herramienta puede parsear, indexar y consultar. La diferencia en la práctica:

// ANTIPATRÓN: string libre, imposible de parsear programáticamente
[2026-03-09 14:23:01] ERROR: Error procesando pago para usuario 12345, monto 500, error: timeout al conectar con banco

// CORRECTO: objeto JSON estructurado
{
  "timestamp": "2026-03-09T14:23:01.423Z",
  "level": "error",
  "service": "payments-api",
  "event": "payment.processing.failed",
  "user_id": "12345",
  "amount": 500,
  "currency": "MXN",
  "error": "upstream timeout",
  "upstream": "banamex-gateway",
  "duration_ms": 5002,
  "trace_id": "7f3a2c1e-9b4d-4e8f-a1c2-3d5e7f9b1a2c"
}

El log estructurado permite hacer queries como “todos los errores de pagos a Banamex en los últimos 30 minutos con duración mayor a 5 segundos”. El string libre no permite eso — tendrías que parsear texto manualmente.

En Go, la librería estándar log/slog (disponible desde Go 1.21) produce logs estructurados de forma nativa. Para versiones anteriores, zap de Uber es la opción más usada en producción por su bajo overhead.

package main

import (
    "log/slog"
    "os"
)

func main() {
    // Handler JSON para producción
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))

    // Reemplaza el logger global
    slog.SetDefault(logger)

    // Log estructurado con campos
    slog.Info("server started",
        slog.String("addr", ":8080"),
        slog.String("env", "production"),
        slog.String("version", "1.4.2"),
    )
}

El output de este código es JSON puro. Una herramienta de agregación como Loki, Elasticsearch o Datadog puede indexar cada campo automáticamente sin configuración adicional.

Para Node.js o TypeScript, pino es el equivalente — estructurado por defecto, con el menor overhead posible para aplicaciones de alto throughput.

import pino from "pino";

const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  // En desarrollo: pretty print. En producción: JSON puro.
  transport:
    process.env.NODE_ENV === "development"
      ? { target: "pino-pretty" }
      : undefined,
  base: {
    service: "payments-api",
    version: process.env.APP_VERSION ?? "unknown",
  },
});

export default logger;

El campo base añade campos que aparecen en todos los logs del proceso. Esto elimina la necesidad de repetir service y version en cada log individual.

Los cinco niveles y cuándo usar cada uno

Los niveles de log no son decorativos. Determinan el costo de almacenamiento, el ruido en producción y la velocidad con la que puedes detectar problemas. Usarlos incorrectamente es tan problemático como no tenerlos.

DEBUG es para el desarrollador durante desarrollo local o debugging específico de un problema. Nunca en producción de forma permanente — genera demasiado volumen y expone información interna. Si necesitas debug en producción, actívalo dinámicamente solo para el servicio y el período necesario.

INFO registra eventos normales del negocio que vale la pena tener en el historial. Una petición procesada exitosamente, un usuario que se autenticó, un job que completó. Debe ser lo suficientemente frecuente para entender qué está haciendo el sistema, pero no tan frecuente que el volumen se vuelva un problema de costo.

WARN indica una situación inesperada que el sistema manejó correctamente. Un retry que fue necesario, una configuración con valor por defecto porque la variable de entorno no estaba, una respuesta que llegó más lenta de lo esperado. El sistema siguió funcionando, pero algo merece atención.

ERROR indica que algo falló y el sistema no pudo recuperarse automáticamente. Una petición que no se procesó, una transacción que falló, una dependencia externa que no respondió. Cada error debería tener un número de ticket potencial detrás.

FATAL / CRITICAL indica que el proceso no puede continuar. Conexión a base de datos perdida al inicio, configuración inválida que hace imposible el funcionamiento correcto. Después de este log, el proceso termina.

// Ejemplo de uso correcto de niveles en un handler HTTP

func (h *PaymentHandler) Process(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    order, err := parseOrderFromRequest(r)
    if err != nil {
        // El input fue inválido — es un error del cliente, no del sistema
        slog.WarnContext(ctx, "invalid payment request",
            slog.String("error", err.Error()),
            slog.String("remote_addr", r.RemoteAddr),
        )
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    result, err := h.payments.Process(ctx, order)
    if err != nil {
        // El sistema falló al procesar — esto es ERROR
        slog.ErrorContext(ctx, "payment processing failed",
            slog.String("error", err.Error()),
            slog.String("order_id", order.ID),
            slog.Int("amount", order.Amount),
            slog.Duration("elapsed", result.Duration),
        )
        http.Error(w, "payment failed", http.StatusInternalServerError)
        return
    }

    // Evento de negocio exitoso — INFO
    slog.InfoContext(ctx, "payment processed",
        slog.String("order_id", order.ID),
        slog.String("payment_id", result.PaymentID),
        slog.Duration("elapsed", result.Duration),
    )

    w.WriteHeader(http.StatusOK)
}

Nótese el uso de Context en todos los logs. Esto no es solo una convención — es el mecanismo que permite propagar el trace_id automáticamente.

Correlación de requests con trace ID

En un sistema de un solo servicio, correlacionar logs es simple: el timestamp y el hilo de ejecución suelen ser suficientes. En un sistema distribuido, no lo son.

El patrón estándar es generar un identificador único al inicio de cada request — el trace ID — y propagarlo a través de todos los servicios que participan en ese request. Cada log que se genera durante el procesamiento incluye ese ID. Cuando hay un falla, filtrar por trace ID te muestra todos los eventos de todos los servicios en orden cronológico.

La implementación en Go usa el contexto para propagar el ID sin necesidad de pasarlo como parámetro explícito a cada función:

package middleware

import (
    "context"
    "log/slog"
    "net/http"

    "github.com/google/uuid"
)

type contextKey string

const traceIDKey contextKey = "trace_id"

// TraceID extrae el trace ID del contexto.
// Las funciones de negocio usan esto para loggear con correlación.
func TraceID(ctx context.Context) string {
    if id, ok := ctx.Value(traceIDKey).(string); ok {
        return id
    }
    return "unknown"
}

// RequestLogger es un middleware HTTP que inyecta un trace ID en el contexto
// y loggea el inicio y fin de cada request.
func RequestLogger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Respetar el trace ID del upstream si existe (para sistemas distribuidos)
        traceID := r.Header.Get("X-Trace-Id")
        if traceID == "" {
            traceID = uuid.New().String()
        }

        ctx := context.WithValue(r.Context(), traceIDKey, traceID)

        // El response writer wrapped permite capturar el status code
        rw := &responseWriter{ResponseWriter: w, status: 200}
        w.Header().Set("X-Trace-Id", traceID)

        slog.InfoContext(ctx, "request started",
            slog.String("method", r.Method),
            slog.String("path", r.URL.Path),
            slog.String("trace_id", traceID),
            slog.String("remote_addr", r.RemoteAddr),
        )

        next.ServeHTTP(rw, r.WithContext(ctx))

        slog.InfoContext(ctx, "request completed",
            slog.String("method", r.Method),
            slog.String("path", r.URL.Path),
            slog.String("trace_id", traceID),
            slog.Int("status", rw.status),
        )
    })
}

Con este middleware, cualquier función que reciba el contexto puede loggear con el trace ID sin saber de dónde vino. El log del procesamiento de pago mostrado antes usará automáticamente el mismo ID que el log del middleware, porque ambos comparten el mismo contexto.

Cuando este servicio llama a otro servicio, debe propagar el ID en el header:

func callDownstreamService(ctx context.Context, url string) (*http.Response, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }

    // Propagar el trace ID al servicio downstream
    req.Header.Set("X-Trace-Id", middleware.TraceID(ctx))

    return http.DefaultClient.Do(req)
}

Si no propagas el ID, cada servicio genera su propia cadena de logs sin conexión entre sí. Este es uno de los errores más comunes en sistemas que empiezan a distribuirse.

Qué no loggear

Tan importante como saber qué loggear es saber qué no loggear. Los errores de diseño en esta dirección tienen consecuencias de seguridad y de costo.

Datos personales o sensibles. Emails, nombres completos, números de teléfono, IPs de usuarios (en muchas jurisdicciones es dato personal), contraseñas, tokens de sesión, números de tarjeta. Los logs van a sistemas de agregación, bases de datos de análisis, y archivos que pueden estar accesibles a más personas de las necesarias. Un log que contiene un token de sesión válido es un vector de ataque.

// ANTIPATRÓN: loggear datos del usuario directamente
slog.Info("user authenticated",
    slog.String("email", user.Email),       // dato personal
    slog.String("token", session.Token),    // credencial activa
    slog.String("password_hash", user.PasswordHash), // nunca, jamás
)

// CORRECTO: solo identificadores internos
slog.Info("user authenticated",
    slog.String("user_id", user.ID),
    slog.String("session_id", session.ID),  // ID, no el token completo
    slog.String("auth_method", "password"),
)

El body de requests y responses. A menos que estés en modo debug explícito para un problema específico. Los bodies pueden contener datos sensibles, y su volumen hace que el costo de almacenamiento se dispare en producción.

Logs de alto volumen sin valor diagnóstico. Health checks, requests a assets estáticos, pings de monitoreo — estos pueden representar el 40-60% del volumen total de logs en un servicio web y no aportan información diagnóstica. En Nginx o en el middleware HTTP, filtra estos endpoints:

// Middleware que omite logs para rutas de bajo valor
func SkipHealthcheckLogs(next http.Handler) http.Handler {
    skip := map[string]bool{
        "/health":  true,
        "/ready":   true,
        "/metrics": true,
    }
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if skip[r.URL.Path] {
            next.ServeHTTP(w, r)
            return
        }
        // El siguiente middleware (RequestLogger) sí se aplica
        next.ServeHTTP(w, r)
    })
}

Logs redundantes en loops o bucles frecuentes. Si un job corre cada 5 segundos y loggea “job iniciado / job completado”, eso son 17,000 líneas de log por día que solo confirman que el sistema funciona normalmente. Loggea la excepción, no la regla.

Agregación centralizada: dónde van los logs

Los logs en archivos locales o stdout de contenedores son útiles para debugging inmediato, pero no escalan. En cuanto tienes más de un servidor o más de un servicio, necesitas un sistema que agregue todos los logs en un solo lugar donde puedas buscar, filtrar y alertar.

El stack más común hoy en día para operaciones self-hosted es Loki + Promtail + Grafana. Loki es el almacenamiento de logs, Promtail es el agente que los recolecta, y Grafana es la interfaz de consulta. A diferencia de Elasticsearch, Loki no indexa el contenido de los logs, solo sus etiquetas — esto lo hace significativamente más barato en almacenamiento y más rápido para queries por etiqueta.

# docker-compose.yml: stack básico de observabilidad
version: "3.8"

services:
  loki:
    image: grafana/loki:2.9.0
    ports:
      - "3100:3100"
    command: -config.file=/etc/loki/local-config.yaml
    volumes:
      - loki-data:/loki

  promtail:
    image: grafana/promtail:2.9.0
    volumes:
      - /var/log:/var/log:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - ./promtail-config.yaml:/etc/promtail/config.yml
    command: -config.file=/etc/promtail/config.yml

  grafana:
    image: grafana/grafana:10.0.0
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - grafana-data:/var/lib/grafana

volumes:
  loki-data:
  grafana-data:

La configuración de Promtail define qué logs recolectar y qué etiquetas asignarles. Las etiquetas son lo que permite filtrar en Loki de forma eficiente:

# promtail-config.yaml
server:
  http_listen_port: 9080

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: containers
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
    relabel_configs:
      # Etiqueta por nombre del contenedor
      - source_labels: [__meta_docker_container_name]
        target_label: container
      # Etiqueta por imagen
      - source_labels: [__meta_docker_container_image]
        target_label: image
    pipeline_stages:
      # Parsear JSON si el log es estructurado
      - json:
          expressions:
            level: level
            trace_id: trace_id
            service: service
      - labels:
          level:
          service:

Con esto, en Grafana puedes hacer queries como {service="payments-api", level="error"} y ver solo los errores del servicio de pagos, con todos sus campos estructurados accesibles para filtrado adicional.

Para cloud, las alternativas nativas eliminan la necesidad de operar la infraestructura de logs: Azure Monitor Logs, AWS CloudWatch Logs, Google Cloud Logging. Son más caras a escala pero no requieren mantenimiento. El criterio de elección es el mismo de siempre: costo versus control.

Alertas sobre logs

Ver logs en un dashboard es útil para investigación post-mortem. Pero el valor real de un sistema de logs maduro es que te notifica cuando algo va mal, antes de que un usuario te escriba.

Las alertas sobre logs funcionan sobre patrones o umbrales en los datos agregados. Ejemplos prácticos:

  • Más de 50 errores del evento payment.processing.failed en los últimos 5 minutos.
  • El nivel fatal aparece en cualquier servicio — cualquier instancia, inmediatamente.
  • El tiempo de respuesta promedio del servicio de autenticación supera 2 segundos.
  • Aparece el evento database.connection.failed — esto nunca debería ocurrir en producción normal.

En Grafana, estas alertas se configuran con LogQL sobre datos de Loki:

# Alerta: tasa de errores mayor a 5 por minuto en payments-api
sum(rate({service="payments-api", level="error"}[5m])) > 5

La alerta envía una notificación a Slack, PagerDuty, o email — y el mensaje debería incluir el trace_id de uno de los eventos que la disparó, para que la persona que recibe la alerta pueda ir directamente a investigar sin buscar manualmente.

Una alerta sin contexto es casi tan mala como no tener alerta. “Error en payments-api” no dice nada. “5 errores de payment.processing.failed con upstream=banamex-gateway en los últimos 3 minutos, trace_id: 7f3a2c1e” te dice exactamente dónde mirar.

Retención y costo de almacenamiento

Los logs son datos que crecen indefinidamente si no se gestiona su ciclo de vida. Definir una política de retención no es opcional en producción.

La regla práctica que funciona en la mayoría de los sistemas:

  • DEBUG: 1-3 días. Solo se activa temporalmente; no necesita retención larga.
  • INFO: 14-30 días. Suficiente para investigar incidentes recientes y tendencias de corto plazo.
  • WARN / ERROR: 90 días. Los problemas intermitentes pueden tomar semanas en manifestarse de forma patrón.
  • FATAL / AUDIT: 1 año o más, dependiendo del dominio y los requisitos regulatorios.

En Azure Monitor, la retención se configura por tabla de log:

# Reducir retención de logs de diagnóstico general a 30 días
az monitor log-analytics workspace table update \
  --resource-group rg-monitoring \
  --workspace-name law-production \
  --name ContainerLog \
  --retention-time 30

# Los logs de auditoría se guardan 365 días
az monitor log-analytics workspace table update \
  --resource-group rg-monitoring \
  --workspace-name law-production \
  --name AuditLogs \
  --retention-time 365

El impacto en costo es significativo. Log Analytics cobra aproximadamente $2.76 por GB ingerido. Un servicio que genera 10 GB diarios con retención de 90 días cuesta $828 solo en almacenamiento. Reducir a 30 días lo lleva a $276. Eliminar logs de health checks y assets estáticos puede reducir el volumen en un 40% antes de tocar la retención.

Errores comunes en sistemas de logs

Loggear en el lugar equivocado. Si un error ocurre en tres capas (handler → servicio → repositorio), no lo loggees en las tres. Loggéalo una vez, en la capa que tiene más contexto. Las capas intermedias deben propagar el error con contexto adicional, no crear logs duplicados que generan ruido.

// ANTIPATRÓN: log en cada capa, tres entradas por el mismo error
func (r *PaymentRepo) Save(p Payment) error {
    err := r.db.Insert(p)
    if err != nil {
        slog.Error("db insert failed", slog.String("error", err.Error())) // log aquí
        return err
    }
    return nil
}

func (s *PaymentService) Process(p Payment) error {
    err := s.repo.Save(p)
    if err != nil {
        slog.Error("save payment failed", slog.String("error", err.Error())) // log aquí también
        return err
    }
    return nil
}

func (h *PaymentHandler) Create(w http.ResponseWriter, r *http.Request) {
    err := h.service.Process(payment)
    if err != nil {
        slog.Error("payment handler error", slog.String("error", err.Error())) // y aquí
        http.Error(w, "error", 500)
    }
}

// CORRECTO: propagar error con contexto, loggear solo en el handler
func (r *PaymentRepo) Save(p Payment) error {
    if err := r.db.Insert(p); err != nil {
        return fmt.Errorf("inserting payment %s: %w", p.ID, err) // wrappear con contexto
    }
    return nil
}

func (s *PaymentService) Process(p Payment) error {
    if err := s.repo.Save(p); err != nil {
        return fmt.Errorf("processing payment: %w", err) // propagar
    }
    return nil
}

func (h *PaymentHandler) Create(w http.ResponseWriter, r *http.Request) {
    if err := h.service.Process(payment); err != nil {
        // Un solo log, con el stack completo de contexto
        slog.ErrorContext(r.Context(), "payment creation failed",
            slog.String("error", err.Error()),
            slog.String("order_id", payment.OrderID),
        )
        http.Error(w, "payment failed", 500)
    }
}

Usar fmt.Println o log.Printf sin estructura. Es el equivalente de no tener sistema de logs. Funciona para desarrollo, no para producción.

No revisar los logs hasta que algo falla. Los logs bien diseñados revelan problemas latentes antes de que se conviertan en incidentes. Una revisión semanal de los patrones de error y warn puede identificar degradaciones que aún no impactan al usuario.

Tratar todos los ambientes igual. En desarrollo, los logs pueden ser verbosos y con formato legible. En producción, deben ser JSON estructurado, con nivel INFO o superior, y sin información que no sea necesaria para el diagnóstico.

Lo que esto significa para el equipo

Un sistema de logs bien diseñado cambia la dinámica de un equipo de ingeniería de formas que no son inmediatamente obvias.

El tiempo medio de resolución de incidentes — MTTR — cae dramáticamente cuando el equipo puede ir de “recibimos una alerta” a “identificamos el problema” en minutos en lugar de horas. No porque los ingenieros sean más inteligentes, sino porque la información correcta está disponible en el momento correcto.

Los deploys se vuelven menos estresantes. Cuando puedes ver en tiempo real si el número de errores aumentó después de un deploy, puedes tomar la decisión de hacer rollback con datos, no con intuición.

La confianza del equipo en el sistema aumenta. Hay algo cualitativamente diferente en operar un sistema que entiendes porque puedes ver lo que está haciendo, versus operar un sistema que es una caja negra a la que solo accedes cuando explota.

La caja negra del avión existe para exactamente este propósito. No la construyeron para usarla en vuelos normales. La construyeron para que, cuando algo saliera mal, hubiera una fuente de verdad que no dependiera de la memoria de nadie.

Los logs no son para el desarrollador que los escribió. Son para la persona que, a las 3 de la madrugada, intenta entender qué pasó en un sistema que nunca vio antes. Escríbelos para esa persona.