Tu aplicación produce miles de líneas de log por segundo. Durante un incidente, te conectas por SSH a la máquina y ejecutas grep "error" app.log | tail -100. Obtienes 100 líneas que se ven así:
2026-05-22 14:32:01 ERROR Algo falló procesando la petición del usuario 4521
2026-05-22 14:32:01 ERROR No se pudo conectar a la base de datos
2026-05-22 14:32:02 ERROR Timeout esperando respuesta de payment-service
¿Qué usuario se vio afectado? ¿Qué request ID? ¿Qué instancia? ¿Cuál era el trace ID? No lo sabes. Estás parseando frases en español con los ojos, y vas a pasar los próximos 20 minutos correlacionando timestamps entre tres servicios diferentes.
Este es el coste del logging no estructurado. Y en sistemas distribuidos, es un coste que no puedes permitirte.
Qué significa realmente logging estructurado
Logging estructurado significa que cada entrada de log es una estructura de datos parseable por máquinas — típicamente JSON — en lugar de una frase escrita por humanos. La diferencia es fundamental:
No estructurado:
2026-05-22 14:32:01 ERROR Error al procesar pedido 8834 del usuario 4521: timeout de conexión a payment-service tras 5000ms
Estructurado:
{
"timestamp": "2026-05-22T14:32:01.342Z",
"level": "ERROR",
"logger": "com.app.order.OrderService",
"message": "Error al procesar pedido",
"service": "order-service",
"instance": "order-service-7b4d9-xk2n1",
"order_id": "8834",
"user_id": "4521",
"error_type": "ConnectionTimeoutException",
"dependency": "payment-service",
"timeout_ms": 5000,
"trace_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"span_id": "1a2b3c4d5e6f7a8b"
}
La primera versión está optimizada para un humano leyendo una sola línea de log. La segunda está optimizada para una máquina consultando millones de ellas. En un sistema en producción con 50 servicios, la segunda versión es la única que escala.
Configurando logging estructurado en Kotlin
La mayoría de aplicaciones JVM usan SLF4J con Logback. El PatternLayout por defecto produce texto no estructurado. Cambiar a salida estructurada requiere un encoder JSON. Logstash Logback Encoder es la elección estándar.
Añadir la dependencia (Gradle):
// build.gradle.kts
dependencies {
implementation("net.logstash.logback:logstash-logback-encoder:7.4")
implementation("ch.qos.logback:logback-classic:1.4.14")
}
Configurar Logback para salida JSON:
<!-- logback.xml -->
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>trace_id</includeMdcKeyName>
<includeMdcKeyName>span_id</includeMdcKeyName>
<includeMdcKeyName>request_id</includeMdcKeyName>
<!-- Siempre incluir metadatos del servicio -->
<customFields>
{"service":"order-service","environment":"${ENV:-dev}"}
</customFields>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>
A partir de este punto, cada llamada a log.info(...) produce una línea JSON en lugar de una línea de texto. No se requieren cambios de código — la migración es puramente de configuración.
El patrón MDC: añadiendo contexto sin cambiar cada llamada de log
El Mapped Diagnostic Context (MDC) es el mecanismo que hace poderoso al logging estructurado. Te permite adjuntar pares clave-valor al hilo actual, y cada sentencia de log en ese hilo los incluye automáticamente.
import org.slf4j.LoggerFactory
import org.slf4j.MDC
class RequestFilter : Filter() {
override fun doFilter(
request: HttpServletRequest,
response: HttpServletResponse,
chain: FilterChain
) {
try {
MDC.put("request_id", request.getHeader("X-Request-ID") ?: generateRequestId())
MDC.put("user_id", extractUserId(request))
MDC.put("client_ip", request.remoteAddr)
chain.doFilter(request, response)
} finally {
MDC.clear() // Siempre limpiar — MDC es thread-local
}
}
}
Ahora cada sentencia de log en la cadena de la petición — ya sea en tu controller, service o repository — incluye automáticamente request_id, user_id y client_ip. Tus desarrolladores no necesitan recordar pasar estos valores.
class OrderService {
private val log = LoggerFactory.getLogger(OrderService::class.java)
fun processOrder(orderId: String) {
log.info("Procesando pedido", kv("order_id", orderId))
try {
val result = paymentClient.charge(orderId)
log.info("Pago completado",
kv("order_id", orderId),
kv("payment_id", result.paymentId),
kv("amount", result.amount))
} catch (e: Exception) {
log.error("Pago fallido",
kv("order_id", orderId),
kv("error_type", e.javaClass.simpleName),
kv("error_message", e.message))
throw e
}
}
}
La salida incluye tanto los campos explícitos como el contexto del MDC:
{
"timestamp": "2026-05-22T14:32:01.342Z",
"level": "INFO",
"message": "Pago completado",
"order_id": "8834",
"payment_id": "pay_9x8w7v",
"amount": 129.99,
"request_id": "req-a1b2c3d4",
"user_id": "4521",
"client_ip": "203.0.113.42",
"service": "order-service",
"trace_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
}
Correlacionando logs con trazas: el eslabón perdido
La mayoría de equipos tienen tracing y logging configurados de forma independiente. Los logs van a un sistema, las trazas van a otro, y cuando algo se rompe, estás correlacionando timestamps mentalmente entre ambos.
La solución es simple: inyectar el contexto de traza de OpenTelemetry en el MDC de tu logging para que cada línea de log lleve el trace_id y el span_id.
import io.opentelemetry.api.trace.Span
import io.opentelemetry.context.Context
class OtelLoggingFilter : Filter() {
override fun doFilter(
request: HttpServletRequest,
response: HttpServletResponse,
chain: FilterChain
) {
val span = Span.fromContext(Context.current())
val spanContext = span.spanContext
if (spanContext.isValid) {
MDC.put("trace_id", spanContext.traceId)
MDC.put("span_id", spanContext.spanId)
MDC.put("trace_flags", spanContext.traceFlags.asHex())
}
try {
chain.doFilter(request, response)
} finally {
MDC.remove("trace_id")
MDC.remove("span_id")
MDC.remove("trace_flags")
}
}
}
Si estás usando el OpenTelemetry Java Agent, esta inyección ocurre automáticamente mediante la integración opentelemetry-logback-mdc-1.0 — no se requiere código manual. Solo añádelo al classpath:
// build.gradle.kts
dependencies {
runtimeOnly("io.opentelemetry.instrumentation:opentelemetry-logback-mdc-1.0-alpha:2.11.0-alpha")
}
Con esto funcionando, puedes hacer clic en un log de error en Grafana Loki y saltar directamente a la traza correspondiente en Tempo. O buscar todos los logs pertenecientes a una traza específica. El trace_id es el puente.
Qué loguear y qué no loguear
El mayor error que cometen los equipos con logging estructurado no es el formato — es loguear demasiado o muy poco. Aquí tienes un framework práctico:
Siempre loguear:
- Fronteras de petición — cuando una petición entra y sale de tu servicio
- Llamadas externas — cada llamada HTTP, consulta a base de datos o publicación de mensaje (con latencia)
- Eventos de negocio — pedido creado, pago procesado, usuario registrado
- Errores y excepciones — con contexto completo (¿cuál era la entrada? ¿cuál era el estado?)
- Transiciones de estado — circuit breaker abierto, caché evacuada, feature flag cambiado
Nunca loguear:
- Datos sensibles — contraseñas, tokens, números de tarjeta, datos personales sin enmascarar
- Bucles de alta frecuencia — loguear dentro de un
forque procesa 10.000 elementos ahogará todo lo demás - Información redundante — si ya está en un atributo del span, no lo dupliques en un log
- Rutas de éxito en DEBUG en producción — a menos que puedas cambiar dinámicamente los niveles de log por petición
Niveles de log que significan algo:
| Nivel | Cuándo usarlo |
|---|---|
ERROR | Algo falló que no debería haber fallado. Requiere investigación. |
WARN | Algo inesperado ocurrió pero el sistema se recuperó. Circuit breaker abierto, reintento exitoso. |
INFO | Eventos de negocio normales. Petición procesada, pedido creado, pago completado. |
DEBUG | Información diagnóstica detallada. Desactivado por defecto en producción. |
Si no puedes distinguir inmediatamente si una línea de log es ERROR o WARN, usa este test: ¿despertarías a alguien a las 3am por esto? Si sí, es ERROR. Si no, es WARN o INFO.
Manejando coroutines y virtual threads
MDC es thread-local, lo que crea problemas con las coroutines de Kotlin y los virtual threads de Java. Cuando la ejecución salta entre hilos, el contexto del MDC se pierde.
Kotlin Coroutines — usar MDCContext:
import kotlinx.coroutines.slf4j.MDCContext
import kotlinx.coroutines.withContext
suspend fun processOrderAsync(orderId: String) {
MDC.put("order_id", orderId)
// MDCContext propaga los valores del MDC a la coroutine
withContext(MDCContext()) {
log.info("Iniciando procesamiento asíncrono") // order_id presente
val result = async { paymentClient.chargeAsync(orderId) }
val inventory = async { inventoryClient.reserveAsync(orderId) }
result.await()
inventory.await()
log.info("Procesamiento asíncrono completado") // order_id sigue presente
}
}
Java 21+ Virtual Threads — envolver el executor:
import java.util.concurrent.Executors
val executor = Executors.newVirtualThreadPerTaskExecutor()
fun submitWithMdc(task: Runnable) {
val contextMap = MDC.getCopyOfContextMap() ?: emptyMap()
executor.submit {
MDC.setContextMap(contextMap)
try {
task.run()
} finally {
MDC.clear()
}
}
}
Si no manejas esto, obtendrás entradas de log en tus rutas de código asíncronas sin trace_id, request_id ni user_id — exactamente las entradas que más necesitarás durante la depuración.
Datos sensibles: enmascaramiento y redacción
Loguear datos de usuario sin enmascarar es tanto un riesgo de seguridad como una violación de cumplimiento. Incorpora la redacción en tu pipeline de logging, no en cada llamada de log.
Con el encoder de Logstash, configura el enmascaramiento de campos directamente en Logback:
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<jsonGeneratorDecorator
class="net.logstash.logback.mask.MaskingJsonGeneratorDecorator">
<defaultMask>****</defaultMask>
<path>email</path>
<path>password</path>
<path>credit_card</path>
<path>ssn</path>
</jsonGeneratorDecorator>
</encoder>
Cualquier campo llamado email, password, credit_card o ssn se reemplazará automáticamente con **** en la salida — independientemente de dónde esté la sentencia de log en el código.
Consultando logs estructurados: de grep a LogQL
Una vez que tus logs son estructurados, desbloqueas la capacidad de consultarlos como una base de datos. Si usas Grafana Loki, esto es lo que se vuelve posible:
# Encontrar todos los errores de un usuario específico en la última hora
{service="order-service"} | json | user_id="4521" | level="ERROR"
# Calcular tasa de error por servicio en 5 minutos
sum by (service) (
count_over_time({level="ERROR"} | json [5m])
)
# Encontrar las llamadas externas más lentas
{service="order-service"} | json | dependency != "" | timeout_ms > 3000
# Rastrear una petición individual a través de todos los servicios
{} | json | request_id="req-a1b2c3d4"
Compara esto con lo que necesitarías con logs no estructurados:
# Buena suerte con esto
grep "req-a1b2c3d4" /var/log/*.log | grep -i error | awk '{print $1, $2, $NF}'
La versión estructurada es más rápida, más precisa, y funciona con millones de líneas de log en segundos.
El problema del coste: controlando el volumen de logs
El logging estructurado con contexto rico significa entradas de log más grandes. A escala, esto se traduce directamente en costes de almacenamiento e ingesta. Aquí tienes cómo gestionarlo:
1. Usa los niveles de log agresivamente. Ejecuta INFO en producción, DEBUG solo cuando estés investigando activamente. Esto solo puede reducir el volumen un 60-80%.
2. Muestrea eventos de alta frecuencia. Si estás logueando cada health check o cada acierto de caché, muestréalos:
import java.util.concurrent.atomic.AtomicLong
class SampledLogger(
private val delegate: Logger,
private val sampleRate: Long = 100
) {
private val counter = AtomicLong(0)
fun infoSampled(message: String, vararg args: Any?) {
if (counter.incrementAndGet() % sampleRate == 0L) {
delegate.info(message, *args)
}
}
}
3. Elimina campos que nunca consultas. Si estás logueando stack_trace en cada entrada pero solo lo consultas en errores, configura tu pipeline para eliminarlo de las entradas que no son errores.
4. Establece políticas de retención por nivel. Mantén los logs ERROR durante 90 días, WARN durante 30 días, INFO durante 7 días. Tu agregador de logs casi seguro soporta esto.
La checklist
Antes de considerar tu logging estructurado listo para producción, verifica:
- Cada entrada de log es JSON válido (o tu formato estructurado elegido)
trace_idyspan_idestán presentes en cada línea de logservice,instanceyenvironmentestán incluidos como campos estáticos- Los campos sensibles están enmascarados o redactados
- El MDC se propaga correctamente a través de fronteras asíncronas (coroutines, virtual threads)
- Los niveles de log son significativos y consistentes entre servicios
- Puedes consultar logs por
trace_iden tu agregador de logs - Puedes saltar de una entrada de log a la traza correspondiente (y viceversa)
- Tienes políticas de retención configuradas por nivel de log
- Tu equipo ha acordado un esquema compartido para campos comunes
Los logs son el tercer pilar de la observabilidad — junto a métricas y trazas. Pero a diferencia de las métricas, que se agregan, y las trazas, que se muestrean, los logs son a menudo el único registro completo de lo que ocurrió. Haz que cuenten.