Log berformat teks seperti 2025-06-15 09:30:45 WARNING pesanan gagal nyaman dibaca manusia, tetapi menyiksa mesin. Tidak ada pemisah yang konsisten antara stempel waktu, level, dan isi pesan, sehingga tidak ada cara andal mengekstrak “berapa pesanan yang gagal hari ini” dari ribuan baris seperti itu. Structured logging membalik pendekatannya: setiap baris log menjadi satu object berfield — biasanya JSON — dengan nilai yang bisa dicari, disaring, dan dihitung secara otomatis. Pada Chapter ini kita akan melihat mengapa teks polos gagal di skala besar, menulis formatter JSON sendiri dengan standard library, lalu menyematkan correlation ID agar baris-baris log dari satu request bisa dikumpulkan kembali.

Masalah Log Teks Polos #

Bayangkan tiga baris log dari tiga style penulisan yang berbeda:

text
2025-06-15 09:30:45 [WARN] pesanan 4128 gagal diproses
09:30:46 WARNING: gagal memproses pesanan id=4129, alasan: stok habis
2025-06-15 09:30:47 pesanan 4130 GAGAL (stok)

Ketiganya menyampaikan kejadian yang sama, tetapi formatnya berbeda-beda. Mencari seluruh kegagalan pesanan menuntut pola grep yang berbeda untuk setiap style, dan itupun belum menghitung pesan yang salah ketik. Masalahnya memburuk ketika satu baris ingin memuat angka (id pesanan, total harga, durasi): di teks polos angka-angka itu terkubur dalam kalimat.

Solusinya adalah menjadikan setiap baris log satu object dengan field bernama:

json
{"timestamp": "2025-06-15T09:30:45", "level": "WARNING", "logger": "toko", "message": "pesanan gagal diproses", "pesanan_id": 4128}

Field pesanan_id kini adalah data bertipe angka yang bisa disaring dengan pesanan_id = 4128, bukan teks yang harus digali dengan regular expression.

Formatter JSON dengan Standard Library #

Kita bisa menulis formatter JSON sendiri dengan turunan logging.Formatter dan module json — tanpa satu pun paket pihak ketiga. Kuncinya ada pada dua hal: object LogRecord menyimpan field tambahan yang dikirim lewat parameter extra, dan method format() kita ganti agar mengembalikan string JSON:

python
import json
import logging


class JsonFormatter(logging.Formatter):
    BAWAAN = set(logging.makeLogRecord({}).__dict__) | {
        "message",
        "asctime",
        "taskName",
    }

    def format(self, record):
        payload = {
            "timestamp": self.formatTime(record, datefmt="%Y-%m-%dT%H:%M:%S"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        for kunci, nilai in record.__dict__.items():
            if kunci not in self.BAWAAN:
                payload[kunci] = nilai
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False)


handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())

logger = logging.getLogger("toko")
logger.addHandler(handler)
logger.setLevel(logging.INFO)

logger.info("Pesanan diproses", extra={"pesanan_id": 4128, "total": 125000})
logger.error("Pembayaran gagal", extra={"pesanan_id": 4129})

Output yang Dihasilkan (dicetak module logging ke stderr; nilai timestamp berubah setiap eksekusi):

text
{"timestamp": "2025-06-15T09:30:45", "level": "INFO", "logger": "toko", "message": "Pesanan diproses", "pesanan_id": 4128, "total": 125000}
{"timestamp": "2025-06-15T09:30:46", "level": "ERROR", "logger": "toko", "message": "Pembayaran gagal", "pesanan_id": 4129}

Mari kita bedah bagian-bagian pentingnya:

  1. Baris 6-10 (BAWAAN): kumpulan nama atribut internal milik LogRecord. Atribut ini dibangun dengan membuat LogRecord kosong lalu mengambil seluruh key-nya — dipakai untuk membedakan field bawaan dari field buatan kita.
  2. Baris 12-17 (payload): empat field inti setiap baris log — stempel waktu, level, nama logger, dan isi pesan.
  3. Baris 18-20 (loop record.__dict__): field apa pun yang tidak termasuk atribut bawaan adalah kiriman parameter extra, seperti pesanan_id dan total, dan dimasukkan ke payload.
  4. Baris 21-22 (exc_info): bila pesan dicatat lewat logger.exception(), traceback lengkap ikut disimpan sebagai field exception.
  5. Baris 23 (json.dumps(...)): payload diubah menjadi satu baris JSON; ensure_ascii=False agar teks Bahasa Indonesia tetap tersimpan apa adanya.

Satu baris output = satu object lengkap. Mencari kejadian kini cukup memfilter field, misalnya semua log dengan "level": "ERROR" atau "pesanan_id": 4128.

Correlation ID dengan contextvars #

Pada aplikasi web, satu request bisa menyentuh banyak function sebelum selesai. Agar baris-baris log dari request yang sama bisa dikumpulkan kembali, setiap request diberi tanda unik bernama correlation ID yang disematkan pada setiap baris lognya. Standard library menyediakan module contextvars untuk menyimpan nilai konteks seperti ini secara aman — satu tempat penyimpanan per konteks eksekusi:

python
import contextvars
import logging

request_id = contextvars.ContextVar("request_id", default="-")


class RequestIdFilter(logging.Filter):
    def filter(self, record):
        record.request_id = request_id.get()
        return True


handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("[%(request_id)s] %(levelname)s %(message)s"))

logger = logging.getLogger("toko")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.addFilter(RequestIdFilter())


def tangani_pesanan(pesanan_id):
    request_id.set(f"req-{pesanan_id:04d}")
    logger.info("Pesanan diterima")
    logger.warning("Stok hampir habis")


tangani_pesanan(4128)
tangani_pesanan(4129)

Output yang Dihasilkan (dicetak module logging ke stderr):

text
[req-4128] INFO Pesanan diterima
[req-4128] WARNING Stok hampir habis
[req-4129] INFO Pesanan diterima
[req-4129] WARNING Stok hampir habis

Mari kita telaah mekanismenya:

  1. Baris 4 (ContextVar): satu slot penyimpanan bernama request_id dengan nilai default "-" untuk kode yang berjalan di luar request.
  2. Baris 7-10 (RequestIdFilter): logging.Filter menyuntikkan nilai konteks ke setiap LogRecord sebagai atribut request_id sebelum diformat — itulah mengapa placeholder %(request_id)s bisa dipakai.
  3. Baris 23 (request_id.set(...)): begitu pesanan ditangani, ID-nya dipasang di konteks; seluruh logger.info() dan logger.warning() setelah otomatis membawa tanda yang sama.

Di aplikasi web sungguhan, nilai request_id biasanya diisi uuid4() oleh middleware di awal setiap request, sehingga filter tidak perlu mengubah kode function mana pun.

Log Aggregation #

Log terstruktur baru benar-benar berdaya guna setelah terkumpul di satu tempat. Layanan log aggregation seperti ELK (Elasticsearch, Logstash, Kibana), Grafana Loki, atau produk berbayar seperti Datadog menampung log dari seluruh server, mengindeks field-field JSON, lalu menyediakan pencarian serupa query: “tampilkan semua "level": "ERROR" dengan "pesanan_id": 4128 dalam satu jam terakhir”. Format JSON satu-baris-per-event adalah bahasa pengantar yang dimengerti seluruh layanan tersebut; inilah alasan praktis mengapa structured logging menjadi standar industri.

Kita sudah membuat log yang mudah dicari. Chapter penutup section ini menyusun gambar besar lengkapnya: bagaimana log, metrics, dan trace bergabung menjadi observability.