lenec ru

← все посты

Логирование в Node: Pino vs Winston на практике

12K

Логи — первое, к чему я лезу, когда что-то поломалось. Поэтому к их формату и инструменту отношусь придирчиво. На моих проектах за последние пять лет были и Winston, и Pino, и встроенный console.log. Расскажу, в каких сценариях я что выбираю и почему сейчас по умолчанию беру Pino.

Что хочу от логгера

Чтобы было понятно, что мы сравниваем, опишу мой минимальный набор требований:

  • Структурированные логи в JSON: чтобы поиск по полям, а не grep по строкам.
  • Корреляция: requestId и userId ездят сквозь всё дерево вызовов.
  • Уровни и фильтрация по ним.
  • Производительность: на нагрузках 10+ тысяч записей в секунду логгер не должен есть половину CPU.
  • Транспорт в prod: stdout с подбором JSON-парсером (vector, fluent-bit).

Pino: что нравится

Pino — производительный, компактный JSON-логгер. Принцип: пишет в stdout как можно быстрее, форматирование делегирует «pretty»-транспортам.

import pino from 'pino';

export const log = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  base: {
    service: 'api',
    env: process.env.NODE_ENV,
  },
  redact: {
    paths: ['req.headers.authorization', 'req.headers.cookie', 'password'],
    censor: '[REDACTED]',
  },
  timestamp: pino.stdTimeFunctions.isoTime,
});

Что я ценю:

  • Скорость. Pino пишет JSON напрямую, никаких колбэков с форматерами на горячем пути. На бенчмарках 5–10x быстрее Winston.
  • Redact. Поля паролей и заголовков можно скрыть на уровне логгера. Меньше шансов случайно записать в лог токен.
  • Базовый JSON. Никаких форматеров на проде, в stdout сразу JSON, который читается любым агрегатором.
  • Дочерние логгеры. log.child({ requestId }) — стандарт на каждый запрос. Все записи внутри запроса несут id, искать просто.

Что не нравится:

  • Pretty-режим — отдельный транспорт. На локальной разработке хочется цветной читаемый лог. Pino делает это через pino-pretty, которое нужно ставить отдельно. Решается, но запоминается.
  • Транспорты как отдельный поток. Pino отдаёт логи через worker_threads. На бенчах это плюс, в реальности иногда это «как наладить отправку в Sentry на ходу» — отдельная история.

Winston: где он нормально работает

Winston — старший товарищ. Гибкий, расширяемый, с десятками транспортов. Если нужно «логировать в файл, в Slack, в email и в Sentry одной командой», Winston вытянет, и Pino — нет.

import winston from 'winston';

export const log = winston.createLogger({
  level: process.env.LOG_LEVEL ?? 'info',
  defaultMeta: { service: 'api' },
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json(),
  ),
  transports: [
    new winston.transports.Console(),
  ],
});

Что мне в нём не хватает:

  • Производительность. На нагрузке вёрстки логов через format.combine() всё-таки заметно медленнее Pino.
  • Сложность конфигурации. Format.combine с printf, splat, ms — много кода ради того, что Pino делает по умолчанию.
  • Объекты Error по умолчанию пишутся не полностью. Нужно подключать format.errors({ stack: true }), иначе stack не попадает в лог.

Сильная сторона Winston — гибкость транспортов. Если задача «класть всё в три разные места одновременно с разными уровнями» — Winston прекрасен.

Bunyan и нативный console

Bunyan — старый JSON-логгер, концептуально близкий к Pino. Все его плюсы у Pino уже есть, плюс производительность лучше. На новых проектах я Bunyan не беру.

console.log вполне годится в очень маленьких проектах и в скриптах. Для веб-приложения с трафиком — нет. Слабая структуризация, нет уровней, нет redact, нет дочерних логгеров.

Структурированные логи в Node 22+

В свежих версиях Node добавилась встроенная утилита node:util.styleText и улучшенный console, но полноценного логгера в core по-прежнему нет. Внешние решения остаются актуальными.

Корреляция с requestId

На API-приложении я обязательно делаю middleware, который:

  1. Достаёт X-Request-Id из заголовка или генерит новый.
  2. Кладёт в AsyncLocalStorage.
  3. Привязывает к log.child.
