--- title: "Logging estructurado en JSON: buenas prácticas y ejemplos" description: "Aprende a emitir logs estructurados en JSON: campos comunes, niveles, IDs de correlación con Nginx y ejemplos en Python, Node.js y Go para Loki o Graylog." category: "Gestión de Logs" language: es source: https://cubepath.com/es/docs/gestión-de-logs/mejores-practicas-de-logging-estructurado updated: 2026-09-25 --- # Logging estructurado en JSON: buenas prácticas y ejemplos 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: ```bash sudo apt update sudo apt install -y jq ``` ## Texto libre frente a logs estructurados Compara la misma información en los dos formatos: ```text 2026-09-25 10:15:02 ERROR Payment failed for order ord_67890 (user usr_12345) after 145ms: card declined ``` ```json {"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 no `message="Payment failed for ord_67890"`. Así puedes agrupar por mensaje. - **Nombres en `snake_case` y con la unidad.** `duration_ms`, `size_bytes`, `amount_eur`. Nunca mezcles `durationMs` y `duration` entre servicios. - **Un tipo por campo.** Si `status` es 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`: ```bash sudo nano /etc/nginx/conf.d/json-logging.conf ``` ```nginx 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`: ```nginx 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: ```bash sudo nginx -t sudo systemctl reload nginx ``` ```output 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: ```bash curl -s -o /dev/null -D - http://localhost/ | grep -i x-request-id sudo tail -n 1 /var/log/nginx/access.json.log | jq ``` ```output 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: ```bash sudo apt install -y python3-venv python3 -m venv ~/logdemo ~/logdemo/bin/pip install structlog ``` Crea el script de ejemplo: ```bash nano ~/logdemo/app.py ``` ```python 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_contextvars` añade a cada evento los campos ligados con `bind_contextvars`. El bloque `with bound_contextvars(...)` añade `request_id` solo mientras dura la petición, y funciona también con código asíncrono. - `dict_tracebacks` convierte la traza de la excepción en un campo `exception` estructurado, dentro de la misma línea. - `EventRenamer("message")` renombra el campo `event` de structlog a `message`, el nombre del esquema común. - `make_filtering_bound_logger(logging.INFO)` descarta los eventos `debug` sin coste. Ejecuta el script y filtra la salida con `jq`: ```bash ~/logdemo/bin/python ~/logdemo/app.py | jq -c 'del(.exception)' ``` ```output {"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í: ```javascript 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: ```go 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) } ``` ```output {"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: ```bash 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`: ```bash sudo mkdir -p /etc/systemd/journald.conf.d sudo nano /etc/systemd/journald.conf.d/retention.conf ``` ```ini [Journal] SystemMaxUse=2G MaxRetentionSec=2week ``` Aplica los cambios y comprueba el espacio que ocupa el journal: ```bash sudo systemctl restart systemd-journald journalctl --disk-usage ``` ```output 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//` 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: ```bash 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: ```bash sudo jq -r '.status' /var/log/nginx/access.json.log | sort | uniq -c | sort -rn ``` ```output 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`: ```bash 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, como `request_id`, `user_id` o `client_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.