Як реалізувати логування в Express.js (Morgan, Winston)?
Логування (logging) в Express.js будується на двох бібліотеках з двома різними задачами: Morgan перехоплює кожен HTTP-запит автоматично; Winston логує те, що твій код явно йому передає.
Теорія
TL;DR
- Morgan = логер HTTP-запитів (відеокамера, що фіксує кожного відвідувача)
- Winston = логер застосунку (блокнот для записів того, що сталося всередині)
- Morgan - це middleware: запускається на кожен запит без додаткового коду в роутах
- Winston викликається явно:
logger.info(),logger.error() - Правило вибору: Morgan для HTTP-трафіку; Winston для помилок, бізнес-логіки і всього іншого
Швидкий приклад
const express = require('express');
const morgan = require('morgan');
const winston = require('winston');
const app = express();
// Morgan логує кожен HTTP-запит автоматично
app.use(morgan('combined'));
// Winston логує тільки те, що ти явно скажеш
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [new winston.transports.File({ filename: 'app.log' })]
});
app.get('/users/:id', (req, res) => {
logger.info(`Fetching user ${req.params.id}`); // Ти вирішуєш, коли це писати
res.json({ id: req.params.id });
});
// Вивід Morgan: GET /users/123 200 5ms
// Вивід Winston: {"level":"info","message":"Fetching user 123"}Morgan зафіксував HTTP-транзакцію. Winston зафіксував бізнес-подію. Вони нічого не знали одне про одного.
Головна різниця
Morgan - це middleware: підключається через app.use() і пише рядок логу після того, як відповідь відправлена. Код в роутах для цього не потрібен. Winston - це бібліотека, яку викликаєш вручну. Якщо не написав logger.info(...), нічого не залогується. Morgan відповідає на питання "який HTTP-трафік прийшов на сервер?"; Winston - "що зробив код застосунку?"
Коли що використовувати
- Тільки Morgan: прості API, середовище розробки, коли достатньо логів HTTP-трафіку
- Тільки Winston: CLI-інструменти, фонові задачі, сервіси без HTTP
- Обидва разом: будь-який production-застосунок - більшість випадків
- Morgan + кастомний токен: коли потрібно логувати тіло запиту, userId або кастомні заголовки
- Winston + кілька транспортів: коли логи мають іти у файли і до зовнішніх сервісів (Datadog, CloudWatch, Sentry)
Порівняння
| Аспект | Morgan | Winston |
|---|---|---|
| Що логує | HTTP-запити/відповіді | Події застосунку, помилки, кастомні повідомлення |
| Як використовується | Middleware (app.use()) | Прямі виклики (logger.info(), logger.error()) |
| Автоматично? | Так, кожен запит | Ні, ти вирішуєш що логувати |
| Формат виводу | Готові (dev, combined, tiny) або кастомний | Повністю конфігурується (JSON, текст тощо) |
| Вплив на продуктивність | Мінімальний | Залежить від транспортів (файловий I/O асинхронний за замовчуванням) |
| Коли використовувати | Відстеження HTTP-трафіку | Помилки, бізнес-логіка, налагодження |
Як це працює
Morgan підключається до циклу відповіді Express. На кожен запит він чекає події res.on('finish') і тільки тоді пише рядок логу. Тому в записі є фінальний статус-код і розмір відповіді. Нічого не блокується.
Winston влаштований інакше. Коли викликаєш logger.info(), Winston форматує повідомлення і передає кожному налаштованому транспорту. Файлові транспорти використовують fs.createWriteStream() з буферизованими записами за замовчуванням. Якщо транспорт повільний (наприклад, HTTP-запит до зовнішнього сервісу), Winston ставить повідомлення в чергу замість того, щоб блокувати обробник роуту.
Типові помилки
Помилка 1: логування чутливих даних
// Неправильно: паролі й токени потраплять у файл логів
morgan.token('body', (req) => JSON.stringify(req.body));
app.use(morgan(':method :url :body'));
// Правильно: фільтруй чутливі поля перед логуванням
morgan.token('body', (req) => {
const { password, token, ...safe } = req.body || {};
return JSON.stringify(safe);
});
app.use(morgan(':method :url :body'));Хто отримає доступ до файлів логів - отримає паролі. Це не параноя, це реальний вектор атаки.
Помилка 2: синхронні записи у файл
// Неправильно: кожен запис блокує весь event loop
const logger = winston.createLogger({
transports: [new winston.transports.File({ filename: 'app.log', sync: true })]
});
// Правильно: async - це поведінка за замовчуванням, просто не встановлюй sync: true
const logger = winston.createLogger({
transports: [new winston.transports.File({ filename: 'app.log' })]
});Одна повільна операція запису з sync: true додає 10-100 мс затримки до кожного запиту.
Помилка 3: неправильні рівні логування
// Неправильно: все на рівні "info"
logger.info('Користувач увійшов');
logger.info('Не вдалося підключитися до БД'); // Це ж помилка!
logger.info('Cache miss для ключа xyz'); // Це шум для debug
// Правильно: рівень відповідає реальній важливості
logger.info('Користувач увійшов');
logger.error('Не вдалося підключитися до БД');
logger.debug('Cache miss для ключа xyz');В production встановлюють level: 'error' щоб відфільтрувати шум. Якщо помилки залоговані на рівні info, вони зникнуть разом з усім іншим під час фільтрації.
Помилка 4: створення нового логера на кожен запит
// Неправильно: відкриває нові файлові дескриптори на кожному запиті
app.get('/api/data', (req, res) => {
const logger = winston.createLogger({ /* config */ });
logger.info('Обробка запиту');
res.json({});
});
// Правильно: створити один раз на рівні модуля
const logger = winston.createLogger({ /* config */ });
app.get('/api/data', (req, res) => {
logger.info('Обробка запиту');
res.json({});
});При великому трафіку швидко досягаєш ліміту ОС і отримуєш "EMFILE: too many open files" - застосунок падає.
Помилка 5: логування health check запитів
// Неправильно: моніторинг пінгує /health кожні 5 секунд, засмічує логи
app.use(morgan('combined'));
// Правильно: ігноруй запити, які тебе не цікавлять
app.use(morgan('combined', {
skip: (req) => req.path === '/health'
}));Health check раз на 5 секунд - це 17 000 зайвих рядків у логах за день.
Де застосовується
- Звичайні Express API: Morgan для HTTP, Winston для помилок і бізнес-подій
- Next.js: Morgan в API routes, Winston для серверного логування
- AWS Lambda: Winston з CloudWatch transport
- Docker/Kubernetes: вивід у stdout, платформа сама маршрутизує в ELK або Datadog
- NestJS: команди часто додають Winston поверх вбудованого логера для збереження у файли
Питання на співбесіді
Q: Чому Morgan чекає завершення відповіді перед тим як писати лог?
A: Бо Morgan записує HTTP-статус і розмір відповіді, які невідомі поки відповідь не відправлена. Він підписується на res.on('finish') і пише рядок тільки після цього.
Q: Як виключити запити до /health з логів Morgan?
A: Через опцію skip: app.use(morgan('combined', { skip: (req) => req.path === '/health' })). Без цього інструменти моніторингу, які пінгують endpoint кожні кілька секунд, засмічують логи.
Q: Яка різниця між format.json() і format.simple() у Winston?
A: json() виводить структурований лог (один JSON-об'єкт на рядок), який зручно парсити сервісам агрегації. simple() виводить читабельний текст. Для локальної розробки підходить simple(), для production - json().
Q: Як переконатися, що логи записані перед завершенням процесу?
A: Викликати logger.close() в обробнику завершення: process.on('SIGTERM', () => { logger.close(); process.exit(0); }). Без цього буферизовані повідомлення можуть ніколи не потрапити у файл.
Q: Як пов'язати логи між мікросервісами?
A: Генеруєш унікальний ID запиту (UUID) на вхідній точці, передаєш його через HTTP-заголовки у всі сервіси і включаєш у кожен лог Winston. Інструменти на кшталт Datadog або ELK групують записи за цим ID. Цей підхід називається distributed tracing.
Q (рівень senior): API обробляє 100k запитів на секунду, Morgan дає 15% навантаження на CPU. Як вирішити?
A: Кілька варіантів у порядку ефективності. Перейти на morgan('tiny') щоб зменшити обсяг форматування. Семплювати запити: skip: () => Math.random() > 0.1 логує тільки 10%. Замінити Winston на Pino - значно швидше для структурованого логування. Перенести логування в окремий процес через Redis або Kafka. Або взагалі вимкнути Morgan і покластися на логи reverse proxy (Nginx, Cloudflare). Правильна відповідь залежить від того, яка точність логів тобі насправді потрібна.
Приклади
Базове налаштування: Morgan і Winston разом
const express = require('express');
const morgan = require('morgan');
const winston = require('winston');
const app = express();
app.use(express.json());
// HTTP-логування (автоматичне)
app.use(morgan('dev'));
// Логування застосунку (ручне)
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/app.log' })
]
});
app.get('/users/:id', (req, res) => {
logger.info('Fetching user', { userId: req.params.id });
res.json({ id: req.params.id, name: 'Alice' });
});
app.listen(3000, () => logger.info('Server started', { port: 3000 }));
// Morgan виводить: GET /users/42 200 3.456 ms - 28
// Winston записує: {"level":"info","timestamp":"...","message":"Fetching user","userId":"42"}Morgan запрацював без єдиного рядка в обробнику роуту. Winston залогував тільки те, що ти йому сказав.
Production-налаштування: ротація логів і обробка помилок
const express = require('express');
const morgan = require('morgan');
const winston = require('winston');
require('winston-daily-rotate-file');
const app = express();
app.use(express.json());
// Щоденна ротація: зберігає 14 днів, максимум 20 МБ на файл
const transport = new winston.transports.DailyRotateFile({
filename: 'logs/application-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '14d'
});
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [transport]
});
// Перенаправляємо вивід Morgan через Winston - один файл для всього
const morganStream = { write: (msg) => logger.http(msg.trim()) };
app.use(morgan('combined', { stream: morganStream }));
app.post('/api/orders', (req, res) => {
try {
logger.info('Order created', { userId: req.user?.id, orderId: req.body.id });
res.json({ success: true });
} catch (err) {
logger.error('Order creation failed', { error: err.message, stack: err.stack });
res.status(500).json({ error: 'Failed' });
}
});
// Скидаємо буфер перед завершенням процесу
process.on('SIGTERM', () => { logger.close(); process.exit(0); });Коли Morgan виводить через Winston - всі логи, і HTTP, і застосунку, йдуть в один файл. Одна команда grep покриває все під час розбору production-інциденту.
Відстеження запиту за унікальним ID
const express = require('express');
const morgan = require('morgan');
const winston = require('winston');
const { v4: uuidv4 } = require('uuid');
const app = express();
const logger = winston.createLogger({
format: winston.format.json(),
transports: [new winston.transports.Console()]
});
// Прикріплюємо унікальний ID до кожного запиту
app.use((req, res, next) => {
req.id = uuidv4();
res.setHeader('X-Request-ID', req.id);
next();
});
// Включаємо ID в логи Morgan
morgan.token('request-id', (req) => req.id);
app.use(morgan(':request-id :method :url :status :response-time ms'));
// Дочірній логер автоматично додає requestId до кожного запису
app.use((req, res, next) => {
req.logger = logger.child({ requestId: req.id });
next();
});
app.get('/api/data', (req, res) => {
req.logger.info('Fetching data'); // {"message":"Fetching data","requestId":"abc-123"}
res.json({ ok: true });
});
// Morgan: abc-123 GET /api/data 200 4.5 ms
// Winston: {"message":"Fetching data","requestId":"abc-123"}
// Обидва мають однаковий ID - один пошук знаходить всеКоли запит падає в production - шукаєш за request ID і бачиш усі записи, і від Morgan, і від Winston, які до нього відносяться. Без цього доводиться вгадувати по таймстемпах.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.