Логирование в Node: Pino vs Winston на практике
Логи — первое, к чему я лезу, когда что-то поломалось. Поэтому к их формату и инструменту отношусь придирчиво. На моих проектах за последние пять лет были и 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, который:
- Достаёт
X-Request-Idиз заголовка или генерит новый. - Кладёт в AsyncLocalStorage.
- Привязывает к 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 с большим объёмом мелких событий.