Un log estructurado es un evento con campos con nombre, normalmente un objeto JSON por línea, en lugar de una frase de texto libre. Sistemas como Loki, Graylog u OpenSearch pueden filtrar, agrupar y contar esos campos sin expresiones regulares frágiles, y una misma petición se puede seguir a través de varios servicios mediante un identificador común. En esta guía verás qué campos incluir, cómo usar los niveles, cómo generar y propagar un ID de petición desde Nginx y cómo emitir logs JSON en Python, Node.js y Go, con ejemplos que puedes probar en Ubuntu 24.04.
Requisitos previos
- Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, y un usuario con privilegios
sudo. - Nginx instalado, para el ejemplo de logs de acceso e IDs de petición.
- Python 3 (incluido en Ubuntu 24.04) para el ejemplo principal. Node.js y Go son opcionales.
jq, para leer los logs JSON desde la terminal:
sudo apt update
sudo apt install -y jq
Texto libre frente a logs estructurados
Compara la misma información en los dos formatos:
2026-09-25 10:15:02 ERROR Payment failed for order ord_67890 (user usr_12345) after 145ms: card declined
{"timestamp":"2026-09-25T10:15:02.412Z","level":"error","service":"orders-api","request_id":"8f14e45fceea167a5a36dedd4bea2543","message":"payment_failed","order_id":"ord_67890","user_id":"usr_12345","duration_ms":145,"reason":"card_declined"}
Con la primera línea, contar los pagos fallidos por motivo exige una expresión regular que se rompe en cuanto alguien cambia la frase. Con la segunda basta con filtrar por message y agrupar por reason. Además, los números (duration_ms) son números, así que se pueden sumar o calcular percentiles.
Campos que debe tener cada evento
Define un esquema común para todos tus servicios y documéntalo. Un buen punto de partida:
| Campo | Ejemplo | Para qué sirve |
|---|---|---|
timestamp | 2026-09-25T10:15:02.412Z | Momento del evento en ISO 8601 y UTC, con milisegundos |
level | info, warning, error | Gravedad, siempre en minúsculas y con los mismos valores |
service | orders-api | Servicio que emite el log |
env | production | Entorno: production, staging, development |
host | web-01 | Servidor o contenedor |
message | payment_failed | Qué ha pasado, con un texto fijo que no incluya variables |
request_id | 8f14e45f... | Identificador de la petición, igual en todos los servicios que atraviesa |
version | 2.3.1 | Versión desplegada, útil para relacionar errores con un despliegue |
Y unas reglas que evitan la mayoría de problemas:
- Mensajes constantes, datos en campos. Escribe
message="payment_failed", order_id="ord_67890"y nomessage="Payment failed for ord_67890". Así puedes agrupar por mensaje. - Nombres en
snake_casey con la unidad.duration_ms,size_bytes,amount_eur. Nunca mezclesdurationMsydurationentre servicios. - Un tipo por campo. Si
statuses un número en un servicio y un texto en otro, los sistemas de indexación rechazarán o convertirán parte de los eventos. - Una línea por evento. Las trazas de excepción van dentro de un campo, no como líneas sueltas que el agregador separará en eventos distintos.
- Nada de secretos ni datos personales innecesarios. Contraseñas, tokens, cabeceras
Authorization, números de tarjeta y cuerpos completos de peticiones no deben llegar nunca a los logs.
Si trabajas con trazas distribuidas, usa los nombres de OpenTelemetry (trace_id, span_id) en lugar de inventar los tuyos, así podrás saltar de un log a su traza.
Niveles de log
Usa pocos niveles y con un significado claro, porque de ellos dependen las alertas:
| Nivel | Cuándo usarlo | Ejemplo |
|---|---|---|
debug | Detalle para desarrollo. Desactivado en producción | Consulta SQL generada |
info | Eventos normales del ciclo de vida | Servicio iniciado, pedido procesado |
warning | Algo inesperado que el sistema ha superado | Reintento de conexión, respuesta lenta |
error | Una operación concreta ha fallado | Pago rechazado por el proveedor |
critical | El servicio no puede funcionar | Sin conexión con la base de datos tras todos los reintentos |
Una regla práctica: si un error no necesita que nadie haga nada, probablemente es un warning. Y si un nivel genera tantas líneas que nadie las lee, está mal elegido.
Paso 1: Logs de acceso de Nginx en JSON con ID de petición
Nginx genera un identificador aleatorio por petición en la variable $request_id. El objetivo es reutilizar el ID si la petición ya trae una cabecera X-Request-ID (por ejemplo, de un balanceador anterior), generarlo si no, devolverlo al cliente, pasarlo al backend y escribirlo en el log.
Crea un fichero en conf.d, que Ubuntu incluye dentro del bloque http:
sudo nano /etc/nginx/conf.d/json-logging.conf
map $http_x_request_id $req_id {
default $http_x_request_id;
"" $request_id;
}
log_format json_access escape=json
'{'
'"timestamp":"$time_iso8601",'
'"service":"nginx",'
'"request_id":"$req_id",'
'"client_ip":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"size_bytes":$body_bytes_sent,'
'"request_time_s":$request_time,'
'"upstream_time_s":"$upstream_response_time",'
'"user_agent":"$http_user_agent",'
'"referer":"$http_referer"'
'}';
access_log /var/log/nginx/access.json.log json_access;
escape=json escapa comillas y caracteres de control en los valores para que cada línea sea JSON válido. upstream_response_time va entre comillas porque puede estar vacío cuando no hay backend, o contener varios valores separados por comas si Nginx reintenta con otro servidor. El access_log de nginx.conf sigue activo, así que tendrás los dos formatos en paralelo hasta que decidas quitar el clásico. La configuración de logrotate de Nginx ya rota todos los ficheros *.log de /var/log/nginx.
Para devolver el ID al cliente y pasarlo al backend, añade estas líneas en el bloque server o location de tu sitio, por ejemplo en /etc/nginx/sites-available/default:
add_header X-Request-ID $req_id always;
proxy_set_header X-Request-ID $req_id;
proxy_set_header solo tiene efecto en los location que usan proxy_pass. Comprueba la configuración y recarga Nginx:
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Haz una petición y lee la última línea del log:
curl -s -o /dev/null -D - http://localhost/ | grep -i x-request-id
sudo tail -n 1 /var/log/nginx/access.json.log | jq
X-Request-ID: 3f2a9c1e7b4d4e0f9a8b6c5d4e3f2a1b
{
"timestamp": "2026-09-25T10:20:11+00:00",
"service": "nginx",
"request_id": "3f2a9c1e7b4d4e0f9a8b6c5d4e3f2a1b",
"client_ip": "127.0.0.1",
"method": "GET",
"uri": "/",
"status": 200,
"size_bytes": 615,
"request_time_s": 0,
"upstream_time_s": "",
"user_agent": "curl/8.5.0",
"referer": ""
}
Si envías tu propio ID con curl -H "X-Request-ID: prueba-123" http://localhost/, Nginx lo reutiliza en la cabecera y en el log.
Paso 2: Logs JSON en Python con structlog
structlog es la librería de logging estructurado más usada en Python. Crea un entorno virtual para la prueba:
sudo apt install -y python3-venv
python3 -m venv ~/logdemo
~/logdemo/bin/pip install structlog
Crea el script de ejemplo:
nano ~/logdemo/app.py
import logging
import os
import socket
import structlog
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.dict_tracebacks,
structlog.processors.EventRenamer("message"),
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
logger_factory=structlog.PrintLoggerFactory(),
)
log = structlog.get_logger()
# Campos comunes a todos los eventos del proceso
structlog.contextvars.bind_contextvars(
service="orders-api",
env=os.getenv("APP_ENV", "production"),
host=socket.gethostname(),
)
def process_order(order_id: str, request_id: str) -> None:
# Campos que solo existen durante esta petición
with structlog.contextvars.bound_contextvars(request_id=request_id):
log.debug("order_received", order_id=order_id)
log.info("order_processed", order_id=order_id, duration_ms=145)
try:
raise ValueError("card declined")
except ValueError:
log.exception("payment_failed", order_id=order_id)
process_order("ord_67890", "3f2a9c1e7b4d4e0f9a8b6c5d4e3f2a1b")
Lo importante de esta configuración:
merge_contextvarsañade a cada evento los campos ligados conbind_contextvars. El bloquewith bound_contextvars(...)añaderequest_idsolo mientras dura la petición, y funciona también con código asíncrono.dict_tracebacksconvierte la traza de la excepción en un campoexceptionestructurado, dentro de la misma línea.EventRenamer("message")renombra el campoeventde structlog amessage, el nombre del esquema común.make_filtering_bound_logger(logging.INFO)descarta los eventosdebugsin coste.
Ejecuta el script y filtra la salida con jq:
~/logdemo/bin/python ~/logdemo/app.py | jq -c 'del(.exception)'
{"order_id":"ord_67890","duration_ms":145,"service":"orders-api","env":"production","host":"web-01","request_id":"3f2a9c1e7b4d4e0f9a8b6c5d4e3f2a1b","level":"info","timestamp":"2026-09-25T10:25:40.183512Z","message":"order_processed"}
{"order_id":"ord_67890","service":"orders-api","env":"production","host":"web-01","request_id":"3f2a9c1e7b4d4e0f9a8b6c5d4e3f2a1b","level":"error","timestamp":"2026-09-25T10:25:40.183790Z","message":"payment_failed"}
El evento debug no aparece, y quitar del(.exception) muestra la traza completa del error como JSON.
Paso 3: Node.js con pino y Go con log/slog
Los mismos principios se aplican en cualquier lenguaje. Estas son configuraciones equivalentes que producen el mismo esquema de campos.
En Node.js, pino es la librería de logging JSON más extendida. Instálala en tu proyecto con npm install pino y configura el logger así:
const os = require('os');
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
messageKey: 'message',
timestamp: pino.stdTimeFunctions.isoTime,
formatters: {
level: (label) => ({ level: label }),
},
base: {
service: 'orders-api',
env: process.env.APP_ENV || 'production',
host: os.hostname(),
},
});
// Un logger hijo por petición, con su request_id
const reqLog = logger.child({ request_id: '3f2a9c1e7b4d4e0f9a8b6c5d4e3f2a1b' });
reqLog.info({ order_id: 'ord_67890', duration_ms: 145 }, 'order_processed');
Por defecto pino escribe el nivel como número (30) y el mensaje en msg. Las opciones formatters.level y messageKey los adaptan al esquema común, y timestamp usa ISO 8601 en lugar de milisegundos desde 1970.
En Go no necesitas dependencias externas: desde Go 1.21 la librería estándar incluye log/slog con un handler JSON:
package main
import (
"log/slog"
"os"
)
func main() {
hostname, _ := os.Hostname()
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})).With("service", "orders-api", "env", "production", "host", hostname)
reqLog := logger.With("request_id", "3f2a9c1e7b4d4e0f9a8b6c5d4e3f2a1b")
reqLog.Info("order_processed", "order_id", "ord_67890", "duration_ms", 145)
}
{"time":"2026-09-25T10:30:12.52Z","level":"INFO","msg":"order_processed","service":"orders-api","env":"production","host":"web-01","request_id":"3f2a9c1e7b4d4e0f9a8b6c5d4e3f2a1b","order_id":"ord_67890","duration_ms":145}
slog usa por defecto las claves time, level (en mayúsculas) y msg. Si necesitas exactamente el esquema común, renómbralas con la opción ReplaceAttr de HandlerOptions; si no, basta con que el agregador sepa qué clave es cada una.
Paso 4: Dónde escribir los logs
La opción más sencilla y robusta es escribir a la salida estándar y dejar que el sistema capture los logs: systemd los guarda en el journal y Docker en su driver de logs. Así la aplicación no gestiona ficheros, permisos ni rotación.
Si el servicio corre con systemd, puedes filtrar sus eventos JSON directamente del journal. La opción -o cat muestra solo el mensaje, sin los metadatos del journal:
journalctl -u orders-api -o cat --since "1 hour ago" | jq -c 'select(.level == "error")'
El journal crece hasta un límite que depende del tamaño del disco. Para fijar el tamaño máximo y la antigüedad de los logs, crea un fichero de configuración adicional en lugar de editar journald.conf:
sudo mkdir -p /etc/systemd/journald.conf.d
sudo nano /etc/systemd/journald.conf.d/retention.conf
[Journal]
SystemMaxUse=2G
MaxRetentionSec=2week
Aplica los cambios y comprueba el espacio que ocupa el journal:
sudo systemctl restart systemd-journald
journalctl --disk-usage
Archived and active journals take 312.5M in the file system.
Si la aplicación escribe en ficheros, usa uno por servicio en /var/log/<servicio>/ y crea una regla de logrotate para él.
Paso 5: Consultar los logs con jq
Con logs JSON, jq sustituye a la mayoría de combinaciones de grep, awk y cut. Algunos ejemplos sobre el log de Nginx del paso 1:
Peticiones con error de servidor:
sudo jq -c 'select(.status >= 500) | {timestamp, request_id, uri, status}' /var/log/nginx/access.json.log
Recuento de peticiones por código de estado:
sudo jq -r '.status' /var/log/nginx/access.json.log | sort | uniq -c | sort -rn
842 200
37 404
5 502
Todas las líneas de una petición concreta, a partir del ID que el cliente ve en la cabecera X-Request-ID:
sudo jq -c 'select(.request_id == "3f2a9c1e7b4d4e0f9a8b6c5d4e3f2a1b")' /var/log/nginx/access.json.log
Si tu aplicación registra el mismo request_id que recibe de Nginx, la misma búsqueda en el agregador devuelve la línea de Nginx junto con todos los eventos de la aplicación para esa petición.
Enviar los logs a un sistema de agregación
Cuando tienes varios servidores, el siguiente paso es enviar los logs a un sistema central. Al hacerlo, ten en cuenta la cardinalidad:
- En Loki, solo unos pocos campos con pocos valores posibles deben ser etiquetas:
service,env,host,level. El resto, comorequest_id,user_idoclient_ip, se queda en el contenido y se extrae al consultar con| json, por ejemplo{service="orders-api"} | json | level="error". - En Graylog u OpenSearch, cada campo JSON se indexa como un campo propio. Mantén los tipos constantes entre servicios y evita crear campos con nombres dinámicos (por ejemplo, un campo por ID de cliente), porque cada nombre nuevo se añade al mapeo del índice.
Solución de problemas
El agregador divide una excepción en varias líneas. La aplicación escribe la traza como texto multilínea. Usa un procesador que la incluya dentro del evento JSON, como dict_tracebacks en structlog.
jq falla con parse error: Invalid numeric literal. Hay líneas que no son JSON, normalmente mensajes de arranque de una librería que escribe texto plano. Filtra con jq -R 'fromjson? // empty' para ignorarlas mientras corriges la configuración de esa librería.
El log de Nginx tiene JSON inválido. Falta escape=json en log_format, o una variable que puede estar vacía o contener varios valores (como $upstream_response_time) está sin comillas en una posición numérica.
El mismo campo aparece con tipos distintos. Revisa que todos los servicios usan el esquema común; en OpenSearch y Graylog el primer tipo que llega a un índice fija el mapeo del campo.
Conclusión
Con un esquema de campos común, niveles con significado claro y un request_id que viaja desde Nginx hasta la aplicación, tus logs pasan de ser texto que hay que leer a datos que se pueden filtrar y agregar. Como siguientes pasos, envía estos logs a Loki con Grafana Alloy o a Graylog por GELF, añade trace_id con OpenTelemetry si usas trazas distribuidas, y crea alertas sobre la tasa de eventos error por servicio.
