Observabilidad: logs, trazas y métricas

Por: Artiko
nestjsbuenas-practicasobservabilidadopentelemetrylogginghealth-checksmetricas

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:

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:

NivelCuándoEjemplo
errorFalla que requiere atención; algo no se completóexcepción no controlada, pago rechazado por el gateway
warnAnomalía recuperable o límite cercanoreintento de una llamada, rate limit al 80%
infoHitos de negocio observablesorden creada, usuario autenticado
debugDetalle para diagnosticar; apagado en prodpayload de una request a un servicio externo
traceMáximo detalle, efímerocada 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:

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:

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:

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]

Checklist

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 →.