Logging HOWTO
Logging yang benar: level, konfigurasi sekali di entrypoint, dan format terstruktur untuk production.
Intisari
logger = logging.getLogger(__name__)di tiap modul. Konfigurasi sekali di entrypoint.- Jangan pakai f-string di panggilan log. Pakai
%splus argumen โ formatnya ditunda sampai perlu. - Level:
DEBUGsaat mengembangkan,INFOdi production,ERRORuntuk yang butuh perhatian. logger.exception()di dalam blokexceptโ ia menyertakan traceback otomatis.- Di container, tulis JSON ke stdout. Jangan menulis ke file sendiri.
Kenapa bukan print()
print() | logging | |
|---|---|---|
| Bisa dimatikan per lingkungan | โ | โ Lewat level |
| Tahu asal pesannya | โ | โ Nama logger + baris |
| Timestamp | โ Manual | โ Otomatis |
| Traceback | โ Manual | โ
exception() |
| Tujuan berganda | โ | โ Handler |
| Format terstruktur | โ | โ Formatter |
Pola dasar
# Di SETIAP modul
import logging
logger = logging.getLogger(__name__)
def proses(dokumen: list[Dokumen]) -> None:
logger.info("memproses %d dokumen", len(dokumen))
for d in dokumen:
logger.debug("dokumen %s: %d karakter", d.id, len(d.isi))
# SEKALI SAJA, di entrypoint (main.py)
import logging, sys
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
stream=sys.stdout,
)
__name__ memberi hierarki logger secara gratis. Modul
myapp.llm.client menghasilkan logger bernama sama, sehingga kamu bisa mengatur
level per bagian aplikasi:
logging.getLogger("myapp.llm").setLevel(logging.DEBUG) menyalakan debug hanya
untuk lapisan LLM.
Jangan pakai f-string
logger.info(f"memproses {len(docs)} dokumen") # โ
logger.info("memproses %s dokumen", len(docs)) # โ
Alasannya dua: pertama, f-string dirakit sebelum fungsi dipanggil โ kalau
level-nya WARNING, string itu dibuat lalu dibuang. Kedua, bentuk %s
menjaga pesan tetap sebagai template, sehingga sistem log terpusat bisa mengelompokkan
kejadian yang sama meski nilainya berbeda. Ruff aturan G menangkap ini.
Memilih level
| Level | Untuk | Contoh |
|---|---|---|
DEBUG | Detail saat mengembangkan | Isi prompt, skor tiap chunk |
INFO | Peristiwa normal yang berarti | Request diterima, ingest selesai |
WARNING | Tidak normal, tapi masih berjalan | Retry, cache miss, fallback dipakai |
ERROR | Operasi gagal | Panggilan LLM gagal setelah semua retry |
CRITICAL | Aplikasi tidak bisa lanjut | Gagal terhubung ke database saat start |
Logging exception
try:
hasil = panggil_llm(prompt)
except anthropic.RateLimitError as e:
logger.warning("kena rate limit, mengulang: %s", e)
raise
except Exception:
logger.exception("panggilan llm gagal") # โ traceback ikut otomatis
raise
logger.exception() hanya boleh dipanggil di dalam blok except
โ ia mengambil traceback dari konteks exception yang sedang aktif. Di luar blok itu,
tidak ada traceback yang bisa diambil. Setara dengan
logger.error(msg, exc_info=True).
Konteks tambahan
logger.info(
"panggilan llm selesai",
extra={
"session_id": session_id,
"model": model,
"token_masuk": usage.input_tokens,
"token_keluar": usage.output_tokens,
"biaya_usd": biaya,
"latensi_ms": latensi,
},
)
Field di extra bisa dibaca formatter kustom โ dasar dari log terstruktur.
Log JSON untuk production
import json, logging, sys
from datetime import datetime, timezone
BAWAAN = {
"name", "msg", "args", "levelname", "levelno", "pathname", "filename",
"module", "exc_info", "exc_text", "stack_info", "lineno", "funcName",
"created", "msecs", "relativeCreated", "thread", "threadName",
"processName", "process", "taskName",
}
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
data = {
"waktu": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"pesan": record.getMessage(),
}
if record.exc_info:
data["exception"] = self.formatException(record.exc_info)
# Semua field dari extra=
data.update({k: v for k, v in record.__dict__.items() if k not in BAWAAN})
return json.dumps(data, ensure_ascii=False)
def setup_logging(level: str = "INFO", json_mode: bool = True) -> None:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(
JsonFormatter() if json_mode
else logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
)
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level)
logging.getLogger("httpx").setLevel(logging.WARNING) # redam library berisik
logging.getLogger("httpcore").setLevel(logging.WARNING)
Level per logger
logging.getLogger("myapp").setLevel(logging.DEBUG)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("anthropic").setLevel(logging.INFO)
logging.getLogger("chromadb").setLevel(logging.ERROR)
Library pihak ketiga sering sangat berisik di level DEBUG. Redam yang tidak kamu butuhkan.
Melacak satu request
import contextvars, logging, uuid
request_id = contextvars.ContextVar("request_id", default="-")
class RequestIdFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id.get()
return True
@app.middleware("http")
async def tandai_request(request, call_next):
request_id.set(str(uuid.uuid4())[:8])
return await call_next(request)
Wajib untuk aplikasi async. Puluhan request berjalan bersamaan dan log-nya bercampur.
Tanpa ID korelasi, kamu tidak bisa merangkai kembali apa yang terjadi pada satu percakapan.
contextvars bekerja dengan benar di asyncio โ threading.local tidak.
Apa yang layak dicatat di aplikasi LLM
| Peristiwa | Level | Sertakan |
|---|---|---|
| Request masuk | INFO | request_id, endpoint, user |
| Panggilan LLM selesai | INFO | model, token, biaya, latensi, cache hit |
| Tool dipanggil | INFO | nama tool, durasi |
| Retry | WARNING | percobaan ke-berapa, alasan |
| Rate limit | WARNING | retry-after |
| Panggilan gagal total | ERROR | traceback, request_id |
| Refusal | WARNING | kategori, request_id |
| Retrieval kosong | WARNING | query, ambang batas |
Jangan mencatat isi prompt di level INFO. Prompt bisa mengandung data pribadi pengguna dan ukurannya besar. Catat panjangnya, hash-nya, atau ID-nya. Isi lengkap hanya di level DEBUG, dan itu pun jangan aktif di production.
Konfigurasi lewat dict
import logging.config
logging.config.dictConfig({
"version": 1,
"disable_existing_loggers": False,
"formatters": {"json": {"()": JsonFormatter}},
"handlers": {
"stdout": {"class": "logging.StreamHandler",
"formatter": "json", "stream": "ext://sys.stdout"},
},
"loggers": {
"myapp": {"level": "DEBUG", "handlers": ["stdout"], "propagate": False},
"httpx": {"level": "WARNING"},
},
"root": {"level": "INFO", "handlers": ["stdout"]},
})
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.