โ† Semua pembelajaran / Python untuk AI Engineer
Fase 6 ยท Data, Testing, Deploy

Logging HOWTO

Logging yang benar: level, konfigurasi sekali di entrypoint, dan format terstruktur untuk production.

Sumber asli docs.python.org Resmi Rangkuman ~6 menit baca

Intisari

  • logger = logging.getLogger(__name__) di tiap modul. Konfigurasi sekali di entrypoint.
  • Jangan pakai f-string di panggilan log. Pakai %s plus argumen โ€” formatnya ditunda sampai perlu.
  • Level: DEBUG saat mengembangkan, INFO di production, ERROR untuk yang butuh perhatian.
  • logger.exception() di dalam blok except โ€” 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

LevelUntukContoh
DEBUGDetail saat mengembangkanIsi prompt, skor tiap chunk
INFOPeristiwa normal yang berartiRequest diterima, ingest selesai
WARNINGTidak normal, tapi masih berjalanRetry, cache miss, fallback dipakai
ERROROperasi gagalPanggilan LLM gagal setelah semua retry
CRITICALAplikasi tidak bisa lanjutGagal 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

PeristiwaLevelSertakan
Request masukINFOrequest_id, endpoint, user
Panggilan LLM selesaiINFOmodel, token, biaya, latensi, cache hit
Tool dipanggilINFOnama tool, durasi
RetryWARNINGpercobaan ke-berapa, alasan
Rate limitWARNINGretry-after
Panggilan gagal totalERRORtraceback, request_id
RefusalWARNINGkategori, request_id
Retrieval kosongWARNINGquery, 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.