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:

CampoEjemploPara qué sirve
timestamp2026-09-25T10:15:02.412ZMomento del evento en ISO 8601 y UTC, con milisegundos
levelinfo, warning, errorGravedad, siempre en minúsculas y con los mismos valores
serviceorders-apiServicio que emite el log
envproductionEntorno: production, staging, development
hostweb-01Servidor o contenedor
messagepayment_failedQué ha pasado, con un texto fijo que no incluya variables
request_id8f14e45f...Identificador de la petición, igual en todos los servicios que atraviesa
version2.3.1Versió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:

NivelCuándo usarloEjemplo
debugDetalle para desarrollo. Desactivado en producciónConsulta SQL generada
infoEventos normales del ciclo de vidaServicio iniciado, pedido procesado
warningAlgo inesperado que el sistema ha superadoReintento de conexión, respuesta lenta
errorUna operación concreta ha falladoPago rechazado por el proveedor
criticalEl servicio no puede funcionarSin 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_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:

~/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, 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.