Skip to main content

Як реалізувати логування в 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 для помилок, бізнес-логіки і всього іншого

Швидкий приклад

js
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)

Порівняння

АспектMorganWinston
Що логує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: логування чутливих даних

js
// Неправильно: паролі й токени потраплять у файл логів 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: синхронні записи у файл

js
// Неправильно: кожен запис блокує весь 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: неправильні рівні логування

js
// Неправильно: все на рівні "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: створення нового логера на кожен запит

js
// Неправильно: відкриває нові файлові дескриптори на кожному запиті 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 запитів

js
// Неправильно: моніторинг пінгує /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 разом

js
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-налаштування: ротація логів і обробка помилок

js
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

js
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, які до нього відносяться. Без цього доводиться вгадувати по таймстемпах.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?