StackPractices
intermediate Por Mathias Paulenko

Logging de Alta Performance con pino

Cómo usar pino para logging estructurado JSON rápido en Node.js, incluyendo niveles de log, child loggers, transports e integración con Express y Fastify.

Overview

pino es el logger más rápido de Node.js — escribe JSON a stdout sincrónicamente con overhead mínimo. A diferencia de Winston o Bunyan, pino evita I/O async en la ruta crítica difiriendo el formateo a un worker thread via transports. Los benchmarks lo suelen situar 3-10x por delante de las alternativas.

When to Use

  • APIs de alto throughput donde el overhead de logging importa (miles de requests por segundo)
  • Microservicios que envían logs JSON a un agregador de logs (ELK, Loki, Datadog)
  • Apps Express o Fastify que necesitan middleware de request logging
  • Reemplazar console.log con salida estructurada y filtrable

When NOT to Use

  • Herramientas de línea de comandos que necesitan salida legible con colores — usa winston con Console transport
  • Scripts donde console.log es suficiente — pino añade una dependencia
  • Aplicaciones que necesitan enrutado de logs complejo (un archivo por nivel, alertas por email en errores) — Winston es más flexible

Solution

Setup

npm install pino

Logging básico

const pino = require("pino");

const logger = pino({
  level: "info",
});

logger.info({ port: 3000 }, "server_started");
logger.warn({ key: "user:42" }, "cache_miss");
logger.error({ error: "Connection refused" }, "database_error");

La firma de pino es logger.info(mergingObject, message) — el objeto va primero. Si lo pasas después del string (al estilo Winston), se interpreta como argumento de interpolación printf y se descarta en silencio: los campos nunca llegan a la línea de log.

Output (JSON a stdout):

{"level":30,"time":1719900000000,"pid":1,"hostname":"api-01","msg":"server_started","port":3000}

Child loggers para context de request

const logger = pino();

const requestLogger = logger.child({ request_id: "req-abc-123", user_id: 42 });

requestLogger.info({ order_id: "ord_456" }, "processing_order");
requestLogger.info({ items: 3 }, "order_validated");
requestLogger.warn({ sku: "widget-001", stock: 2 }, "inventory_low");

Ambos campos viajan en cada línea que emite el child.

Pretty printing en desarrollo

const logger = pino({
  transport: {
    target: "pino-pretty",
    options: {
      colorize: true,
      translateTime: "SYS:yyyy-mm-dd HH:MM:ss",
      ignore: "pid,hostname",
    },
  },
});

logger.info({ port: 3000 }, "server_started");

Añádelo como dependencia de desarrollo: npm install -D pino-pretty. Una alternativa local más barata que ni toca tu proceso: node app.js | npx pino-pretty.

Niveles de log

const logger = pino({ level: "debug" });

logger.trace("very_detailed");   // level 10
logger.debug("debug_info");      // level 20
logger.info("general_info");     // level 30
logger.warn("warning");          // level 40
logger.error("error_occurred");  // level 50
logger.fatal("crash");           // level 60

Integración con Express

const express = require("express");
const pino = require("pino");
const pinoHttp = require("pino-http");

const logger = pino({ level: "info" });
const app = express();

app.use(pinoHttp({ logger }));

app.get("/api/users/:id", (req, res) => {
  req.log.info({ user_id: req.params.id }, "fetching_user");
  res.json({ id: req.params.id, name: "Alice" });
});

app.listen(3000);

pino-http es el middleware que lo conecta: npm install pino-http.

Integración con Fastify

const fastify = require("fastify")({
  logger: {
    level: "info",
    transport: process.env.NODE_ENV === "development"
      ? { target: "pino-pretty" }
      : undefined,
  },
});

fastify.get("/api/users/:id", async (request, reply) => {
  request.log.info({ user_id: request.params.id }, "fetching_user");
  return { id: request.params.id, name: "Alice" };
});

fastify.listen({ port: 3000 });

Fastify tiene pino integrado — no necesita configuración extra.

Serializers personalizados

const logger = pino({
  serializers: {
    req(req) {
      return {
        method: req.method,
        url: req.url,
        headers: { "user-agent": req.headers["user-agent"] },
      };
    },
    res(res) {
      return { statusCode: res.statusCode };
    },
    err(err) {
      return { type: err.constructor.name, message: err.message, stack: err.stack };
    },
  },
});

logger.info({ req: { method: "GET", url: "/api/users", headers: { "user-agent": "curl/8.0" } } }, "request_received");

File transport con rotación

const logger = pino({
  transport: {
    targets: [
      {
        target: "pino/file",
        options: { destination: "./logs/app.log", mkdir: true },
        level: "info",
      },
      {
        target: "pino/file",
        options: { destination: "./logs/errors.log", mkdir: true },
        level: "error",
      },
    ],
  },
});

logger.info("goes_to_app_log");
logger.error("goes_to_both_logs");

Para rotación, usa pino-roll:

const logger = pino({
  transport: {
    target: "pino-roll",
    options: {
      file: "./logs/app",
      frequency: "daily",
      mkdir: true,
    },
  },
});

Redacción de campos sensibles

const logger = pino({
  redact: {
    paths: ["password", "api_key", "token", "headers.authorization", "*.credit_card"],
    censor: "[REDACTED]",
  },
});

logger.info({ user: { email: "alice@example.com", password: "secret123" } }, "user_login");
// Output: {"user": {"email": "alice@example.com", "password": "[REDACTED]"}, ...}

Logging de errores con stack traces

const logger = pino();

try {
  JSON.parse("{invalid}");
} catch (err) {
  logger.error({ err }, "json_parse_failed");
}

