La lección anterior dejó a CicloUrbana vigilada: Prometheus recoge, Grafana dibuja y Alertmanager avisa. Pero las alertas terminan siempre en la misma frase. A las cuatro de la mañana llega un mensaje que dice «tasa de error del 3 % en el inicio de alquiler», y la pregunta inmediata —qué ha fallado exactamente— no la responde ninguna gráfica. Una métrica sabe contar; no sabe contar qué pasó.

Eso lo responde el segundo pilar de la observabilidad, y es el que llevamos usando sin tratarlo en serio desde la primera lección: el registro de eventos. Hasta ahora el log de CicloUrbana es lo que Spring Boot trae de fábrica más un patrón con el trazaId que añadimos en 03-06. Sirve en un portátil. En producción, con tres instancias en Kubernetes escribiendo a la vez, es una cinta de texto que nadie puede consultar.

Esta lección lo convierte en una herramienta. Veremos la pila de logging de Spring Boot y por qué nunca se programa contra la implementación, los niveles con una política concreta para cada capa de CicloUrbana, la configuración por propiedades y el logback-spring.xml completo con consola legible en dev y JSON en prod, los logs estructurados que convierten un texto en un dato consultable, el MDC que hace que todas las líneas de una petición compartan identificador, lo que jamás debe escribirse sobre los ciudadanos de Ribalta, el coste real del logging y la agregación centralizada que permite buscar en las tres instancias como si fueran una.

Contenido

  1. Qué es un log y en qué se diferencia de una métrica
  2. La pila de logging de Spring Boot
  3. Obtener un logger
  4. Los niveles y la política de CicloUrbana
  5. Mensajes parametrizados
  6. Configuración por propiedades
  7. logback-spring.xml y los perfiles
  8. Logs estructurados en JSON
  9. Contexto: el MDC y el trazaId
  10. Qué no registrar nunca
  11. El coste del logging
  12. Agregación centralizada
  13. Loki y LogQL
  14. Correlacionar logs de varias instancias
  15. Auditoría de seguridad frente a logging de aplicación
  16. Probar los logs
  17. Errores Comunes y Consejos
  18. Ejercicios

  1. Qué es un log y en qué se diferencia de una métrica

Un log es un evento discreto con contexto: una cosa que ocurrió, cuándo, dónde y con qué datos. Una métrica es una agregación numérica: cuántas veces, cuánto tardó. La diferencia no es de formato, es de pregunta.

Métrica Log
Unidad Un número por intervalo Un evento
Responde a ¿Cuántos? ¿Cuánto? ¿Desde cuándo? ¿Qué pasó exactamente?
Cardinalidad Debe ser baja (09-03) Alta: cada línea es única
Identificadores concretos Prohibidos como etiqueta Su razón de ser
Coste Constante con el tráfico Proporcional al tráfico
Retención Meses o años Días o semanas
Para alertar Sí Solo por tasas

La relación entre ambos es de complemento exacto: lo que en 09-03 estaba prohibido meter en una etiqueta —el identificador del usuario, el del alquiler, la URI concreta— es precisamente lo que debe ir en el log. La métrica dice «el 3 % de los inicios de alquiler falla»; el log dice «el alquiler del usuario 4711 en la estación 2 falló porque la pasarela devolvió TARJETA_CADUCADA».

Un buen log de producción cumple tres condiciones que el log por defecto no cumple: es consultable (se puede filtrar por campos, no solo buscar texto), es correlacionable (todas las líneas de una petición comparten identificador) y es seguro (no contiene nada que no debería salir del sistema). Los tres apartados centrales de esta lección son esos tres requisitos.

  1. La pila de logging de Spring Boot

flowchart LR
    A[Tu codigo<br/>log.info] --> B[SLF4J<br/>fachada]
    C[Spring, Hibernate,<br/>HikariCP] --> B
    D[Bibliotecas con JCL,<br/>JUL o Log4j] --> E[Puentes<br/>jcl-over-slf4j, jul-to-slf4j]
    E --> B
    B --> F[Logback<br/>por defecto]
    B -.alternativa.-> G[Log4j2]
    F --> H1[Consola<br/>stdout]
    F --> H2[Fichero<br/>rotado]
    F --> H3[JSON<br/>agregador]

SLF4J es la fachada: define Logger, LoggerFactory y los métodos trace/debug/info/warn/error. Logback es la implementación por defecto de spring-boot-starter-logging, incluido en todos los starters. Los puentes redirigen a SLF4J lo que las bibliotecas antiguas escriben con otras APIs, que es la razón por la que el log de CicloUrbana es homogéneo aunque Hibernate, HikariCP y Spring usen mecanismos distintos.

Por qué se programa contra SLF4J Consecuencia
El código no depende de la implementación Cambiar a Log4j2 no toca una línea de negocio
Las bibliotecas también la usan Un único fichero de configuración gobierna todo
Los mensajes parametrizados son de SLF4J El apartado 5 no existe fuera de la fachada
Es lo que Spring Boot configura Todo funciona sin escribir nada

Cambiar a Log4j2 —que aporta appenders asíncronos muy eficientes y una configuración algo más potente— es sustituir una dependencia: se excluye spring-boot-starter-logging del starter web y se añade spring-boot-starter-log4j2.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>

La decisión de CicloUrbana es Logback, el predeterminado: es suficiente, está mejor documentado en el ecosistema Spring y sus <springProfile> (apartado 7) son una comodidad real. Log4j2 se justifica con volúmenes de log muy altos.

  1. Obtener un logger

package com.ciclourbana.alquileres;

public class AlquilerService {
    private static final Logger log = LoggerFactory.getLogger(AlquilerService.class);
    // ...
}

Tres detalles que no son estéticos. static final: un logger por clase, no por instancia. Se pasa la clase, no una cadena: así el nombre del logger coincide con el nombre completo de la clase, que es lo que permite configurar niveles por paquete (logging.level.com.ciclourbana.alquileres: DEBUG). Y se importan org.slf4j.Logger y org.slf4j.LoggerFactory, no las de Logback: importar ch.qos.logback.classic.Logger rompe la abstracción entera.

