Observabilidad: logs, trazas y métricas
Observabilidad: logs, trazas y métricas
Cuando tu API funciona en tu máquina, console.log alcanza. Cuando corre en tres réplicas detrás de un balanceador, procesa miles de requests concurrentes y falla de forma intermitente solo para el 2% del tráfico, console.log no solo no alcanza: te miente. No sabés a qué request pertenece cada línea, no podés seguir una llamada que cruza tres servicios, y “está lento” no te dice si es lento para todos o para la cola de peticiones que tocan una query sin índice.
La observabilidad es la capacidad de responder preguntas nuevas sobre el comportamiento de tu sistema sin desplegar código nuevo, a partir de la telemetría que emite. Se apoya en tres pilares complementarios:
- Logs: eventos discretos con contexto (“el usuario 42 falló el pago con
card_declined”). - Métricas: agregados numéricos a lo largo del tiempo (“la latencia p99 del checkout subió a 1.2 s”).
- Trazas: el recorrido de un request a través de todos los servicios y operaciones que toca.
Los tres se refuerzan: una métrica te avisa que algo empeoró, una traza te muestra dónde se fue el tiempo, y un log te dice por qué. Este capítulo cubre cómo emitir los tres desde NestJS 11 con el mínimo acoplamiento y sin degradar el rendimiento. Si querés la teoría a fondo de cada pilar, el sitio tiene cursos dedicados de Observabilidad, OpenTelemetry y Jaeger; acá nos enfocamos en la implementación idiomática en Nest.
flowchart TD
R[Request entrante] --> APP[App NestJS]
APP -->|eventos con contexto| L[Logs estructurados<br/>JSON + traceId]
APP -->|agregados temporales| M[Métricas<br/>contadores, histogramas]
APP -->|spans encadenados| T[Trazas distribuidas<br/>context propagation]
L --> BK[(Backend de<br/>observabilidad)]
M --> BK
T --> BK
BK --> Q{¿Qué pasó?}
Q -->|métrica: empeoró| Q2[¿Dónde?]
Q2 -->|traza: este span| Q3[¿Por qué?]
Q3 -->|log: esta causa| FIX[Diagnóstico]
Logging estructurado: por qué JSON y no console.log
Un log solo sirve si una máquina puede consultarlo. En producción nadie lee líneas de texto a ojo: las envías a un agregador (Loki, Elasticsearch, Datadog, CloudWatch) que las indexa y las consultás con filtros. Para eso el log tiene que ser estructurado: cada línea es un objeto JSON con campos consultables, no una cadena que hay que parsear con expresiones regulares frágiles.
Compará las dos formas de reportar el mismo evento.
// ANTIPATRÓN: log de texto sin estructura ni correlación
console.log(`Usuario ${userId} creó la orden ${orderId} por $${total}`);
// -> "Usuario 42 creó la orden 8891 por $199.9"
// Para filtrar por usuario tenés que hacer regex sobre texto libre.
// No hay timestamp, ni nivel, ni requestId, ni servicio.
// MEJOR: log estructurado, cada campo es consultable
logger.info(
{ userId, orderId, total, event: 'order.created' },
'orden creada',
);
// -> {"level":"info","time":1721260800000,"userId":42,
// "orderId":8891,"total":199.9,"event":"order.created",
// "reqId":"c1f...","traceId":"4bf9...","msg":"orden creada"}
Con la segunda forma podés preguntar “todas las órdenes de más de $100 del usuario 42 en la última hora” con un filtro estructurado, y correlacionar por traceId con la traza distribuida. Con la primera, no.
Por qué reemplazar el logger de Nest
Nest trae un Logger propio (@nestjs/common). Es útil para arrancar, pero por defecto imprime texto formateado para humanos, no JSON, y es síncrono: cada llamada escribe a stdout bloqueando el event loop. Bajo carga, un logging verboso y síncrono se convierte en un cuello de botella medible (revisá el capítulo de Rendimiento sobre bloqueo del event loop).
La práctica recomendada es reemplazar el logger global por uno estructurado y asíncrono. En el ecosistema Node la opción de referencia es Pino: serializa a JSON con muy bajo overhead y puede escribir en un worker aparte. Para Nest, el binding idiomático es nestjs-pino, que además loguea automáticamente cada request HTTP.
// app.module.ts
import { Module } from '@nestjs/common';
import { LoggerModule } from 'nestjs-pino';
@Module({
imports: [
LoggerModule.forRoot({
pinoHttp: {
level: process.env.LOG_LEVEL ?? 'info',
// pino-pretty SOLO en desarrollo; en prod, JSON crudo al stdout
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
// no loguear el healthcheck en cada probe del orquestador
autoLogging: {
ignore: (req) => req.url === '/health',
},
},
}),
],
})
export class AppModule {}
// main.ts — que Nest use Pino como logger de toda la app
import { NestFactory } from '@nestjs/core';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
await app.listen(3000);
}
bootstrap();
Con bufferLogs: true, los logs del arranque se retienen hasta que el logger real está disponible, así ni siquiera los mensajes de bootstrap salen sin estructura.
Niveles de log: usarlos con criterio
Los niveles no son decorativos; determinan qué se emite en cada entorno y qué dispara alertas. Una convención sólida:
| Nivel | Cuándo | Ejemplo |
|---|---|---|
error | Falla que requiere atención; algo no se completó | excepción no controlada, pago rechazado por el gateway |
warn | Anomalía recuperable o límite cercano | reintento de una llamada, rate limit al 80% |
info | Hitos de negocio observables | orden creada, usuario autenticado |
debug | Detalle para diagnosticar; apagado en prod | payload de una request a un servicio externo |
trace | Máximo detalle, efímero | cada iteración de un bucle en una investigación puntual |
En producción normalmente corrés en info. El nivel se controla por variable de entorno (LOG_LEVEL), nunca hardcodeado, en línea con la configuración por entorno y el factor de logs del 12-factor: la app escribe a stdout y el entorno de ejecución decide el destino.
No loguear datos sensibles
Un log estructurado que incluye password, authorization, tokens, números de tarjeta o PII es una fuga de datos con retención indefinida en tu agregador. Esto se conecta directamente con el capítulo de Seguridad: tratá los logs como una superficie de exposición más.
Pino permite redactar campos por path antes de serializar, sin que tengas que acordarte de omitirlos en cada llamada:
LoggerModule.forRoot({
pinoHttp: {
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'req.body.password',
'req.body.creditCard',
'*.token',
],
censor: '[REDACTED]',
},
},
});
La regla operativa: lista blanca de lo que sí se loguea, no lista negra de lo que no. Loguear el objeto de dominio entero (logger.info({ user })) tarde o temprano arrastra un campo sensible que se agregó después. Logueá campos explícitos.
Correlación de requests: seguir un hilo sin pasarlo a mano
El problema central del logging distribuido: un solo request genera decenas de líneas desde el controlador, el service, el repositorio, un job encolado. Sin un identificador común, esas líneas se mezclan con las de otros miles de requests concurrentes y no podés reconstruir qué pasó en uno.
El antipatrón es propagar un correlationId como parámetro a mano por cada función de la cadena: ensucia todas las firmas, se olvida en algún salto y no cruza los límites asíncronos de forma confiable.
// ANTIPATRÓN: pasar el id a mano contamina toda la cadena de llamadas
async createOrder(dto: CreateOrderDto, correlationId: string) {
this.logger.info({ correlationId }, 'creando orden');
await this.inventory.reserve(dto.items, correlationId); // y así hasta el fondo
}
La solución es AsyncLocalStorage (API nativa de Node): un almacén ligado al contexto asíncrono del request, que cualquier función dentro de ese contexto puede leer sin recibirlo por parámetro. En Nest, el paquete idiomático que lo envuelve es nestjs-cls (CLS = continuation-local storage). Genera y expone un id por request de forma transparente.
// app.module.ts
import { ClsModule } from 'nestjs-cls';
@Module({
imports: [
ClsModule.forRoot({
global: true,
middleware: {
mount: true,
generateId: true,
// reusar el id entrante si el cliente/gateway ya envió uno
idGenerator: (req) =>
req.headers['x-request-id']?.toString() ?? crypto.randomUUID(),
},
}),
],
})
export class AppModule {}
Ahora cualquier provider lee el id con ClsService, sin recibirlo por parámetro:
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
@Injectable()
export class OrderService {
constructor(private readonly cls: ClsService) {}
async createOrder(dto: CreateOrderDto) {
const correlationId = this.cls.getId();
this.logger.info({ correlationId }, 'creando orden');
// ...sin propagar nada a inventory.reserve()
}
}
Lo más potente es enganchar el id al logger para que toda línea lo incluya automáticamente. nestjs-pino integra con nestjs-cls, o podés inyectar el id por request. El resultado: cada log lleva el reqId, y cuando sumemos OpenTelemetry, también el traceId, que es la llave para saltar del log a la traza distribuida y viceversa (el curso de Observabilidad lo trata como correlación de señales).
sequenceDiagram
participant C as Cliente
participant MW as ClsMiddleware
participant Ctrl as Controller
participant Svc as Service
participant Repo as Repository
C->>MW: HTTP request (x-request-id?)
Note over MW: AsyncLocalStorage.run()<br/>genera/reusa id
MW->>Ctrl: dentro del contexto CLS
Ctrl->>Svc: createOrder(dto)
Svc->>Repo: save(order)
Note over Ctrl,Repo: cls.getId() devuelve el mismo id<br/>en toda la cadena, sin pasarlo
Repo-->>C: respuesta (todos los logs con reqId)
OpenTelemetry: trazas distribuidas y métricas
OpenTelemetry (OTel) es el estándar abierto para instrumentar aplicaciones: define un formato único (OTLP) para trazas, métricas y logs, y desacopla la generación de la telemetría del backend que la consume. Instrumentás una vez y exportás a Jaeger, Tempo, Prometheus o el vendor que sea, sin tocar el código. Los conceptos de fondo (spans, contexto, propagación) están en el curso de OpenTelemetry y en trazas distribuidas; acá va el cableado en Node/Nest.
Una traza es el árbol de spans que produce un request. Cada span representa una operación (recibir el HTTP, ejecutar una query, llamar a otro servicio) con nombre, duración, atributos y estado. La propagación de contexto es lo que hace “distribuida” a la traza: OTel inyecta el traceId en los headers salientes (traceparent, formato W3C Trace Context), y el servicio siguiente continúa la misma traza en vez de empezar una nueva. Así ves el request completo cruzando N servicios como un solo árbol.
Auto-instrumentación
La forma de menor esfuerzo y mayor cobertura es la auto-instrumentación: parches que envuelven las librerías comunes (el servidor HTTP, el cliente HTTP, Postgres, Redis, etc.) y generan spans sin que toques la lógica. Se configura en un archivo aparte que debe cargarse antes que la aplicación, porque necesita interceptar los require/import de esas librerías.
// instrumentation.ts — se carga ANTES del código de la app
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
const sdk = new NodeSDK({
serviceName: 'orders-api',
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/traces',
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/metrics',
}),
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
# cargar la instrumentación antes de la app (Node 20+)
node --import ./instrumentation.mjs dist/main.js
# en desarrollo con TypeScript
npx tsx --import ./instrumentation.ts src/main.ts
Con esto, cada request HTTP ya produce una traza con spans de las queries a la base y las llamadas salientes, con la propagación de contexto resuelta. La clave de arquitectura: exportás vía OTLP a un collector (el binario otelcol), no directo al backend. El collector recibe, procesa (samplea, filtra, enriquece) y reenvía; así podés cambiar de backend o agregar destinos sin redeployar la app. El detalle del collector y de Jaeger como backend de trazas están en sus cursos.
Spans manuales para operaciones de negocio
La auto-instrumentación cubre I/O, pero no sabe qué es “reservar inventario” o “calcular el precio”. Para las operaciones de negocio que querés medir, creás spans a mano:
import { trace, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer('orders');
async reserveInventory(items: Item[]) {
return tracer.startActiveSpan('inventory.reserve', async (span) => {
span.setAttribute('items.count', items.length);
try {
const result = await this.doReserve(items);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
throw err;
} finally {
span.end(); // SIEMPRE cerrar el span, o queda colgado en la traza
}
});
}
Como el span vive en el mismo contexto asíncrono, su traceId es el que también inyectás en los logs: log y traza quedan correlacionados por ese id.
Health checks: liveness vs readiness
Un orquestador (Kubernetes, ECS) necesita preguntarle a tu app dos cosas distintas, y confundirlas causa incidentes:
- Liveness: “¿el proceso está vivo o hay que reiniciarlo?”. Debe chequear solo el proceso, sin tocar dependencias externas. Si la liveness consulta la base y la base está caída, k8s reinicia todas tus réplicas en cascada por un problema que no es del proceso.
- Readiness: “¿está listo para recibir tráfico?”. Acá sí chequeás dependencias: base de datos, cache, colas. Si una falla, k8s saca la réplica del balanceador (deja de mandarle tráfico) pero no la reinicia; cuando la dependencia vuelve, la readiness pasa y la réplica reingresa. Esto habilita el arranque y apagado ordenados que pide el factor de desechabilidad del 12-factor.
El paquete oficial para esto es @nestjs/terminus. Expone HealthCheckService y un set de indicadores. En la versión actual, los indicadores personalizados se escriben con HealthIndicatorService y el patrón check(key) → up() / down() (la vieja clase base HealthIndicator con getStatus quedó deprecada).
// health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
HealthCheck,
HealthCheckService,
TypeOrmHealthIndicator,
MemoryHealthIndicator,
DiskHealthIndicator,
} from '@nestjs/terminus';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
private memory: MemoryHealthIndicator,
private disk: DiskHealthIndicator,
) {}
// LIVENESS: solo el proceso, sin dependencias externas
@Get('live')
@HealthCheck()
liveness() {
return this.health.check([
() => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024),
]);
}
// READINESS: dependencias necesarias para servir tráfico
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() => this.db.pingCheck('database', { timeout: 1500 }),
() => this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
]);
}
}
Un indicador propio para una dependencia externa (un servicio de pagos, por ejemplo):
import { Injectable } from '@nestjs/common';
import { HealthIndicatorService } from '@nestjs/terminus';
@Injectable()
export class PaymentGatewayHealthIndicator {
constructor(
private readonly healthIndicatorService: HealthIndicatorService,
private readonly gateway: PaymentGateway,
) {}
async isHealthy(key: string) {
const indicator = this.healthIndicatorService.check(key);
try {
await this.gateway.ping();
return indicator.up();
} catch (err) {
return indicator.down({ message: err.message });
}
}
}
El HealthCheckService.check([...]) devuelve 200 si todos los indicadores pasan y 503 si alguno falla, con el detalle por indicador en el body: exactamente el contrato que esperan las probes del orquestador.
Métricas: RED, USE y por qué percentiles y no promedios
Las métricas son series temporales numéricas: baratas de almacenar, ideales para dashboards y alertas. La librería estándar en Node es prom-client, que expone las métricas en el formato de Prometheus por un endpoint /metrics que el servidor de Prometheus scrapea.
Para no medir cualquier cosa, hay dos marcos:
- RED (para servicios que atienden requests): Rate (requests/s), Errors (fallos/s), Duration (latencia). Es lo primero que querés de una API.
- USE (para recursos): Utilization, Saturation, Errors. Aplica a CPU, memoria, pool de conexiones, cola de trabajos.
import { Injectable } from '@nestjs/common';
import { Histogram, Counter, register } from 'prom-client';
@Injectable()
export class HttpMetrics {
private readonly duration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Duración de requests HTTP',
labelNames: ['method', 'route', 'status'],
// buckets pensados para latencia web; definen la resolución de los percentiles
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});
observe(method: string, route: string, status: number, seconds: number) {
this.duration.observe({ method, route, status: String(status) }, seconds);
}
}
// endpoint que Prometheus scrapea
@Get('metrics')
async metrics() {
return register.metrics();
}
Percentiles, no promedios
Éste es el error de métricas más costoso. El promedio de latencia esconde la cola. Si 95 requests tardan 20 ms y 5 tardan 3000 ms, el promedio da ~170 ms: parece sano, pero uno de cada veinte usuarios espera 3 segundos. Los promedios no se pueden ni siquiera agregar correctamente entre réplicas.
Lo que se mide son percentiles sobre un histograma:
- p50 (mediana): la experiencia típica.
- p95: la cola que empieza a doler; el 5% peor.
- p99: los outliers; a alto volumen, miles de usuarios reales.
Por eso usás un Histogram con buckets, no un Gauge con el promedio: Prometheus calcula los percentiles con histogram_quantile() a partir de los buckets, y esos sí se agregan bien entre instancias. La resolución de tus percentiles depende de que los buckets estén cerca de tu latencia real: buckets mal elegidos dan percentiles imprecisos. El tratamiento completo de percentiles y SLOs está en SLI/SLO/SLA.
Antipatrones de observabilidad
flowchart LR
A[console.log en prod] --> A2[texto sin indexar,<br/>síncrono, sin niveles]
B[Logs sin correlación] --> B2[imposible seguir<br/>un request]
C[Loguear secretos/PII] --> C2[fuga con retención<br/>indefinida]
D[Sin health checks] --> D2[el orquestador no sabe<br/>cuándo enrutar/reiniciar]
E[Liveness que toca la DB] --> E2[reinicio en cascada<br/>por fallo externo]
F[Medir promedios] --> F2[la cola de latencia<br/>queda invisible]
console.logen producción: sin estructura, sin niveles, síncrono al event loop, sin correlación. Reemplazalo por un logger estructurado (Pino).- Logs sin estructura ni correlación: texto libre que hay que parsear con regex y que no se puede seguir a lo largo de un request. Usá JSON +
reqId/traceId. - Loguear secretos o PII: passwords, tokens, tarjetas, datos personales en el agregador. Redactá por path y logueá campos explícitos, no objetos enteros.
- No tener health checks, o mezclar liveness con readiness: el orquestador no puede enrutar ni reiniciar correctamente; una dependencia caída reinicia procesos sanos.
- Medir promedios en vez de percentiles: la latencia de la cola (p95/p99) queda oculta detrás de una media tranquilizadora.
- Exportar telemetría directo al backend sin collector: acopla la app al vendor y te obliga a redeployar para cambiar de destino o ajustar el sampling.
- Instrumentar todo con spans a mano: duplica lo que la auto-instrumentación ya hace. Reservá los spans manuales para operaciones de negocio.
Checklist
- Reemplazaste el logger de Nest por uno estructurado (Pino/
nestjs-pino) que emite JSON y usásapp.useLoggerconbufferLogs: true. - El nivel de log se controla por variable de entorno (
LOG_LEVEL), no hardcodeado. - Redactás campos sensibles (
authorization,password, tokens, PII) conredacty logueás campos explícitos, no objetos de dominio enteros. - Cada request lleva un
correlationId/reqIdpropagado conAsyncLocalStorage/nestjs-cls, sin pasarlo por parámetros. - El
traceIdde OpenTelemetry aparece en los logs para saltar de log a traza. - Tenés auto-instrumentación de OTel cargada antes de la app y exportás vía OTLP a un collector, no directo al backend.
- Creás spans manuales solo para operaciones de negocio relevantes y siempre los cerrás (
span.end()). - Exponés
/health/live(solo proceso) y/health/ready(dependencias) con@nestjs/terminus, y el orquestador los usa como liveness y readiness probes. - Emitís métricas RED (rate, errors, duration) y USE para recursos con
prom-clienten/metrics. - Medís latencia con histogramas y percentiles (p50/p95/p99), nunca con promedios.
Con logs, trazas y métricas correlacionados por un id común, ya podés responder qué, dónde y por qué falla tu API en producción. El próximo paso es asegurarte de que no vuelva a fallar: cómo probar todo esto de forma confiable y rápida en Testing →.