Logging trong Python: cấu hình cho production
1. Tổng quan
print() đủ dùng khi bạn viết script nhỏ. Lên production thì khác: bạn cần lọc log theo level, gom log vào hệ thống tập trung, lần theo một request qua nhiều dòng log, và việc ghi log không được làm chậm ứng dụng.
Bài này hướng dẫn cấu hình module logging chuẩn của Python cho production. Đọc xong, bạn sẽ dựng được một cấu hình gồm logger theo module, dictConfig, log dạng JSON, request_id gắn tự động, ghi log bất đồng bộ bằng QueueHandler và test log bằng pytest. Mọi ví dụ dùng Python 3.12 và chỉ cần thư viện chuẩn. Riêng phần test cần thêm pytest.
2. Vì sao print và basicConfig không đủ
Xem một service xử lý đơn hàng viết theo kiểu “nhanh cho xong”:
import logging
logging.basicConfig(level=logging.INFO)
def place_order(order_id: str, amount: float) -> None:
print(f"Placing order {order_id}")
logging.info(f"Order {order_id} amount={amount}")
place_order("ORD-1001", 250_000)
Placing order ORD-1001
INFO:root:Order ORD-1001 amount=250000
Đoạn code này có ba vấn đề:
printkhông có level, không có timestamp và không tắt được qua cấu hình.logging.info(...)ghi vào root logger. Bạn không biết dòng log đến từ module nào và không chỉnh level riêng cho từng phần được.basicConfigchỉ có tác dụng ở lần gọi đầu tiên, nếu root logger chưa có handler. Thư viện nào gọi nó trước bạn thì cấu hình của bạn bị bỏ qua mà không có cảnh báo nào.
3. Logger theo module với getLogger(__name__)
Quy tắc đầu tiên: mỗi module tạo một logger riêng, đặt tên theo __name__.
# shop/orders.py
import logging
logger = logging.getLogger(__name__) # tên logger: "shop.orders"
class OrderService:
def place_order(self, order_id: str, quantity: int) -> None:
logger.info("Placing order %s with quantity %d", order_id, quantity)
if quantity > 50:
logger.warning("Large order %s: quantity=%d", order_id, quantity)
Tên logger tạo thành một cây phân cấp theo dấu chấm. shop.orders là con của shop, và shop là con của root. Một record đi từ logger con lên logger cha (gọi là propagation). Vì vậy bạn chỉ cần gắn handler ở root hoặc ở shop, rồi chỉnh level riêng cho từng nhánh. Ví dụ, bạn có thể đặt shop.payments ở DEBUG và giữ phần còn lại ở INFO.
Bạn cũng để ý cách truyền tham số: dùng %s kèm đối số thay vì f-string. Khi level bị tắt, chuỗi sẽ không được format, nên bạn không tốn chi phí cho những log không bao giờ được in. Ngoài ra, công cụ gom log có thể nhóm các dòng theo cùng message template.
Lưu ý: Trong thư viện (package bạn phát hành cho người khác dùng), đừng cấu hình handler. Chỉ gọi
getLogger(__name__)và để ứng dụng quyết định. Nếu muốn, thêmlogging.getLogger("your_lib").addHandler(logging.NullHandler()).
4. Cấu hình tập trung bằng dictConfig
Ở production, bạn nên gom toàn bộ cấu hình vào một chỗ, gọi một lần duy nhất khi ứng dụng khởi động. logging.config.dictConfig phù hợp cho việc này.
# shop/logging_config.py
import logging.config
import os
def setup_logging() -> None:
level = os.getenv("LOG_LEVEL", "INFO")
logging.config.dictConfig({
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"plain": {
"format": "%(asctime)s %(levelname)-8s %(name)s - %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "plain",
},
},
"loggers": {
"shop": {"level": level},
"urllib3": {"level": "WARNING"},
},
"root": {"level": "WARNING", "handlers": ["console"]},
})
Các điểm chính trong cấu hình:
"disable_existing_loggers": Falselà bắt buộc trong thực tế. Giá trị mặc địnhTruesẽ vô hiệu hóa mọi logger đã tạo trước khidictConfigchạy, kể cảloggerở đầu các module bạn đã import. Kết quả là log biến mất mà không có lỗi nào.- Handler chỉ gắn ở
root. Loggershopchỉ đặt level, còn record sẽ propagate lên root để được in ra. - Root để ở
WARNINGnhằm chặn logINFOồn ào từ thư viện bên thứ ba. Code của bạn trong nhánhshopvẫn log ởINFO. - Level đọc từ biến môi trường
LOG_LEVEL. Bạn bậtDEBUGkhi điều tra sự cố mà không cần sửa code.
Gọi hàm ở entry point:
# main.py
from shop.logging_config import setup_logging
from shop.orders import OrderService
setup_logging()
OrderService().place_order("ORD-1001", quantity=80)
2026-10-18 09:15:02,114 INFO shop.orders - Placing order ORD-1001 with quantity 80
2026-10-18 09:15:02,114 WARNING shop.orders - Large order ORD-1001: quantity=80
Ghi ra stdout là lựa chọn đúng khi chạy trong container. Docker hoặc Kubernetes sẽ thu log từ stream này, nên bạn không cần tự quản lý file và việc xoay vòng file.
5. Log có cấu trúc dạng JSON
Hệ thống gom log như Loki, Elasticsearch hay CloudWatch tìm kiếm trên JSON tốt hơn nhiều so với text tự do. Bạn có thể tự viết formatter mà không cần thư viện ngoài:
# shop/json_formatter.py
import json
import logging
from datetime import datetime, timezone
_RESERVED = set(vars(logging.LogRecord("", 0, "", 0, "", None, None))) | {"message", "asctime"}
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
for key, value in record.__dict__.items():
if key not in _RESERVED and not key.startswith("_"):
payload[key] = value
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, default=str)
Cách hoạt động:
_RESERVEDchứa các thuộc tính mặc định của mộtLogRecord, ví dụpathname,lineno,args.- Mọi thuộc tính khác được coi là field tùy biến và đưa vào JSON. Các field này thường đến từ tham số
extra=hoặc từ filter (xem mục 6). default=strgiúpjson.dumpskhông lỗi khi gặp kiểu nhưDecimalhayUUID.
Khai báo formatter trong dictConfig bằng key đặc biệt "()":
"formatters": {
"json": {"()": "shop.json_formatter.JsonFormatter"},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "json",
},
},
Thêm field nghiệp vụ bằng extra:
logger.info("Payment captured", extra={"order_id": "ORD-1001", "amount": 250000})
{"ts": "2026-10-18T02:15:02.114+00:00", "level": "INFO", "logger": "shop.payments", "message": "Payment captured", "order_id": "ORD-1001", "amount": 250000}
Lưu ý: Key trong
extrakhông được trùng tên thuộc tính có sẵn củaLogRecord. Ví dụ,extra={"name": "..."}hayextra={"message": "..."}sẽ némKeyError: "Attempt to overwrite 'name' in LogRecord".
6. Gắn request_id tự động bằng contextvars
Truyền extra={"request_id": ...} vào từng lời gọi log thì vừa dài dòng vừa dễ quên. Cách tốt hơn là lưu request_id vào một ContextVar, rồi để một Filter gắn nó vào mọi record.
# shop/request_context.py
import logging
from contextvars import ContextVar
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
class RequestIdFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id_var.get()
return True # không chặn record nào, chỉ bổ sung dữ liệu
ContextVar an toàn với cả thread lẫn asyncio. Mỗi task hoặc request giữ giá trị riêng, nên không có chuyện request này “mượn” ID của request khác.
Gắn filter vào handler, không gắn vào logger. Filter gắn ở logger chỉ chạy cho record tạo ra trực tiếp từ logger đó, chứ không chạy cho record propagate từ logger con lên.
"filters": {
"request_id": {"()": "shop.request_context.RequestIdFilter"},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "json",
"filters": ["request_id"],
},
},
Đặt giá trị ở đầu mỗi request, ví dụ trong middleware:
import uuid
from shop.request_context import request_id_var
def handle_request(order_id: str) -> None:
token = request_id_var.set(uuid.uuid4().hex[:12])
try:
OrderService().place_order(order_id, quantity=3)
finally:
request_id_var.reset(token)
{"ts": "...", "level": "INFO", "logger": "shop.orders", "message": "Placing order ORD-1002 with quantity 3", "request_id": "9f2c1ab07d3e"}
Giờ bạn chỉ cần lọc theo request_id là thấy toàn bộ hành trình của một request.
7. Ghi log exception đúng cách
logger = logging.getLogger("shop.payments")
class PaymentService:
def charge(self, order_id: str, amount: int) -> bool:
try:
raise ConnectionError("gateway timeout")
except ConnectionError:
logger.exception("Charge failed for order %s", order_id)
return False
logger.exception(...) ghi ở level ERROR và tự đính kèm stack trace. Bạn chỉ gọi nó bên trong khối except. Nếu muốn ghi ở level khác mà vẫn có stack trace, dùng logger.warning("...", exc_info=True).
Hai lỗi hay gặp:
logger.error(f"Charge failed: {e}")làm mất stack trace. Khi điều tra, bạn chỉ thấy đúng một dòng “gateway timeout” mà không biết nó xảy ra ở đâu.- Vừa log vừa
raiselại ở mọi tầng. Cùng một exception sẽ xuất hiện năm lần trong log. Hãy log ở tầng xử lý exception, tức là nơi quyết định không ném tiếp nữa.
8. Không để logging chặn ứng dụng: QueueHandler
StreamHandler ghi ra stdout thường đủ nhanh. Nhưng handler gửi qua mạng (syslog, HTTP, SMTP) hoặc ghi lên ổ đĩa chậm sẽ chặn chính thread đang xử lý request. Để tránh việc này, đưa record vào hàng đợi và để một thread nền ghi ra đích thật:
import atexit
import logging
import logging.handlers
import queue
def setup_async_logging(target: logging.Handler) -> None:
log_queue: queue.Queue = queue.Queue(-1)
queue_handler = logging.handlers.QueueHandler(log_queue)
listener = logging.handlers.QueueListener(
log_queue, target, respect_handler_level=True
)
listener.start()
atexit.register(listener.stop) # flush phần còn lại khi tắt
root = logging.getLogger()
root.handlers = [queue_handler]
Luồng xử lý như sau:
- Thread ứng dụng chỉ làm một việc là
putrecord vàoqueue. Thao tác này gần như tức thời. QueueListenerchạy trên thread riêng, lấy record ra và chuyển chotarget, ví dụ mộtSysLogHandler.atexit.register(listener.stop)bảo đảm log cuối cùng được ghi xong trước khi process thoát.
Lưu ý: Filter như
RequestIdFilterphải gắn vàoQueueHandler, không gắn vào handler đích. Handler đích chạy trên thread của listener, màContextVarở thread đó không mang giá trị của request gốc.
9. Test log với pytest caplog
Log cảnh báo nghiệp vụ, ví dụ đơn hàng số lượng lớn, là hành vi đáng được test. Thêm pytest vào dev dependency:
pip install pytest
# tests/test_orders.py
import logging
from shop.orders import OrderService
def test_large_order_logs_warning(caplog):
with caplog.at_level(logging.INFO, logger="shop.orders"):
OrderService().place_order("ORD-2001", quantity=80)
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
assert warnings[0].name == "shop.orders"
assert warnings[0].getMessage() == "Large order ORD-2001: quantity=80"
def test_small_order_does_not_warn(caplog):
with caplog.at_level(logging.INFO, logger="shop.orders"):
OrderService().place_order("ORD-2002", quantity=2)
assert all(r.levelno < logging.WARNING for r in caplog.records)
tests/test_orders.py .. [100%]
2 passed in 0.03s
caplog.at_level tạm hạ level của logger shop.orders trong phạm vi khối with. caplog.records chứa các LogRecord gốc, nên bạn kiểm tra được level, tên logger và cả field extra nếu có.
Lưu ý:
caploggắn handler vào root logger. Nếu cấu hình của bạn đặt"propagate": Falsecho loggershop, test sẽ không bắt được record nào.
10. Lỗi hay gặp và khi nào không nên làm
- Cấu hình logging ở cấp module (gọi
basicConfighaydictConfiglúc import). Chỉ cấu hình ở entry point, một lần duy nhất. - Handler bị gắn hai lần, ví dụ gắn ở cả
shopvà root trong khipropagatevẫn làTrue. Mỗi dòng log sẽ in hai lần. Bạn chỉ nên gắn handler ở một tầng. - Ghi dữ liệu nhạy cảm: mật khẩu, token, số thẻ. Đừng log nguyên object request. Nếu bắt buộc, hãy viết một
Filterđể che (mask) các field này. - Dùng
RotatingFileHandlertrong ứng dụng nhiều process (Gunicorn với nhiều worker). Các process cùng xoay vòng một file sẽ gây mất log. Hãy ghi ra stdout, hoặc để mỗi worker đẩy quaQueueHandlervề một process ghi duy nhất. - Log quá nhiều ở
INFOtrong vòng lặp nóng. Chi phí lưu trữ và độ nhiễu tăng rất nhanh. Những log như vậy nên để ởDEBUG. - Dùng JSON khi phát triển local. JSON khó đọc bằng mắt, vì vậy hãy chọn formatter theo biến môi trường:
plaincho dev,jsoncho production.
11. Kết luận
Một cấu hình logging tốt cho production không phức tạp, chỉ cần nhất quán. Dùng getLogger(__name__) trong mọi module. Gom cấu hình vào một lần gọi dictConfig với disable_existing_loggers: False. Ghi JSON ra stdout, gắn request_id qua ContextVar và Filter, và dùng logger.exception để giữ stack trace. Khi handler đích chậm, chuyển sang QueueHandler và QueueListener.
Cuối cùng, hãy coi log là một phần hành vi của hệ thống và test nó bằng caplog. Khi có sự cố lúc 2 giờ sáng, những dòng log có cấu trúc này sẽ giúp bạn tìm ra nguyên nhân nhanh hơn nhiều.