import { AsyncLocalStorage } from 'node:async_hooks';
import pino from 'pino';
import { randomUUID } from 'node:crypto';

const als = new AsyncLocalStorage<{ requestId: string; userId?: string }>();
const rootLog = pino({ level: process.env.LOG_LEVEL ?? 'info' });

export function withRequestContext(req: any, res: any, next: any) {
  const requestId = req.headers['x-request-id']?.toString() ?? randomUUID();
  res.setHeader('x-request-id', requestId);
  als.run({ requestId }, next);
}

export function log() {
  const ctx = als.getStore();
  return ctx ? rootLog.child(ctx) : rootLog;
}

В коде:

log().info({ orderId }, 'order created');
log().error({ err }, 'failed to charge card');

В каждой записи будет requestId, и любой запрос восстанавливается по логу одним grep-ом.

Логи HTTP-запросов

Не пиши руками log.info('GET /api/...'). Возьми pino-http или middleware-сервис фреймворка. Он сам положит метод, URL, статус, длительность, requestId.

import pinoHttp from 'pino-http';

app.use(pinoHttp({
  logger: rootLog,
  customLogLevel: (req, res) => {
    if (res.statusCode >= 500) return 'error';
    if (res.statusCode >= 400) return 'warn';
    return 'info';
  },
  customSuccessMessage: (req, res) => `${req.method} ${req.url} ${res.statusCode}`,
}));

Что обязательно redact-ить

  • req.headers.authorization — токены.
  • req.headers.cookie — сессии.
  • Поля password, token, secret, apiKey в любых telemetry-объектах.
  • В платёжных интеграциях — cardNumber, cvv, любые PAN.
  • В личных кабинетах — паспорт, СНИЛС, ИНН (по 152-ФЗ).

Pino redact работает с глубокими путями типа req.body.user.password. Лучше написать список redact-полей один раз и потом расширять, чем потом разбирать утечку из-за того, что в логе остался токен.

Уровни

Я держу простую шкалу:

  • fatal — приложение не может продолжать. Минута до падения.
  • error — операция не удалась, нужно разобраться.
  • warn — что-то странное, но операция продолжилась.
  • info — заметные события: пользователь зарегистрировался, заказ создан.
  • debug — диагностика, отключено в проде.

В проде уровень info. На дев — debug. Чтобы поднять уровень в проде на час, я не пересобираю приложение, а делаю SIGHUP/restart с другим LOG_LEVEL — это даёт «временный telnet» без танцев с зависимостями.

Производительность: что я мерил

Один и тот же сценарий: HTTP-эндпоинт, в котором 10 log-вызовов на запрос, нагрузка 5000 RPS.

  • Pino: CPU ~12% на логи, медиана ответа +0.4 мс.
  • Winston: CPU ~38% на логи, медиана ответа +1.6 мс.

Разница на простом сценарии не катастрофическая, но на нагруженном API заметна. Особенно видно, когда в Winston используется человеко-читаемый формат с printf — он на горячем пути буквально жгёт CPU.

Куда отправлять логи

На VPS я обычно пишу в stdout, journald их подбирает, и оттуда vector или fluent-bit пересылает в общее хранилище (Loki, Elasticsearch, ClickHouse). Это даёт:

  • Никаких сетевых вызовов в горячем пути.
  • Если упало — логи от падения остаются в journald до restart.
  • Конфиг прометрика и алертов отдельно от приложения.

В контейнерных окружениях рекомендуется тот же подход: писать в stdout, всё остальное делает sidecar/agent.

Шпаргалка для себя

  • Новый проект — Pino, JSON, child-логгеры.
  • requestId через AsyncLocalStorage.
  • HTTP-логи через pino-http или эквивалент.
  • Redact обязателен, список расширяется по факту.
  • В проде stdout, агент пересылает.
  • Pretty-формат только в dev через pino-pretty.
  • Winston — для проектов, где нужны множественные транспорты с гибкими форматами.

Логи — это не место, где стоит экономить. Хорошая инфраструктура логов окупается за первые два инцидента, когда ты вместо часа в исходниках за 5 минут видишь, что произошло. И главное — не думай, что Pino быстрый «по бумажке»: на длинных профилях это реально заметная экономия CPU, особенно в API с большим объёмом мелких событий.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.