Lombok evita la línea repetida con @Slf4j, que genera exactamente ese campo. Es cómodo y muy extendido; CicloUrbana lo declara explícito para que se vea de dónde sale log, pero ambas opciones son correctas.

  1. Los niveles y la política de CicloUrbana

Nivel Significado ¿Alguien tiene que actuar? ¿En producción?
ERROR Algo falló y no se pudo cumplir la petición Sí, ahora o mañana Sí
WARN Algo anómalo que se ha podido manejar Vigilar; si se repite, sí Sí
INFO Hito relevante del negocio o del ciclo de vida No Sí
DEBUG Detalle para diagnosticar No No (temporalmente sí)
TRACE Detalle exhaustivo No Nunca

La política de niveles de CicloUrbana, por capa:

Controladores. Nada en el camino feliz: http.server.requests de 09-03 ya cuenta y cronometra cada petición mucho mejor que una línea de log. Registrar «entrando en el método X» en cada petición es duplicar una métrica al triple de coste.

Servicios de dominio. INFO en los hitos de negocio: alquiler iniciado, alquiler finalizado con importe, bicicleta puesta en mantenimiento. Son los eventos que un operario del ayuntamiento entendería, y los que se consultan al investigar. DEBUG para el detalle del cálculo.

Errores de negocio esperados. Un usuario que ya tiene un alquiler en curso, un saldo insuficiente: WARN o incluso INFO, nunca ERROR. Son el sistema funcionando, y el ManejadorGlobalExcepciones de 03-06 ya devuelve el ProblemDetail correcto. Si se registran como ERROR, la alerta de «pico de errores» se dispara todos los días y deja de creerse.

Errores inesperados. ERROR con la excepción completa, en el manejador global y en un solo sitio. Ese es el nivel que alimenta la alerta sobre logback_events_total{level="error"}.

Integraciones (07-06). WARN en cada reintento, ERROR al agotarlos, INFO al abrirse y cerrarse el cortacircuitos.

Y la regla más importante de todas, que merece ser explícita: un catch que solo hace e.printStackTrace() es un error grave. Escribe en System.err, sin marca de tiempo, sin nivel, sin logger, sin trazaId y sin pasar por ninguna configuración: no se puede filtrar, no se puede desactivar, no llega al agregador y no se puede correlacionar. Peor todavía es el catch vacío, que hace desaparecer el problema. Un catch legítimo hace una de tres cosas: relanza una excepción de dominio, la registra con log.error("...", e) pasando la excepción como último argumento —nunca e.getMessage(), que pierde la traza de pila— o la ignora con un comentario que explique por qué.

try {
    pasarela.cobrar(idAlquiler, importe);
} catch (PasarelaNoDisponibleException e) {          // la excepcion, como ultimo argumento
    log.warn("Cobro diferido del alquiler {}: la pasarela no responde", idAlquiler, e);
    cobrosPendientes.encolar(idAlquiler, importe);
}

  1. Mensajes parametrizados

log.debug("Alquiler {} iniciado por el usuario {} en la estacion {}", id, idUsuario, idEstacion);

Nunca así:

log.debug("Alquiler " + id + " iniciado por el usuario " + idUsuario);   // MAL

La razón es de rendimiento y es concreta. En la versión con +, la concatenación ocurre siempre, antes de llamar al método, aunque el nivel DEBUG esté desactivado: se crean objetos String, se invocan toString() y se genera basura que el GC tendrá que recoger, todo para descartarlo. En la versión parametrizada, SLF4J recibe la plantilla y el array de argumentos, comprueba el nivel y solo si está activo construye el mensaje. En un método que se ejecuta cien veces por segundo con DEBUG apagado, la diferencia es real y aparece en los perfilados como una torre inesperada bajo StringBuilder.append.

Tres detalles útiles: el marcador es {} y no admite índices, así que el orden importa; la excepción va como último argumento sin {} (log.error("Fallo al cobrar {}", id, e)), y SLF4J la detecta y escribe la traza de pila; y si construir un argumento es caro de verdad —serializar un objeto grande—, se protege con if (log.isDebugEnabled()), que en cualquier otro caso es ruido innecesario.

  1. Configuración por propiedades

Para la mayoría de los casos no hace falta un fichero XML: basta con el YAML de 02-04.

