Production lúc 3h sáng, khách hàng báo không login được, mình mở CloudWatch và tìm thấy đúng một dòng log: Error: connection refused.

Không có request ID. Không có user ID. Không có service nào gây ra. Không có timestamp trước đó để biết quá trình xảy ra như thế nào. Chỉ có connection refused nằm giữa 50,000 dòng log của 12 service khác nhau trong 30 phút đó. Mình mất 47 phút để tìm ra vấn đề là Redis connection pool bị exhausted — không phải vì mình giỏi debugging, mà vì mình may mắn đoán đúng chỗ để nhìn vào. Log có đấy, nhưng không dùng được.

Đây là vấn đề của unstructured logging — và structured logging giải quyết nó không phải bằng cách log nhiều hơn, mà bằng cách log có thể query được.


Vấn đề của console.log trong production

Unstructured log trông ổn khi đọc bằng mắt trên local. Vấn đề bắt đầu khi deploy lên production với nhiều service, nhiều instance, và cần tìm một sự kiện cụ thể trong hàng chục nghìn dòng log.

[2026-05-02 03:12:44] INFO: User logged in
[2026-05-02 03:12:44] ERROR: connection refused
[2026-05-02 03:12:45] INFO: Request completed in 234ms
[2026-05-02 03:12:45] WARN: Slow query detected

Những dòng này grep được — nhưng không query được. Muốn tìm tất cả lỗi của user u_8af2c3 trong 10 phút qua? Không có field để filter. Muốn tìm tất cả request chậm hơn 500ms kèm request ID? Phải parse string bằng regex. Muốn correlate log giữa 3 service trong cùng một request? Không có trace ID, không làm được.

Vấn đề sâu hơn là log không có schema — mỗi developer tự format theo ý mình, không nhất quán. Sau 6 tháng, 10 developer tạo ra 10 format log khác nhau trong cùng một codebase (mình đã đếm thật).


JSON structured log — mọi field đều queryable

Hiểu nôm na thì structured log là log theo schema cố định — thay vì string tự do, mỗi dòng log là một JSON object với các field được định nghĩa rõ ràng. Tool như CloudWatch Logs Insights, Loki, hay Elasticsearch có thể query trực tiếp trên từng field mà không cần parse.

{
  "timestamp": "2026-05-02T03:12:44.312Z",
  "level": "error",
  "service": "auth-service",
  "traceId": "tr_4f8a2b1c9d3e",
  "userId": "u_8af2c3",
  "message": "Redis connection refused",
  "error": {
    "name": "ConnectionError",
    "message": "connect ECONNREFUSED 10.0.1.45:6379",
    "stack": "ConnectionError: connect ECONNREFUSED..."
  },
  "durationMs": 1203,
  "host": "auth-pod-7d9f4b-xkp2m"
}

Với format này, query trên CloudWatch Logs Insights:

-- Tìm tất cả lỗi Redis của một user cụ thể trong 30 phút
fields @timestamp, traceId, userId, error.message
| filter level = "error"
  AND service = "auth-service"
  AND userId = "u_8af2c3"
  AND error.message like /ECONNREFUSED/
| sort @timestamp desc
| limit 20

47 phút debug rút xuống còn dưới 2 phút. Không phải vì tool mạnh hơn — mà vì log có cấu trúc để query.


Correlation ID — sợi chỉ xuyên suốt các service

Khi một request đi qua API Gateway → Auth Service → Order Service → Payment Service, mỗi service log riêng. Nếu payment fail, làm sao tìm log của toàn bộ hành trình?

Trace ID (hay correlation ID) là một UUID được generate một lần ở entry point, sau đó truyền qua header qua mọi service call. Mỗi service log traceId vào mọi dòng log — sau đó query traceId = "tr_4f8a2b1c9d3e" trả về toàn bộ log của request đó across tất cả service.

// middleware/correlationId.ts
import { Request, Response, NextFunction } from "express";
import { v4 as uuidv4 } from "uuid";

export function correlationIdMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  // Nhận từ upstream service nếu có, hoặc tự generate mới tại entry point
  const traceId =
    (req.headers["x-trace-id"] as string) ??
    `tr_${uuidv4().replace(/-/g, "").slice(0, 12)}`;

  req.traceId = traceId;
  res.setHeader("X-Trace-Id", traceId);

  next();
}

// Khi gọi downstream service, LUÔN truyền traceId
async function callPaymentService(traceId: string, payload: PaymentPayload) {
  return await fetch("https://payment-service.internal/payments", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Trace-Id": traceId, // Truyền tiếp để payment service log cùng traceId
    },
    body: JSON.stringify(payload),
  });
}

Quy tắc: mọi outbound HTTP call đều phải có X-Trace-Id header. Nếu thiếu một service, chuỗi correlation bị đứt và query sẽ không hoàn chỉnh — đúng lúc cần nhất.


Logger middleware với pino

pino là lựa chọn tốt hơn winston cho production Node.js (mình đã dùng cả hai). Pino nhanh hơn khoảng 3x — benchmark: winston ~35k logs/s vs pino ~110k logs/s trên cùng máy — vì nó serialize object lazy thay vì ngay lập tức.

// logger/index.ts
import pino from "pino";

const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  // Pretty print chỉ ở local development
  transport:
    process.env.NODE_ENV === "development"
      ? { target: "pino-pretty", options: { colorize: true } }
      : undefined,
  // Các field xuất hiện trong mọi dòng log
  base: {
    service: process.env.SERVICE_NAME ?? "unknown-service",
    version: process.env.APP_VERSION ?? "unknown",
    env: process.env.NODE_ENV ?? "development",
  },
  timestamp: pino.stdTimeFunctions.isoTime,
});

export default logger;

// logger/requestLogger.ts — middleware log mỗi request
import { Request, Response, NextFunction } from "express";
import logger from "./index";

export function requestLogger(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const startTime = Date.now();

  // Child logger bind context của request — KHÔNG truyền traceId vào từng log call
  const reqLogger = logger.child({
    traceId: req.traceId,
    userId: (req as any).user?.id,
    method: req.method,
    path: req.path,
    ip: req.ip,
  });

  // Gắn vào request để handler dùng thay vì import logger trực tiếp
  (req as any).log = reqLogger;

  res.on("finish", () => {
    const durationMs = Date.now() - startTime;
    const level =
      res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info";

    reqLogger[level](
      {
        statusCode: res.statusCode,
        durationMs,
        slow: durationMs > 1000, // Flag để alert slow request
      },
      `${req.method} ${req.path} ${res.statusCode} ${durationMs}ms`
    );
  });

  next();
}

Dùng child logger thay vì truyền traceId vào từng lần log là điểm quan trọng — một lần bind context, mọi log trong request đó đều đủ field mà không cần developer nhớ.


Log level strategy — không phải mọi thứ đều cần log

Sai lầm phổ biến nhất mình thấy là log mọi thứ ở INFO, hoặc log mọi request body “để debug sau nếu cần”. Với 50,000 request/phút, đó là khoảng 72 triệu dòng log/ngày. CloudWatch Logs Insights charge theo GB scanned — một query đơn giản trên 30 phút log có thể tốn hơn $2. Nhân 20 lần debug/ngày = $40/ngày chỉ tiền query log (mình có bill CloudWatch để chứng minh).

Nguyên tắc mình dùng:

  • ERROR: cần wake up on-call lúc 3h sáng — service không làm được việc và không tự recover
  • WARN: hệ thống degraded nhưng còn chạy — retry thành công ở attempt thứ 3, slow query > 2s
  • INFO: business event quan trọng có giá trị audit — user đăng ký, order tạo, payment confirmed
  • DEBUG: chi tiết kỹ thuật — chỉ bật local hoặc khi debug production cụ thể, không bật mặc định
// Sampling strategy cho high-frequency endpoint
// Log 100% 4xx/5xx, chỉ log 1% request 2xx thông thường
function shouldLogRequest(statusCode: number): boolean {
  if (statusCode >= 400) return true; // Lỗi: log tất cả
  return Math.random() < 0.01; // Thành công: sample 1%
}

Với sampling 1% trên happy path, bill CloudWatch giảm từ $1,200/tháng xuống $340/tháng ở một project mình đã tối ưu. Vẫn đủ data để phân tích trend, nhưng không đốt tiền cho log không ai đọc.


Không log sensitive data — dễ quên, khó sửa

Password và token thì ai cũng nhớ không log. Nhưng PII (Personally Identifiable Information) thì ít ai để ý hơn: email, phone, địa chỉ, ngày sinh. Nếu log đi vào CloudWatch hay Loki mà không mask, đó là GDPR compliance risk thật sự — và CloudWatch không có built-in PII masking mạnh.

// utils/logSanitizer.ts
const SENSITIVE_KEYS = new Set([
  "password",
  "token",
  "secret",
  "authorization",
  "creditCard",
  "cvv",
  "ssn",
  "apiKey",
  "refreshToken",
]);

export function sanitizeForLog(
  obj: Record<string, unknown>
): Record<string, unknown> {
  return Object.fromEntries(
    Object.entries(obj).map(([key, value]) => {
      // Redact hoàn toàn các field nhạy cảm
      if (SENSITIVE_KEYS.has(key.toLowerCase())) {
        return [key, "[REDACTED]"];
      }
      // Mask email một phần: [email protected] → u***@domain.com
      if (typeof value === "string" && key === "email" && value.includes("@")) {
        const [local, domain] = value.split("@");
        return [key, `${local[0]}***@${domain}`];
      }
      return [key, value];
    })
  );
}

// Dùng khi cần log request context
logger.error(
  {
    traceId: req.traceId,
    context: sanitizeForLog(req.body), // Không log body thô
  },
  "Payment failed"
);
Cảnh báo: JSON.stringify(req.body) trực tiếp vào log message là pattern rất phổ biến — và là cách nhanh nhất để leak password của user vào log aggregation system mà không ai nhận ra cho đến khi audit.

Mình mất 47 phút tìm bug Redis lúc 3h sáng với unstructured log. Sau khi migrate sang structured logging với traceId và proper log levels, lần sau bug tương tự mất 3 phút 40 giây. Không phải vì mình debug giỏi hơn — mà vì log lần này có thể trả lời câu hỏi thay vì chỉ làm đầy disk.