Structured logging: log có cấu trúc và các field cần có
Chuyện quen thuộc: 3 giờ sáng, alert kêu, bạn mở log ra và thấy một biển dòng text không đầu không cuối. Không ai biết dòng nào thuộc request nào, cũng không ai lọc được "chỉ cho tôi xem các request bị lỗi thanh toán". Bài này dựng lại nền móng: biến một dòng log text rời rạc thành một object JSON có đủ field để lọc và tìm.
Sau bài này, bạn sẽ:
- Giải thích được vì sao log text gây tốn thời gian khi có sự cố
- Viết được một dòng log JSON với bộ field tối thiểu
- Thêm
request_idvà lan truyền nó qua mọi dòng log của cùng một request - Biết đặt chi tiết vào field riêng thay vì nhồi hết vào message
Vì sao dòng log text làm bạn mất 40 phút lúc 3 giờ sáng
Log text thường trông như thế này:
INFO 2025-01-14 03:12:44 payment failed for user 4821 order 9931
INFO 2025-01-14 03:12:44 retry payment
ERROR 2025-01-14 03:12:45 payment failed for user 4821 order 9931
Nhìn thì hiểu, nhưng khi có 50.000 dòng mỗi đêm thì bạn chỉ còn cách grep theo chuỗi. Muốn đếm "bao nhiêu đơn bị lỗi thanh toán" thì phải viết regex, mà regex thì vỡ ngay khi ai đó đổi câu chữ trong message.
Vấn đề gốc: thông tin nằm trong câu văn, không nằm trong field. Máy không tách được "4821" là user hay order.
Log có cấu trúc là gì
Log có cấu trúc (structured logging) là mỗi dòng log được ghi thành một object key-value, thường là một dòng JSON. Cùng sự kiện trên, viết lại:
- INFO 2025-01-14 03:12:44 payment failed for user 4821 order 9931
+ {"ts":"2025-01-14T03:12:44.512Z","level":"error","service":"order-api","request_id":"7f3c9a1e","message":"payment_failed","order_id":9931,"user_id":4821,"error_code":"CARD_DECLINED","duration_ms":1832}
Khác biệt không nằm ở việc "đẹp hơn", mà ở chỗ bạn query được: level = error AND service = order-api AND error_code = CARD_DECLINED. Không cần regex, không phụ thuộc câu chữ.
Một điểm rất thực dụng: log JSON vẫn đọc bằng mắt được, và các tool như jq, Loki, CloudWatch Insights, Datadog đều hiểu JSON ngay mà không cần cấu hình parser phức tạp.
Bộ field tối thiểu cho một app nhỏ
Đừng cố nghĩ ra 30 field. Bắt đầu với 5 field này, thiếu cái nào thì lúc debug sẽ thấy ngay.
| Field | Ví dụ | Vì sao cần |
|---|---|---|
ts |
2025-01-14T03:12:44.512Z |
Sắp xếp và tính khoảng thời gian. Dùng ISO 8601, UTC, có milliseconds |
level |
info, error |
Lọc theo mức độ (bài sau đi sâu phần này) |
service |
order-api |
Biết log phát ra từ service nào khi hệ thống có nhiều service |
request_id |
7f3c9a1e |
Nối mọi dòng log của cùng một request |
message |
payment_failed |
Tên sự kiện ngắn, ổn định, viết snake_case |
Field bổ sung nên thêm khi đã có nhu cầu thật: route, method, status_code, duration_ms, error_code, user_id.
Hai quy tắc nhỏ nhưng cứu rất nhiều thời gian:
messagelà hằng số mô tả sự kiện, không phải câu kể có biến số. Viếtpayment_failed, rồi đặtorder_id,error_codera field riêng.- Số đo để dạng số, không để chuỗi:
duration_ms: 1832chứ không phảiduration: "1.8s". Có số thì mới tính percentile được.
Tip: Đặt tên field một lần rồi dùng đúng như vậy ở mọi nơi.
user_idở service A vàuserIdở service B là hai field khác nhau với mọi tool query, và bạn sẽ phát hiện ra điều đó đúng lúc đang cháy.
Bài tập 1. Chuyển dòng log sau thành JSON với bộ field tối thiểu, thêm field bạn thấy cần:
ERROR 2025-01-14 03:15:02 Không gửi được email xác nhận cho đơn 9931
Xem gợi ý đáp án
{"ts":"2025-01-14T03:15:02.000Z","level":"error","service":"notification-worker","request_id":"a1b2c3d4","message":"email_send_failed","order_id":9931,"error_code":"SMTP_TIMEOUT","duration_ms":5021}
Điểm cần kiểm tra: message là hằng số, order_id là số, thời gian có timezone, và có request_id (nếu worker nhận được nó từ hàng đợi).
request_id: sợi chỉ nối các dòng log
request_id là field đáng đầu tư nhất trong cả danh sách. Nó là một chuỗi ngắn, duy nhất cho mỗi request đi vào hệ thống, được sinh ra ở điểm vào đầu tiên (middleware, API gateway, hoặc handler nhận message) rồi truyền xuống mọi tầng phía dưới.
flowchart TD
A["🌐 Request vào"] --> B["🆔 Middleware sinh request_id"]
B --> C["📝 order-api log"]
B --> D["📝 payment-api log"]
C --> E["🔎 Lọc theo request_id"]
D --> E
Khi có sự cố, quy trình trở thành: tìm một dòng error bất kỳ, lấy request_id của nó, rồi lọc toàn bộ log theo đúng id đó. Bạn thấy được câu chuyện đầy đủ của một request, thay vì đoán giữa hàng nghìn dòng của người khác.
Với app nhỏ, ba việc là đủ:
- Sinh id ở middleware: đọc header
X-Request-Idnếu có, không thì tự sinh. - Gắn id vào context/logger của request, để mọi dòng log trong request đó tự động có field này.
- Trả lại id trong response header để user báo lỗi có thể gửi kèm id.
Bài tập 2. Bạn có 4 dòng log dưới đây. Dòng nào thuộc cùng một request, và dòng nào là dòng bạn nên mở đầu cuộc điều tra?
{"request_id":"aa11","level":"info","message":"order_created"}
{"request_id":"bb22","level":"info","message":"order_created"}
{"request_id":"aa11","level":"error","message":"payment_failed","error_code":"CARD_DECLINED"}
{"request_id":"cc33","level":"warn","message":"cache_miss"}
Xem gợi ý đáp án
Dòng 1 và 3 cùng request aa11. Mở đầu từ dòng 3 vì đó là dòng error, rồi lọc ngược lại theo request_id = aa11 để xem chuyện gì xảy ra trước đó. Dòng 4 không liên quan và không nên xuất hiện trong màn hình điều tra của bạn.
Mấy lỗi hay gặp khi mới chuyển sang log JSON
- Nhồi biến vào
message."payment_failed for order 9931"làm message bị phân mảnh thành hàng nghìn biến thể, không group được. Chi tiết đưa ra field riêng. - Log nguyên object.
logger.info(response)tạo ra dòng log khổng lồ, dễ lộ dữ liệu không nên có trong log. Chọn đúng field bạn sẽ dùng khi debug. - Đổi tên field giữa các service. Chốt một bộ tên dùng chung ngay từ đầu.
- Thiếu timezone.
2025-01-14 03:12:44không nói được là giờ nào; dùng ISO 8601 UTC. - Sinh
request_idquá muộn. Nếu chỉ sinh ở tầng handler sâu nhất, các dòng log phía trước sẽ mất dấu. - Quên log lúc thành công. Chỉ log lỗi thì bạn không có baseline để so sánh "bình thường trông thế nào".
Quy tắc thực dụng cho người mới: nếu một field không giúp bạn lọc, đếm, hoặc tái hiện sự cố, đừng thêm nó.
Chốt lại và bước tiếp theo
Ba điều mang theo:
- Log có cấu trúc = mỗi dòng là JSON với field rõ ràng, để máy query được thay vì bạn
grep. - Bộ tối thiểu:
ts,level,service,request_id,message, cùng các field chi tiết nhưorder_id,error_code,duration_ms. request_idbiến hàng nghìn dòng rời rạc thành câu chuyện của từng request.
Bài tiếp theo đi vào field level: chọn mức nào cho việc gì, và làm sao để log info không biến thành bãi rác làm mờ chính những dòng error bạn cần.
Front: Log có cấu trúc là gì? Back: Mỗi dòng log là một object key-value (thường JSON) với các field đặt tên rõ ràng, query được thay vì phải grep chuỗi.
Front: Vì sao log text gây tốn thời gian khi debug? Back: Thông tin nằm trong câu văn, không nằm trong field, nên không lọc hay đếm chính xác được và dễ vỡ khi message đổi câu chữ.
Front: Bộ field tối thiểu của một dòng log? Back: ts, level, service, request_id, message.
Front: timestamp nên ghi ở định dạng nào? Back: ISO 8601 theo UTC, có milliseconds, ví dụ 2025-01-14T03:12:44.512Z.
Front: request_id dùng để làm gì? Back: Nối tất cả dòng log thuộc cùng một request để xem trọn câu chuyện khi điều tra.
Front: request_id nên được sinh ở đâu? Back: Ở điểm vào đầu tiên (middleware hoặc API gateway), rồi truyền xuống mọi tầng phía dưới.
Front: message nên viết thế nào? Back: Ngắn, ổn định, snake_case, mô tả sự kiện; biến số đặt ở field riêng.
Front: Vì sao không nhồi order_id vào message? Back: Message bị phân mảnh thành nhiều biến thể, không group hay lọc theo giá trị được.
Front: Field service trả lời câu hỏi gì? Back: Dòng log này phát ra từ service nào trong hệ thống.
Front: duration nên là số hay chuỗi? Back: Là số, ví dụ duration_ms: 1832, để tính được percentile và aggregate.
Front: Lỗi đặt tên field hay gặp nhất? Back: Mỗi service một kiểu (user_id, userId, uid), khiến query theo field bị hụt.
Front: Có nên log nguyên một object response không? Back: Không. Chọn field cụ thể cần cho việc debug, tránh log phình và tránh lộ dữ liệu.
Front: Vì sao cần log cả khi thành công? Back: Để có baseline so sánh, biết trạng thái bình thường trông thế nào khi sự cố xảy ra.
Các field then chốt, điền nhanh để nhớ:
Mỗi dòng log nên có _____ để nối các dòng thuộc cùng một request. Field _____ cho biết log phát ra từ service nào. Thời gian nên ghi theo chuẩn _____ ở múi giờ UTC. Nội dung _____ nên là hằng số mô tả sự kiện, còn các giá trị chi tiết như order_id hay error_code đặt ở _____.