pino serializa automáticamente la clave err con type, message y stack. Para reporting de errores en producción con agrupación y alertas, combínalo con Sentry error tracking.

Variants

Usar pino con TypeScript

import pino from "pino";

const logger = pino({
  level: "info",
  base: { service: "user-api", version: "1.0.0" },
});

interface UserLogData {
  user_id: number;
  action: string;
}

function logUserAction(data: UserLogData): void {
  logger.info(data, "user_action");
}

Usar pino con OpenTelemetry

const { trace, context } = require("@opentelemetry/api");
const logger = pino();

function logWithTrace(msg, data = {}) {
  const span = trace.getSpan(context.active());
  const traceId = span?.spanContext().traceId;
  const spanId = span?.spanContext().spanId;
  logger.info({ ...data, trace_id: traceId, span_id: spanId }, msg);
}

Explicación

pino saca su velocidad de dos decisiones de diseño. Primero, escribe JSON delimitado por líneas a stdout de forma sincrónica a través de un stream mínimo — no hay un pipeline de formateo entre logger.info() y la escritura. Segundo, los transports corren en un worker thread, así que enviar logs a archivos o servicios externos nunca bloquea tu event loop.

La contrapartida es que pino pide pensar distinto a Winston — menos en formatters, más en adónde va cada línea. Dos puntos de entrada importan:

  • pino.destination() — un stream sincrónico en bruto. La opción más rápida, pero con una sola salida.
  • transport — corre el target (pino-pretty, pino-roll, shippers remotos) en un worker thread. Algo más lento por llamada, pero puedes repartir a varios targets sin frenar la app.

child() es barato: el nuevo logger comparte el stream del padre y simplemente fusiona los campos vinculados en cada línea. Es justo lo que hace pino-http por dentro — un child por request, sin que nadie repita request_id a mano.

pino-pretty es la excepción que confirma la regla: como transport inline formatea cada línea de forma sincrónica, así que sirve en desarrollo y perjudica bajo carga de producción. Si necesitas logs legibles en local sin coste dentro de la app, usa un pipe: node app.js | npx pino-pretty.

Un detalle que confunde a muchos: logger.info("msg", { data }) parece que debería funcionar — Winston acepta ese orden — pero pino trata el segundo argumento como interpolación estilo printf para el string del mensaje. Sin un placeholder %j, tus campos se descartan en silencio. Siempre logger.info({ data }, "msg").

Best Practices

  • Para rotación diaria de archivos, mira Rotate Logs Daily with Winston. Para código ejecutable, descarga los ejemplos del companion (config del logger, apps Express y Fastify).

  • Usa pino-pretty solo en desarrollo — añade overhead en producción

  • Usa child loggers para el contexto de la request en lugar de repetir campos en cada llamada

  • Establece level con una variable de entorno: level: process.env.LOG_LEVEL || "info"

  • Usa paths de redact para eliminar campos sensibles — no confíes en que los desarrolladores lo recuerden

  • Usa serializers para objetos complejos (req, res, err) y controlar qué se registra

  • Envía JSON a stdout en producción — deja que un colector de logs maneje la escritura a archivo y la rotación

  • Usa pino-http para Express o el logging integrado de Fastify para logs automáticos de request/response

Common Mistakes

  • Usar pino-pretty en producción: es sincrónico y bloquea el event loop. Solo úsalo en desarrollo.
  • No usar child loggers: repetir request_id en cada logger.info() es propenso a errores. Vincula una vez con child().
  • Loguear objetos grandes: pino serializa sincrónicamente. Loguear un objeto de 10MB bloquea el event loop. Usa serializers para recortarlos.
  • No establecer level: el default es info. Si necesitas debug en staging, establécelo con una variable de entorno.
  • Usar console.log junto a pino: console.log escribe texto no estructurado a stdout, mezclándolo con el JSON de pino. Elimina todos los console.log.

Preguntas frecuentes

¿Qué tan rápido es pino comparado con Winston?

En benchmarks, pino es 3-10x más rápido que Winston para logging JSON sincrónico. La diferencia viene de la escritura sincrónica de pino con formatting diferido vs la cadena async de Winston.

¿Cómo envío logs de pino a Elasticsearch?

Dirige stdout a un colector de logs (Filebeat, Fluent Bit, Logstash). El formato JSON ya está estructurado — sin reglas de parsing:

node app.js | filebeat --config=filebeat.yml
¿Puedo usar pino con async/await?

Sí. pino mismo es sincrónico (sin I/O async en la ruta crítica), así que llamar a logger.info() dentro de una función async es una escritura sincrónica más — nada que esperar con await.

¿Cómo agrego un middleware de request ID con pino?
const { randomUUID } = require("crypto");

app.use((req, res, next) => {
  req.id = req.headers["x-request-id"] || randomUUID();
  req.log = logger.child({ request_id: req.id });
  next();
});
¿Cuál es la diferencia entre pino transports y pino destinations?

Destinations son writable streams (rápidos, sincrónicos). Transports corren en un worker thread (async, y pueden repartir a stdout más archivos o servicios remotos a la vez). Usa destinations para velocidad pura, transports para rutear a archivos o servicios externos.

¿Cómo redacto campos sensibles en logs de pino?

Usa la opción redact en la configuración de pino. Especifica paths a remover o censurar: redact: { paths: ['req.headers.authorization', 'password', '*.token'], remove: false }. Con remove: false reemplaza el valor con [Redacted] (el censor por defecto — el ejemplo de arriba muestra uno personalizado). Con remove: true elimina la clave completamente del log.