logging:
  level:
    root: INFO
    com.ciclourbana.alquileres: DEBUG          # solo el paquete que se investiga
    org.hibernate.SQL: WARN
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level [%X{trazaId:-sin-traza}] %logger{36} - %msg%n"
    file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [%X{trazaId:-}] %logger - %msg%n"
  file.name: /var/log/ciclourbana/aplicacion.log
  logback.rollingpolicy:
    max-file-size: 100MB
    max-history: 7            # dias de historico
    total-size-cap: 2GB       # tope duro del directorio
    file-name-pattern: ${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz    # comprimido

Los elementos del patrón: %d fecha, %-5level nivel alineado a cinco caracteres, %thread el hilo, %X{trazaId:-sin-traza} lee el MDC con valor por defecto tras :-, %logger{36} el nombre abreviado a 36 caracteres, %msg el mensaje y %n el salto de línea.

La política de rotación merece atención porque es la que evita el incidente clásico. Sin total-size-cap, un DEBUG olvidado llena el disco, y un disco lleno detiene la aplicación: escribir un log deja de ser una operación inocua. Las tres protecciones se combinan —tamaño por fichero, días de historia y tope total— y el .gz reduce el texto en torno al 90 %.

Y una limitación de la que nace el apartado siguiente: las propiedades no permiten condicionar por perfil ni definir varios destinos con formatos distintos. Para tener consola legible en dev y JSON en prod hace falta el XML.

  1. logback-spring.xml y los perfiles

El nombre importa: logback-spring.xml (y no logback.xml) es el que carga Spring Boot, y solo ese permite <springProfile> y <springProperty>, porque el otro lo lee Logback antes de que exista el contexto de Spring.

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="false">

    <!-- Trae los valores por defecto de Spring Boot: colores, conversores, CONSOLE_LOG_PATTERN -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <springProperty scope="context" name="APP" source="spring.application.name"/>
    <springProperty scope="context" name="ENTORNO" source="spring.profiles.active"/>

    <!-- ============ dev y test: consola legible por humanos ============ -->
    <springProfile name="dev,test,local">
        <appender name="CONSOLA" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>%clr(%d{HH:mm:ss.SSS}){faint} %clr(%-5level) %clr([%X{trazaId:-sin-traza}]){magenta} %clr(%logger{36}){cyan} - %msg%n</pattern>
            </encoder>
        </appender>
        <root level="INFO"><appender-ref ref="CONSOLA"/></root>
        <logger name="com.ciclourbana" level="DEBUG"/>
    </springProfile>

    <!-- ============ pre y prod: JSON a stdout ============ -->
    <springProfile name="pre,prod">
        <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <includeMdcKeyName>trazaId</includeMdcKeyName>
                <includeMdcKeyName>usuarioId</includeMdcKeyName>
                <fieldNames>
                    <timestamp>@timestamp</timestamp>
                    <message>mensaje</message>
                </fieldNames>
                <customFields>{"aplicacion":"${APP}","entorno":"${ENTORNO}"}</customFields>
                <throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
                    <maxDepthPerThrowable>30</maxDepthPerThrowable>
                    <exclude>^sun\.reflect\..*</exclude>
                </throwableConverter>
            </encoder>
        </appender>

        <!-- Amortigua los picos de E/S sin bloquear el hilo de la peticion -->
        <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
            <queueSize>2048</queueSize>
            <discardingThreshold>0</discardingThreshold>   <!-- no descartar WARN ni ERROR -->
            <neverBlock>true</neverBlock>                  <!-- ante cola llena, descartar -->
            <appender-ref ref="JSON"/>
        </appender>

        <root level="INFO"><appender-ref ref="ASYNC"/></root>
        <logger name="org.hibernate.SQL" level="WARN"/>
    </springProfile>
</configuration>

Las decisiones, una a una. <include> de los valores por defecto evita reescribir los conversores y patrones de Spring Boot. <springProperty> trae valores del application.yml al XML, lo que permite que el nombre de la aplicación y el entorno viajen en cada línea de JSON. %clr(...) colorea solo en dev, donde hay una persona mirando. En prod el destino es la consola, no un fichero: es la decisión del apartado 12 y del 12-Factor de 08-01. Y AsyncAppender con neverBlock: true decide algo importante de antemano: ante una avalancha, se prefiere perder líneas de log antes que frenar las peticiones de los ciudadanos; discardingThreshold: 0 garantiza que lo que se descarte no sean los WARN ni los ERROR.

  1. Logs estructurados en JSON

En producción, un log no es un texto: es un dato. La diferencia se ve comparando el mismo evento.

Texto plano:

2026-08-31 08:14:22.481 INFO  [http-nio-8080-exec-7] [a3f19c2e] c.c.a.AlquilerService - Alquiler 84213 iniciado por el usuario 4711 en la estacion 2 con tarifa ESTUDIANTE

El mismo evento en JSON:

{
  "@timestamp": "2026-08-31T08:14:22.481+02:00", "level": "INFO",
  "thread_name": "http-nio-8080-exec-7",
  "logger_name": "com.ciclourbana.alquileres.AlquilerService",
  "mensaje": "Alquiler 84213 iniciado por el usuario 4711 en la estacion 2 con tarifa ESTUDIANTE",
  "trazaId": "a3f19c2e", "aplicacion": "ciclourbana", "entorno": "prod",
  "idAlquiler": 84213, "idEstacion": 2, "tarifa": "ESTUDIANTE"
}

Lo que cambia: sobre el texto plano solo se pueden hacer búsquedas de subcadena y expresiones regulares frágiles; sobre el JSON se puede consultar tarifa = "ESTUDIANTE" AND level = "ERROR", agrupar por idEstacion, contar por entorno y construir un panel. La estructura convierte un archivo en una base de datos consultable, y ese es todo el argumento.

Spring Boot 3.4 trae soporte nativo sin dependencias:

logging.structured:
  format.console: ecs        # ecs (Elastic Common Schema), gelf o logstash
  ecs.service:
    name: ciclourbana
    version: ${APP_VERSION:desconocida}
    environment: ${SPRING_PROFILES_ACTIVE:local}

La alternativa, disponible desde antes y todavía más flexible, es logstash-logback-encoder (la del apartado 7):

<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>
Nativo de Spring Boot 3.4 logstash-logback-encoder
Dependencias Ninguna Una
Configuración Propiedades YAML XML de Logback
Formatos ECS, GELF, Logstash Logstash y personalizado
Campos propios logging.structured.json.add customFields, StructuredArguments
Control fino Limitado Total

Los campos idAlquiler, idEstacion y tarifa del ejemplo salen de argumentos estructurados, que añaden campos al JSON sin ensuciar el mensaje:

import static net.logstash.logback.argument.StructuredArguments.kv;

log.info("Alquiler {} iniciado por el usuario {} en la estacion {} con tarifa {}",
         kv("idAlquiler", id), kv("idUsuario", idUsuario),
         kv("idEstacion", idEstacion), kv("tarifa", tarifa));

kv escribe el valor en el mensaje y lo añade como campo indexable. Es lo que permite después contar alquileres por estación desde el propio log, cruzando con las métricas de 09-04.

  1. Contexto: el MDC y el trazaId

El MDC (Mapped Diagnostic Context) es un mapa asociado al hilo actual cuyo contenido se añade a todas las líneas que ese hilo escriba. Es lo que convierte líneas sueltas en la historia de una petición.

CicloUrbana ya lo usa desde 03-06: el FiltroTraza genera un identificador, lo pone en el MDC bajo la clave trazaId, lo devuelve en una cabecera y lo retira en un finally —ese MDC.remove() no es opcional, porque el hilo se reutiliza y el identificador se pegaría a la petición siguiente—. Con eso, el patrón %X{trazaId:-sin-traza} y el <includeMdcKeyName>trazaId</includeMdcKeyName> del JSON hacen el resto.

Merece la pena añadir algo más de contexto en el mismo filtro, con la misma disciplina de limpieza:

MDC.put("trazaId", traza);
MDC.put("metodo", request.getMethod());
MDC.put("ruta", request.getRequestURI());
Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
        .map(Authentication::getName)
        .ifPresent(nombre -> MDC.put("usuarioId", nombre));   // identificador, NO el correo
try {
    cadena.doFilter(request, response);
} finally {
    MDC.clear();                        // imprescindible: el hilo vuelve al pool
}

Propagarlo a los hilos asíncronos. El MDC vive en un ThreadLocal, así que no viaja solo al ejecutor de @Async. Ya resolvimos eso en 07-03 con el DecoradorMdc, un TaskDecorator que copia el mapa en el hilo llamante y lo restaura en el finally del hilo trabajador. La consecuencia práctica es exactamente la de esta lección: la línea de log del envío del correo de confirmación lleva el mismo trazaId que la petición POST /api/v1/alquileres que lo originó, aunque se escriba diez segundos después desde otro hilo.

Por qué esto lo cambia todo: con el trazaId en cada línea y en formato JSON, una incidencia se investiga con una sola consulta —trazaId: "a3f19c2e"— que devuelve la historia completa de esa petición, en orden, incluidas las líneas del trabajo asíncrono. Sin él, hay que reconstruirla a partir de marcas de tiempo entre miles de líneas de peticiones concurrentes entremezcladas. La diferencia entre cinco segundos y una hora.

  1. Qué no registrar nunca

Advertencia. Los logs de CicloUrbana contienen datos de ciudadanos de Ribalta y están sujetos al RGPD. Un log no es un espacio privado: se copia a un agregador, se replica, se guarda semanas, lo consultan personas que no son del equipo y a menudo acaba en un servicio de terceros. Todo lo que se escribe ahí sale del sistema.

Nunca Por qué Qué hacer en su lugar
Contraseñas, en claro o cifradas Compromiso directo de cuentas Nada; ni siquiera su longitud
Tokens JWT (05-04) Quien lea el log puede suplantar al usuario El sub o el identificador interno
Números de tarjeta, CVV Prohibido por PCI-DSS Los cuatro últimos dígitos, si hace falta
Correo, teléfono, DNI, dirección Datos personales del RGPD El identificador interno (usuarioId: 4711)
Coordenadas GPS del ciudadano Permiten seguir a una persona La estación, que ya es pública
Cuerpos completos de peticiones Suelen contener todo lo anterior Campos concretos elegidos
Cabeceras Authorization, Cookie Credenciales El nombre de la cabecera, sin valor
e.getMessage() de un error de base de datos Puede filtrar SQL y datos de otras filas Un mensaje propio (03-06)

Enmascaramiento. Cuando un dato debe aparecer, se enmascara antes de registrarlo:

public final class Enmascarador {

    public static String correo(String c) {           // [email protected] -> a***@ribalta.es
        if (c == null || !c.contains("@")) return "***";
        return c.charAt(0) + "***" + c.substring(c.indexOf('@'));
    }

    public static String tarjeta(String numero) {     // -> **** **** **** 4242
        return numero == null || numero.length() < 4 ? "****"
                : "**** **** **** " + numero.substring(numero.length() - 4);
    }
}

Para el caso de que algo se escape, Logback permite un RegexReplaceRule o un MessageConverter propio que sustituya patrones —números largos de 16 dígitos, cadenas que empiezan por eyJ, típicas de un JWT— antes de escribir. Es una red de seguridad, no una excusa para relajar la disciplina en el código.

Retención y derechos. Dos consecuencias del RGPD que sorprenden y que conviene decidir pronto: los logs deben tener un plazo de conservación definido y aplicado —30 días para el log de aplicación es un valor habitual y defendible—, y si contienen datos personales, el derecho de supresión de un ciudadano alcanza también a los logs. La forma más barata de cumplir ambas cosas es la de la tabla: registrar identificadores internos y no datos personales, con lo que el log deja de ser un archivo de datos personales y el problema desaparece en origen.

  1. El coste del logging

Escribir un log no es gratis y su coste tiene tres componentes: formatear el mensaje, serializar a texto o JSON y escribir en el destino. El tercero domina, porque una escritura a disco o a un socket es E/S bloqueante en el hilo de la petición.

Los números orientativos, por línea: un INFO a consola cuesta del orden de decenas de microsegundos; con AsyncAppender la entrega baja a unidades de microsegundos, porque el hilo solo encola. Parece poco hasta que se multiplica: dejar DEBUG activado en el paquete de Hibernate en producción produce fácilmente veinte líneas por petición, y con 100 peticiones por segundo son 2.000 líneas por segundo, decenas de megabytes por minuto, un disco lleno en horas y una latencia notablemente peor. Es una de las causas más frecuentes de degradación tras un despliegue, y es enteramente autoinfligida.

Tres medidas concretas. AsyncAppender (apartado 7), que desacopla el hilo de la petición de la escritura, con la decisión explícita de descartar antes que bloquear. Mensajes parametrizados (apartado 5), que evitan construir lo que no se va a escribir. Y /actuator/loggers de 07-01, que resuelve el dilema entero:

# Subir el detalle de un paquete concreto, en caliente, sin reiniciar
curl -X POST -u admin:*** -H 'Content-Type: application/json' \
     -d '{"configuredLevel":"DEBUG"}' \
     http://localhost:8081/actuator/loggers/com.ciclourbana.alquileres

# Y devolverlo a su sitio al terminar: {"configuredLevel":null}

Esa es la forma correcta de investigar en producción: INFO como base permanente, DEBUG durante quince minutos en un paquete concreto y en una sola instancia, y vuelta atrás. Nunca un DEBUG global «para ver qué pasa», y nunca dejarlo puesto.

Conviene además vigilar el propio log como una métrica: logback_events_total{level="error"} de 09-03 permite alertar de un pico de errores en 09-04 sin leer una sola línea, que es la forma barata de usar logs para alertar.

  1. Agregación centralizada

Con tres réplicas en Kubernetes, cada una escribe sus líneas y ninguna tiene la historia completa. Peor: los contenedores son efímeros, y cuando el pod que falló desaparece, su log desaparece con él. La agregación centralizada resuelve las dos cosas.

El primer paso es una consecuencia del principio XI de los 12-Factor de 08-01: los logs son un flujo de eventos y la aplicación escribe a stdout. No gestiona ficheros, ni rotación, ni destinos. Las razones:

  • El contenedor no tiene un disco duradero: un fichero dentro del contenedor se pierde en cada reinicio y no lo ve nadie.
  • La rotación ya la hace la plataforma; hacerla también en la aplicación duplica trabajo y llena el disco del nodo.
  • Escribir a stdout hace que docker logs y kubectl logs funcionen, que es lo primero que alguien va a intentar.
  • El recolector —Promtail, Fluent Bit, el agente de la nube— lee esa salida y añade metadatos del pod que la aplicación no conoce.

De ahí que en el logback-spring.xml el perfil prod use un ConsoleAppender, aunque parezca contradictorio con la política de rotación del apartado 6: esa política es para ejecuciones fuera de contenedor.

Pila Componentes Fuerte en Coste Cuándo
ELK / Elastic Elasticsearch + Logstash/Beats + Kibana Búsqueda de texto completa y muy potente Alto: memoria y operación Volumen grande y necesidad de análisis
Grafana Loki Loki + Promtail + Grafana Indexa etiquetas, no contenido: barato Bajo CicloUrbana: ya hay Grafana
CloudWatch Logs (08-03) Agente incluido en ECS Cero operación en AWS Medio, por GB ingerido Todo en AWS
Datadog Logs Agente Integrado con métricas y APM Alto Ya se paga Datadog

La elección de CicloUrbana es Loki, por tres razones: su modelo de indexación lo hace muy barato de operar, se consulta desde el mismo Grafana de 09-04 —así que un panel puede mostrar métricas y logs juntos— y usa el mismo vocabulario de etiquetas que Prometheus, lo que reduce a la mitad lo que hay que aprender.

  1. Loki y LogQL

# se añade a docker-compose.observabilidad.yml de 09-04
  loki:
    image: grafana/loki:3.0.0
    command: ["-config.file=/etc/loki/local-config.yaml"]
    ports: ["3100:3100"]
    volumes: ["loki-datos:/loki"]
    networks: [red-ciclourbana]

  promtail:
    image: grafana/promtail:3.0.0
    command: ["-config.file=/etc/promtail/config.yml"]
    volumes:
      - ./observabilidad/promtail.yml:/etc/promtail/config.yml:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
    depends_on: [loki]
    networks: [red-ciclourbana]

Y el origen de datos aprovisionado en Grafana, junto al de Prometheus:

  - name: Loki
    type: loki
    access: proxy
    url: http://loki:3100

Cómo funciona Loki. No indexa el contenido de las líneas, solo un pequeño conjunto de etiquetas (app, entorno, pod, level). Primero selecciona por etiquetas —lo que es instantáneo— y después filtra el texto de ese subconjunto por fuerza bruta. De ahí que sea barato y de ahí también su regla de oro, idéntica a la de 09-03: las etiquetas deben ser de baja cardinalidad; etiquetar por trazaId en Loki es el mismo desastre que etiquetar por él en Prometheus.

LogQL empieza como PromQL y añade filtros y análisis:

# 1. Todas las lineas de una peticion concreta: la consulta que resuelve incidencias
{app="ciclourbana", entorno="prod"} | json | trazaId = "a3f19c2e"

# 2. Errores de una estacion concreta, usando los campos de StructuredArguments
{app="ciclourbana"} | json | level = "ERROR" | idEstacion = 2

# 3. De log a metrica: errores por segundo y por pod
sum by (pod) (rate({app="ciclourbana"} | json | level = "ERROR" [5m]))

# 4. Los alquileres finalizados por minuto, contados desde el log
sum(count_over_time({app="ciclourbana"} |= "Alquiler finalizado" [1m]))

# 5. Latencia extraida del propio mensaje
{app="ciclourbana"} | json | unwrap duracionMs | quantile_over_time(0.95, [5m])

Los operadores clave: |= y != filtran por subcadena, |~ por expresión regular, | json interpreta la línea JSON y expone sus campos —lo que hace que el apartado 8 rinda—, y rate, count_over_time y quantile_over_time convierten logs en series temporales que se dibujan en el mismo panel que las métricas de Prometheus.

Esa última capacidad es la que justifica la elección: en un solo cuadro de mando de Grafana se puede tener la gráfica de latencia p95 de 09-04 y, justo debajo, las líneas de log de ERROR del mismo intervalo, sincronizadas en el tiempo. Detectar y entender en la misma pantalla.

  1. Correlacionar logs de varias instancias

Con Loki y el trazaId en el MDC, investigar una incidencia se convierte en un procedimiento de tres pasos:

  1. El ciudadano o la alerta aporta el identificador. El ManejadorGlobalExcepciones de 03-06 ya devuelve el trazaId en el ProblemDetail y en la cabecera de respuesta, así que quien llama al ayuntamiento puede leerlo en su pantalla.
  2. Una consulta LogQL con ese identificador devuelve todas las líneas de esa petición, de la instancia que fuera y del hilo que fuera, incluidas las del trabajo asíncrono gracias al TaskDecorator.
  3. Se lee la historia completa en orden: entrada de la petición, decisiones del dominio, la excepción con su traza de pila, el cobro diferido.

Sin correlación, ese mismo trabajo consiste en buscar por marca de tiempo aproximada entre las líneas de tres pods entremezcladas, con decenas de peticiones concurrentes. El trazaId es, con diferencia, la mejor inversión por línea de código de todo este módulo.

Queda una limitación honesta: el trazaId de CicloUrbana solo existe dentro de la aplicación. Cuando la petición sale hacia la pasarela de pagos (07-06), el proveedor no lo conoce y su log no lo contiene. Y si mañana el monolito se divide (07-05), cada servicio generará el suyo y la correlación se romperá en la frontera. La solución a eso es un estándar de propagación entre procesos, y es exactamente el tema de la lección siguiente.

  1. Auditoría de seguridad frente a logging de aplicación

Los eventos de seguridad de 05-05 —inicios de sesión, fallos de autenticación, denegaciones de @PreAuthorize, cambios de rol— parecen logs y no lo son del todo:

Log de aplicación Registro de auditoría
Propósito Diagnosticar Rendir cuentas: quién hizo qué y cuándo
Público El equipo técnico Seguridad, cumplimiento, un juez
Retención Días o semanas Meses o años, por normativa
Puede perderse Sí (descarte del AsyncAppender) No: debe ser fiable
Modificable Irrelevante Debe ser inalterable
Volumen Alto Bajo
Dónde vive Loki, 30 días Tabla en PostgreSQL o almacén WORM

Por qué conviene separarlos. Un registro de auditoría que se descarta cuando la cola se llena no sirve como prueba; uno que caduca a los 30 días no cumple la normativa; y uno mezclado entre millones de líneas de INFO no se puede entregar a nadie. En CicloUrbana, los AuthenticationSuccessEvent, AuthenticationFailureBadCredentialsEvent y AuthorizationDeniedEvent de 05-05 se escuchan con un @EventListener y se guardan en una tabla auditoria_seguridad —con su migración de Flyway— y además se registran como WARN en el log de aplicación para poder verlos en contexto. Lo primero es la prueba; lo segundo, la comodidad.

Una advertencia final que enlaza con el apartado 10: el registro de auditoría sí contiene identificadores de personas, por definición. Precisamente por eso debe estar controlado —acceso restringido, retención definida— y no mezclado con el log general al que tiene acceso todo el equipo.

  1. Probar los logs

Cuando una línea de log es parte del comportamiento esperado —un aviso de seguridad, un error que alimenta una alerta— conviene probarla. JUnit 5 y Spring Boot ofrecen dos formas.

OutputCaptureExtension, que captura la salida estándar:

@ExtendWith(OutputCaptureExtension.class)
class AlquilerServiceLogTest {
    @Test
    void avisaCuandoDifiereElCobro(CapturedOutput salida) {
        alquilerService.finalizar(84213L, new FinalizarAlquilerRequest(2L));
        assertThat(salida).contains("Cobro diferido del alquiler 84213")
                          .doesNotContain("4111111111111111");   // la tarjeta, jamas
    }
}

ListAppender de Logback, más preciso porque inspecciona los eventos en lugar del texto:

class ManejadorGlobalExcepcionesTest {

    private final ListAppender<ILoggingEvent> appender = new ListAppender<>();
    private final Logger logger = (Logger) LoggerFactory.getLogger(ManejadorGlobalExcepciones.class);

    @BeforeEach
    void engancharAppender() { appender.start(); logger.addAppender(appender); }

    @AfterEach
    void soltarAppender() { logger.detachAppender(appender); }

    @Test
    void unErrorDeNegocioNoSeRegistraComoError() {
        manejador.manejar(new AlquilerEnCursoException(4711L));

        assertThat(appender.list).singleElement().satisfies(evento -> {
            assertThat(evento.getLevel()).isEqualTo(Level.WARN);       // WARN, no ERROR
            assertThat(evento.getFormattedMessage()).contains("4711");
        });
    }
}

La segunda prueba es la interesante: verifica el nivel, que es justo la decisión del apartado 4 y la que hace que la alerta de errores de 09-04 sea creíble. Y el doesNotContain de la primera es una forma barata y efectiva de convertir la política del apartado 10 en una prueba automática: una prueba que falla si alguien registra una tarjeta.

Dos advertencias: ListAppender requiere el Logger de Logback y por eso es la única excepción a la regla de no importar la implementación; y hay que soltar el appender en el @AfterEach, porque el logger es estático y se filtraría a las pruebas siguientes.

Errores Comunes y Consejos

e.printStackTrace() o un catch vacío. Sin nivel, sin marca de tiempo, sin trazaId, fuera de toda configuración y sin llegar al agregador. Siempre log.error("mensaje", e) con la excepción como último argumento, o relanzar.

Concatenar en lugar de parametrizar. log.debug("Alquiler " + id) construye la cadena aunque DEBUG esté apagado. Siempre {}.

Registrar e.getMessage() en lugar de la excepción. Se pierde la traza de pila, que es lo único que permite localizar el fallo, y el mensaje de una excepción de base de datos puede filtrar datos.

Marcar como ERROR los errores de negocio esperados. Un usuario con alquiler en curso no es un fallo del sistema. Si se registran como ERROR, la alerta de errores se dispara a diario y el equipo deja de mirarla.

Registrar tokens, contraseñas, correos o cuerpos completos. Un JWT en el log permite suplantar a un ciudadano de Ribalta, y el log se copia, se replica y lo lee gente ajena al equipo. Identificadores internos y enmascaramiento.

Olvidar MDC.clear(). El hilo vuelve al pool con el trazaId de la petición anterior y contamina el log justo cuando más falta hace. Siempre en el finally.

Dejar DEBUG puesto en producción. Decenas de megabytes por minuto, latencia peor y disco lleno. Se sube en caliente con /actuator/loggers, en un paquete y por un rato.

Escribir a fichero dentro de un contenedor. El fichero se pierde con el pod y nadie lo ve. A stdout, y que la plataforma lo recoja (12-Factor, 08-01).

Consejo: adopta JSON en pre y prod desde el primer día, con consola coloreada solo en dev. Migrar después obliga a rehacer todas las consultas del agregador.

Consejo: pon total-size-cap y max-history siempre que escribas a fichero. Un disco lleno detiene la aplicación, y es un incidente evitable con dos líneas.

Consejo: escribe una prueba que falle si aparece un dato sensible en el log. Es la única forma de que la política del apartado 10 sobreviva a la rotación del equipo.

Ejercicios

Ejercicio 1: corregir un método

Este método concentra seis errores de logging de los tratados en la lección. Encuéntralos, explica el riesgo de cada uno y escribe la versión corregida.

@PostMapping("/api/v1/alquileres")
public ResponseEntity<AlquilerResponse> iniciar(@RequestBody IniciarAlquilerRequest p,
                                                @RequestHeader("Authorization") String token) {
    log.info("Entrando en iniciar con " + p.toString() + " y token " + token);
    try {
        AlquilerResponse r = alquilerService.iniciar(p);
        log.info("Alquiler creado");
        return ResponseEntity.status(201).body(r);
    } catch (UsuarioConAlquilerEnCursoException e) {
        log.error("Error: " + e.getMessage());
        throw e;
    } catch (Exception e) {
        e.printStackTrace();
        throw e;
    }
}

Ejercicio 2: la investigación

Son las 04:12. Alertmanager avisa: «Tasa de error 4,1 % en prod». Tienes Grafana con Prometheus y Loki, tres réplicas, logs en JSON con trazaId, y el cuadro de mando de 09-04. Describe el procedimiento completo de investigación paso a paso, escribiendo las consultas PromQL y LogQL concretas que ejecutarías en cada paso y qué decidirías según lo que devuelva cada una.

Ejercicio 3: diseñar la política de logs

El ayuntamiento de Ribalta pide una política de logging por escrito antes de la auditoría de protección de datos. Redáctala para CicloUrbana cubriendo: qué se registra en cada capa y con qué nivel, formato y destino por entorno, qué datos están prohibidos y cómo se garantiza, retención de cada tipo de registro, quién tiene acceso, y cómo se sube el detalle para investigar sin desplegar. Justifica cada decisión.

Soluciones

Solución 1.

Error 1 — concatenación en lugar de parametrización. "Entrando en iniciar con " + p.toString() se construye siempre, incluso con INFO desactivado.

Error 2 — registrar el token. El fallo grave: quien lea ese log puede suplantar al ciudadano hasta que el JWT caduque, y el log se copia al agregador y lo ve todo el equipo. Es además un incidente de seguridad notificable.

Error 3 — volcar el cuerpo completo de la petición. p.toString() puede contener datos personales presentes o futuros; basta con que alguien añada un campo al record para que empiece a filtrarse sin que nadie lo note.

Error 4 — log de entrada en el controlador. Duplica lo que http.server.requests de 09-03 ya mide mejor y más barato, y multiplica el volumen por el tráfico.

Error 5 — ERROR para un error de negocio, y solo el mensaje. Un usuario con alquiler en curso es la regla del índice único parcial de 04-08 funcionando: es WARN o INFO. Y e.getMessage() pierde la traza de pila.

Error 6 — e.printStackTrace(). Va a System.err sin nivel, sin marca de tiempo, sin trazaId y sin llegar a Loki. Además, este catch no aporta nada: el ManejadorGlobalExcepciones de 03-06 ya centraliza el tratamiento y el registro.

Versión corregida:

@PostMapping("/api/v1/alquileres")
public ResponseEntity<AlquilerResponse> iniciar(@RequestBody IniciarAlquilerRequest p) {
    AlquilerResponse r = alquilerService.iniciar(p);
    return ResponseEntity.status(201).body(r);
}

El controlador no registra nada, que es lo correcto: la métrica cuenta la petición y el manejador global trata los errores. El hito de negocio se registra donde ocurre, en el servicio:

log.info("Alquiler iniciado {} {} {}",
         kv("idAlquiler", alquiler.getId()),
         kv("idUsuario", usuario.getId()),          // identificador, nunca el correo
         kv("idEstacion", peticion.estacionOrigenId()));

Y el manejador global decide el nivel según la naturaleza del error: WARN sin traza de pila para los de negocio, ERROR con la excepción completa para los inesperados. El trazaId lo aporta el MDC del FiltroTraza, sin que nadie lo escriba.

Solución 2.

Paso 1 — ¿qué endpoint y qué instancia? En Grafana, sobre Prometheus:

sum by (uri, instance) (rate(http_server_requests_seconds_count{outcome="SERVER_ERROR"}[5m]))

Si los errores se reparten entre las tres instancias, el problema es común —código, base de datos o un servicio externo—; si se concentran en una, es esa instancia —memoria, disco, un pod degradado— y la acción inmediata puede ser sacarla del balanceador. Supongamos que se reparten y que la uri es /api/v1/alquileres.

Paso 2 — ¿desde cuándo y coincide con algo? Se amplía el rango a 6 horas y se compara el inicio de la subida con el panel de despliegues o con application_ready_time_seconds. Si empezó justo tras un despliegue, la hipótesis principal es una regresión y la acción es revertir (08-05) antes de seguir investigando: primero se restablece el servicio.

Paso 3 — ¿qué error concreto? En Loki:

{app="ciclourbana", entorno="prod"} | json | level = "ERROR"

Se busca el patrón dominante. Si aparecen decenas de PasarelaNoDisponibleException, la causa es externa; si aparece CannotAcquireLockException o timeouts de conexión, es la base de datos.

Paso 4 — cuantificar por tipo de error:

sum by (logger_name) (rate({app="ciclourbana"} | json | level = "ERROR" [5m]))

Convierte los logs en una serie temporal y dice qué componente domina, en lugar de deducirlo leyendo.

Paso 5 — la historia completa de un caso. Se toma un trazaId de una línea de ERROR y:

{app="ciclourbana", entorno="prod"} | json | trazaId = "9c4b21fa"

Devuelve la petición entera, en orden, incluidas las líneas asíncronas: qué se intentó, qué respondió el sistema externo, con qué mensaje y en qué punto se rompió.

Paso 6 — confirmar con las métricas de la causa. Si el sospechoso es la pasarela: resilience4j_circuitbreaker_state{state="open"} y histogram_quantile(0.95, sum by (le) (rate(http_client_requests_seconds_bucket[5m]))). Si es el pool: hikaricp_connections_pending. El objetivo de este paso es no quedarse con la primera hipótesis: los logs muestran el síntoma en un caso, las métricas confirman que es general.

Decisión: si la causa es externa y el cortacircuitos está haciendo su trabajo, se documenta, se avisa al proveedor y se vuelve a dormir, porque el respaldo de 07-06 mantiene el servicio. Si es una regresión propia, se revierte. Si es la base de datos, se mira el postgres_exporter y pg_stat_activity en busca de bloqueos. En los tres casos, la investigación ha durado minutos porque las tres señales estaban preparadas de antemano.

Solución 3.

Qué se registra y con qué nivel. Controladores: nada en el camino feliz, porque http.server.requests ya lo mide. Servicios: INFO en los hitos de negocio —alquiler iniciado, alquiler finalizado con importe, bicicleta a mantenimiento, cobro diferido—, con identificadores internos como argumentos estructurados; DEBUG para el detalle del cálculo, apagado por defecto. Errores de negocio esperados: WARN sin traza de pila. Errores inesperados: ERROR con la excepción completa, únicamente en el ManejadorGlobalExcepciones. Integraciones: WARN por reintento, ERROR al agotarlos, INFO en los cambios de estado del cortacircuitos. Seguridad: los eventos de 05-05 a la tabla de auditoría y a WARN.

Formato y destino. dev y test: consola coloreada y legible, com.ciclourbana en DEBUG. pre y prod: JSON a stdout con AsyncAppender, nivel raíz INFO, recogido por Promtail hacia Loki. Justificación: los humanos leen texto y las máquinas leen JSON, y en contenedor el único destino sensato es la salida estándar (12-Factor).

Datos prohibidos y garantía. Prohibidos: contraseñas, tokens, tarjetas, correos, teléfonos, DNI, direcciones, coordenadas y cuerpos completos de peticiones. Se registran identificadores internos. Garantías en tres capas: revisión de código con esta política como criterio explícito; pruebas automáticas que afirman doesNotContain sobre datos sensibles en los caminos críticos; y un RegexReplaceRule en Logback que enmascara números de 16 dígitos y cadenas con aspecto de JWT, como red de seguridad y no como sustituto de lo anterior.

Retención. Log de aplicación en Loki: 30 días, suficiente para investigar y proporcionado para el RGPD. Auditoría de seguridad en PostgreSQL: 2 años, con acceso restringido y sin borrado. Métricas en Prometheus: 30 días, y agregados anuales en almacenamiento de larga duración si el ayuntamiento pide informes. Como el log de aplicación no contiene datos personales por diseño, la retención de 30 días no plantea conflicto con el derecho de supresión: solo la tabla de auditoría lo hace, y su base legal es la obligación de rendir cuentas.

Acceso. Loki, a través de Grafana con autenticación e integración con el directorio del ayuntamiento: todo el equipo técnico. La tabla de auditoría: solo el responsable de seguridad, y su consulta queda a su vez registrada.

Investigar sin desplegar. /actuator/loggers de 07-01, protegido con ADMIN en el puerto 8081, permite subir un paquete concreto a DEBUG en caliente. Procedimiento obligado: un solo paquete, una sola instancia si es posible, un tiempo acotado y devolverlo a null al terminar. Queda expresamente prohibido un DEBUG global en prod, por su efecto sobre la latencia y el disco.

Conclusión

El segundo pilar está en pie. Sabes en qué se diferencia un log de una métrica y por qué son complementarios exactos: lo que en 09-03 estaba prohibido meter en una etiqueta es justo lo que debe ir en el log. Conoces la pila de Spring Boot —SLF4J como fachada, Logback por defecto, los puentes que unifican lo que escriben Hibernate y HikariCP, y Log4j2 como alternativa que se cambia con una exclusión— y por qué nunca se programa contra la implementación. Tienes una política de niveles por capa, con la decisión que hace creíble la alerta de errores de 09-04: los errores de negocio esperados no son ERROR. Y tienes tipificado el error grave, e.printStackTrace(), con las tres únicas cosas legítimas que puede hacer un catch.

Sabes por qué se parametrizan los mensajes con {} y qué cuesta no hacerlo; configuras el logging por propiedades con una política de rotación que impide llenar el disco; y tienes el logback-spring.xml completo de CicloUrbana con <springProfile>, consola coloreada en dev, JSON a stdout en prod y un AsyncAppender que decide de antemano perder líneas antes que frenar a los ciudadanos. Entiendes por qué en producción un log es un dato y no un texto, con el mismo evento comparado en ambos formatos, el soporte nativo de Spring Boot 3.4 (logging.structured.format.console: ecs) frente a logstash-logback-encoder, y los argumentos estructurados que añaden campos consultables sin ensuciar el mensaje.

El MDC y el trazaId del FiltroTraza de 03-06 —propagado a los hilos asíncronos por el TaskDecorator de 07-03— convierten líneas sueltas en la historia de una petición, y con Loki y LogQL esa historia se recupera con una sola consulta desde el mismo Grafana donde vives las métricas, cruzando rate sobre logs con rate sobre métricas en el mismo panel. Tienes la tabla de pilas de agregación y la razón de escribir a stdout en contenedor, la separación entre auditoría de seguridad y logging de aplicación, y las pruebas con OutputCaptureExtension y ListAppender que convierten en verificable tanto el nivel de un evento como la ausencia de datos sensibles. Y, por encima de todo, la advertencia que no admite matices: contraseñas, tokens, tarjetas y datos personales de los ciudadanos de Ribalta jamás entran en un log, porque el log se copia, se replica, se guarda semanas y lo lee gente ajena al equipo.

Queda el límite que apareció al final del apartado 14 y que ninguna de las dos señales puede superar. El trazaId de CicloUrbana existe solo dentro de la aplicación: cuando la petición sale hacia la pasarela de pagos de 07-06, el proveedor no lo conoce; si mañana el monolito se divide en servicios (07-05), cada uno generará el suyo y la correlación se romperá en la frontera. Y hay una pregunta que ni las métricas ni los logs responden bien: cuando una petición tarda dos segundos y atraviesa el controlador, tres consultas, una caché y una llamada remota, ¿dónde se fueron esos dos segundos? Ni el agregado de una métrica ni una sucesión de líneas con marcas de tiempo lo dicen con precisión. La última lección del módulo, Trazabilidad Distribuida, responde a las dos cosas: spans y trazas, propagación del contexto con traceparent de W3C, Micrometer Tracing en lugar del descontinuado Sleuth, spans propios con la Observation API de 09-03, exemplars que saltan de un punto de una gráfica a la traza concreta, y un backend donde ver la cascada completa de un alquiler y señalar con el dedo el span que se comió el 80 % del tiempo.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados