Hướng dẫn Winston: Logging chuyên nghiệp cho ứng dụng Node.js

VMas-Dev-KA

Logging chuyên nghiệp với Winston trong Node.js

Tại sao console.log là ‘kẻ thù’ của Production?

So sánh console.log không phân cấp và log có cấu trúc với Winston

Trong quá trình phát triển, console.log là người bạn thân thiết giúp bạn debug nhanh. Nhưng khi ứng dụng chạy trên production, nó trở thành cơn ác mộng vì:

  • Không phân cấp độ: Bạn không thể lọc riêng lỗi nghiêm trọng (error) ra khỏi các dòng log thông thường (info).
  • Không cấu trúc: Log chỉ là chuỗi văn bản thuần, rất khó để máy phân tích (ví dụ gửi vào ELK, Splunk).
  • Không lưu trữ: console.log chỉ in ra stdout – nếu không có hệ thống gom log (như PM2 hay systemd), bạn sẽ mất sạch log khi restart ứng dụng.
  • Hiệu năng kém khi log quá nhiều dữ liệu lớn.

Nguyên tắc quan trọng: Production cần một giải pháp logging chuyên nghiệp, có cấu trúc, config linh hoạt và an toàn.

Winston là gì? Các thành phần: Logger, Transports, Formats

Winston là thư viện logging phổ biến nhất trong hệ sinh thái Node.js (hơn 22k GitHub stars). Nó được thiết kế theo kiến trúc transport – mỗi transport là một đích đến khác nhau cho log của bạn.

Ba thành phần chính:

  1. Logger – Đối tượng chính bạn dùng để ghi log: logger.info('message'), logger.error('message').
  2. Transports – Nơi log được gửi đến. Các transport có sẵn: Console, File, HTTP. Ngoài ra có thể mở rộng thêm (ví dụ ghi vào MongoDB, Elasticsearch).
  3. Formats – Định dạng log trước khi ghi. Winston hỗ trợ kết hợp (combine) nhiều format: timestamp, json, printf, colorize, simple

Tham khảo nguồn chính thức: Theo Winston GitHub, bạn có thể ghi log ra nhiều nơi đồng thời mà không cần thay đổi code logic.

Cài đặt và cấu hình bộ Logger tiêu chuẩn

Cấu trúc thư mục logs với file combined và error xoay vòng theo ngày

Hãy bắt đầu bằng việc tạo một logger có khả năng:

  • Ghi ra console (có màu sắc cho môi trường dev)
  • Ghi ra file combined.log (tất cả các level log)
  • Ghi ra file error.log (chỉ level error trở lên)
  • Sử dụng format JSON + timestamp – cực kỳ hữu ích sau này tích hợp với ELK Stack.

Bước 1: Cài đặt

npm install winston
# Nếu dùng log rotation thêm:
npm install winston-daily-rotate-file

Bước 2: Tạo thư mục logs (quan trọng)

Trước khi chạy ứng dụng, hãy đảm bảo thư mục logs tồn tại. Winston không tự động tạo thư mục. Bạn có thể tạo thủ công hoặc thêm đoạn code dưới đây vào đầu file cấu hình:

const fs = require('fs');
if (!fs.existsSync('logs')) {
  fs.mkdirSync('logs');
}

Bước 3: Tạo file logger.js hoàn chỉnh

const winston = require('winston');
const DailyRotateFile = require('winston-daily-rotate-file');
const fs = require('fs');

// Đảm bảo thư mục logs tồn tại
if (!fs.existsSync('logs')) {
  fs.mkdirSync('logs');
}

// Định nghĩa format tùy chỉnh
const { combine, timestamp, json, printf, colorize } = winston.format;

// Format cho console (người đọc dễ nhìn)
const consoleFormat = combine(
  colorize(),
  timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
  printf(({ level, message, timestamp }) => {
    return `${timestamp} [${level}]: ${message}`;
  })
);

// Format cho file (JSON - dành cho máy đọc)
const fileFormat = combine(
  timestamp(),
  json()
);

// Tạo các transport xoay vòng hàng ngày
const transportCombined = new DailyRotateFile({
  filename: 'logs/combined-%DATE%.log',
  datePattern: 'YYYY-MM-DD',
  maxSize: '20m',       // tối đa 20MB mỗi file
  maxFiles: '14d',      // giữ log trong 14 ngày
  format: fileFormat,
  level: 'info'         // ghi từ info trở lên
});

const transportError = new DailyRotateFile({
  filename: 'logs/error-%DATE%.log',
  datePattern: 'YYYY-MM-DD',
  maxSize: '20m',
  maxFiles: '30d',      // giữ lỗi lâu hơn (30 ngày)
  format: fileFormat,
  level: 'error'        // chỉ ghi error và fatal
});

// Tạo logger
const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',  // có thể ghi đè bằng biến môi trường
  transports: [
    transportCombined,
    transportError,
    new winston.transports.Console({
      format: consoleFormat,
      level: process.env.NODE_ENV === 'production' ? 'info' : 'debug'
    })
  ],
  // Ghi lại các exception không bắt được (uncaughtException)
  // LƯU Ý: Sau khi log, process nên exit vì app ở trạng thái không ổn định
  exceptionHandlers: [
    new winston.transports.File({ filename: 'logs/exceptions.log' })
  ]
});

module.exports = logger;

Giải thích code

  • DailyRotateFile giải quyết vấn đề log quá lớn gây tràn ổ cứng – tự động xoay vòng theo ngày, giới hạn kích thước và thời gian lưu trữ.
  • Phân tách error và combined giúp bạn nhanh chóng tìm ra lỗi nghiêm trọng mà không cần grep qua cả tấn log.
  • Format JSON cho file: chuẩn hóa đầu ra để các công cụ như Logstash, Fluentd có thể parse dễ dàng.
  • Console format màu sắc chỉ dùng trong môi trường development (dựa theo NODE_ENV).
  • exceptionHandlers chỉ bắt uncaughtException. Với unhandledRejection, bạn vẫn cần xử lý riêng (xem phần tích hợp Express).

Sử dụng logger cơ bản

const logger = require('./logger');

logger.info('Server started on port 3000');
logger.warn('Database connection slow');
logger.error('Failed to fetch user data', { userId: 123, error: 'timeout' });
// Log đối tượng phức tạp sẽ tự động được JSON.stringify
logger.debug('Debug info – chỉ hiện khi LOG_LEVEL=debug');

Tích hợp Winston với Express thông qua Middleware

Một ứng dụng Express thường cần ghi log tất cả request đến (HTTP method, URL, status code, thời gian xử lý). Hãy tạo middleware riêng để tận dụng logger đã cấu hình.

Luồng request đi qua middleware HTTP logger rồi vào route handler

Ví dụ: middleware/logger.js

const logger = require('../logger');

function httpLogger(req, res, next) {
  const start = Date.now();

  // Ghi log khi response kết thúc
  res.on('finish', () => {
    const duration = Date.now() - start;
    const message = `${req.method} ${req.originalUrl} ${res.statusCode} - ${duration}ms`;

    // Lấy IP thực tế nếu chạy sau proxy (nginx, load balancer)
    const realIp = req.headers['x-forwarded-for'] || req.ip || req.connection.remoteAddress;

    // Phân loại level theo status code
    if (res.statusCode >= 500) {
      logger.error(message, { ip: realIp, userAgent: req.get('User-Agent') });
    } else if (res.statusCode >= 400) {
      logger.warn(message);
    } else {
      logger.info(message);
    }
  });

  next();
}

module.exports = httpLogger;

Lưu ý với proxy: Nếu ứng dụng chạy sau nginx hoặc load balancer, bạn cần kích hoạt app.set('trust proxy', true) trong Express để req.ip trả về IP thật.

Sử dụng trong app.js

const express = require('express');
const logger = require('./logger');
const httpLogger = require('./middleware/logger');

const app = express();

// Nếu chạy sau proxy, bật trust proxy
app.set('trust proxy', true);

// Middleware log request – nên đặt ngay đầu
app.use(httpLogger);

// Ví dụ route có lỗi
app.get('/user/:id', async (req, res) => {
  try {
    const user = await getUserFromDB(req.params.id);
    res.json(user);
  } catch (err) {
    // Ghi log lỗi chi tiết kèm context
    logger.error(`Error fetching user ${req.params.id}`, { error: err.message, stack: err.stack });
    res.status(500).json({ error: 'Internal Server Error' });
  }
});

// Bắt lỗi unhandled rejection (Promise rejection không bắt)
process.on('unhandledRejection', (reason, promise) => {
  logger.error('Unhandled Rejection at:', { promise, reason });
  // Không nên tiếp tục chạy app nếu lỗi nghiêm trọng, có thể process.exit(1)
});

app.listen(3000, () => {
  logger.info('Server running on port 3000');
});

Liên quan đến nội bộ: Sau khi bắt được lỗi, hãy ghi log chi tiết như ví dụ trên. Để hiểu rõ hơn cách xử lý lỗi chuyên nghiệp trước khi log, bạn có thể tham khảo bài viết Xử lý lỗi trong Node.js chuyên nghiệp – logging là bước kế tiếp sau khi đã bắt và phân loại lỗi.

Lỗi thường gặp và cách khắc phục

1. Không cấu hình xoay vòng file → ổ cứng đầy sau vài ngày

Nguyên nhân: Ghi log vào một file duy nhất, không giới hạn kích thước.
Cách fix: Dùng winston-daily-rotate-file như trong ví dụ trên. Nếu không muốn thêm thư viện, có thể dùng winston.transports.File kết hợp với cron job, nhưng không khuyến khích.

2. Log nhạy cảm (password, token, credit card)

// ❌ SAI
logger.info('User login', { username, password: plainPassword });

// ✅ ĐÚNG – loại bỏ hoặc mask trường nhạy cảm
const safeUser = { ...user, password: undefined, creditCard: '****' };
logger.info('User login', safeUser);

3. Log quá nhiều trong vòng lặp

// ❌ Ghi 10.000 dòng log chỉ để debug
for (let i = 0; i < 10000; i++) {
  logger.debug(`Processing item ${i}`);
}

// ✅ Ghi tổng hợp hoặc dùng level debug chỉ khi cần
logger.debug(`Processing ${items.length} items`);

4. Quên xử lý ngoại lệ (uncaughtException / unhandledRejection)

Luôn cấu hình exceptionHandlers cho Winston và xử lý riêng unhandledRejection để đảm bảo lỗi crash được ghi lại.

5. Lấy IP sai khi chạy sau proxy

Nguyên nhân: req.ip trả về IP của proxy (127.0.0.1).
Cách fix: Dùng req.headers['x-forwarded-for'] kết hợp app.set('trust proxy', true) như ví dụ trên.

Best practices cho logging chuyên nghiệp

  1. Sử dụng đúng level log

    • error: Lỗi khiến một chức năng hỏng (database disconnect, API trả về 500).
    • warn: Hành vi không mong đợi nhưng app vẫn chạy (dùng API sắp deprecated, request chậm).
    • info: Luồng chính xác (user login, order created).
    • debug: Chi tiết nội bộ – chỉ bật khi cần debug trên môi trường dev hoặc staging.
  2. Không log thông tin nhạy cảm
    Dùng thư viện omit hoặc tạo hàm sanitize để lọc password, token, số thẻ tín dụng trước khi log.

  3. Luôn thêm context – sử dụng child logger
    Thay vì logger.error('Cannot find user'), hãy ghi kèm userId, requestId. Ví dụ với child:

    const childLogger = logger.child({ requestId: 'abc-123', userId: 456 });
    childLogger.info('Processing payment'); // requestId và userId tự động được ghi

    Điều này cực kỳ hữu ích để trace một request xuyên suốt các service.

  4. Sử dụng JSON format cho file/production
    JSON giúp các công cụ phân tích log (ELK, Graylog, Loki) dễ dàng index và tìm kiếm.

  5. Đừng quên log rotation và retention policy
    Giữ log tối thiểu 14–30 ngày tùy nhu cầu pháp lý.

FAQ

Có nên log vào Database không?

, nhưng chỉ dành cho log có tần suất thấp (ví dụ giao dịch quan trọng, audit trail). Không nên ghi trực tiếp mọi request vào database vì:

  • I/O database chậm hơn file rất nhiều, làm nghẽn ứng dụng.
  • Bảng log sẽ phình to, ảnh hưởng đến backup và truy vấn chính.

Giải pháp: Ghi log ra file trước, sau đó dùng một tiến trình riêng (Logstash, Fluentd) để đọc file và insert vào database với tần suất batch.

Winston vs Pino: Cái nào tốt hơn?

  • Winston: Linh hoạt, nhiều transports có sẵn, cộng đồng lớn, dễ cấu hình. Phù hợp cho dự án cần tính đa dạng (log ra file + console + http + …).
  • Pino: Nhanh hơn đáng kể (tối ưu tốc độ, ít overhead), format JSON mặc định, nhưng có ít transports tích hợp. Phù hợp cho ứng dụng hiệu năng cao (microservices, serverless).

Lời khuyên: Nếu bạn cần một hệ thống logging đơn giản, cực nhanh → chọn Pino. Nếu cần tích hợp nhiều đích đến, tùy chỉnh format dễ dàng và có cộng đồng hỗ trợ → chọn Winston.

Kết luận

Thay thế console.log bằng Winston không chỉ giúp bạn kiểm soát log một cách chuyên nghiệp mà còn đặt nền móng cho việc giám sát hệ thống sau này. Với cách cấu hình log ra file JSON + xoay vòng + tích hợp Express middleware + xử lý đúng IP và child logger, bạn đã sẵn sàng cho môi trường production.

Hãy bắt đầu ngay bằng cách tạo file logger.js cho dự án hiện tại, và dần dần thay thế toàn bộ console.log đang tồn tại.

Chia sẻ bài viết này