Logger JSON lines pada chapter sebelumnya sudah menulis baris yang rapi, tetapi tiga field waktu, level, dan pesan masih terlalu miskin untuk menjawab pertanyaan nyata: siapa penggunanya, berapa lama prosesnya, dan permintaan mana yang sedang berjalan. Structured logging menyelesaikannya dengan satu disiplin sederhana: setiap baris log adalah kumpulan pasangan kunci-nilai yang bisa dicari, difilter, dan diolah mesin. Bab ini memperluas logger kita dengan array konteks, menetapkan konvensi penamaan field, mencatat exception secara terstruktur, menghubungkan baris-baris log lewat correlation ID, lalu mengurai hasilnya dengan grep dan jq.

Dari Teks Bebas ke Pasangan Kunci-Nilai #

Bedakan dua baris log berikut:

text
[2026-01-15 08:00:03] Query lambat untuk user 42 selama 812 ms
text
{"waktu":"2026-01-15T08:00:03+07:00","level":"warning","event":"query_lambat","user_id":42,"duration_ms":812}

Baris pertama enak dibaca manusia, tetapi mesin harus menebak strukturnya: apakah 42 itu user, produk, atau milidetik? Baris kedua menyatakan strukturnya sendiri — setiap nilai punya nama. Inilah inti structured logging: log yang tidak perlu diparse dengan tebak-tebakan regex, melainkan dibaca sebagai data. Imbasnya langsung terasa pada skala: dari satu juta baris, menarik semua kejadian milik user_id 42 atau semua query di atas 500 ms menjadi operasi satu filter, bukan ekspedisi arkeologi.

Kita perluas function logger dengan parameter $konteks bertipe array. Field dasar digabungkan dengan konteks melalui array_merge(), lalu seluruhnya di-encode sebagai satu baris JSON:

php
<?php
function catat(string $berkasLog, string $waktu, string $level, string $event, array $konteks = []): void
{
    $baris = array_merge(
        ["waktu" => $waktu, "level" => $level, "event" => $event],
        $konteks
    );
    file_put_contents(
        $berkasLog,
        json_encode($baris, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL,
        FILE_APPEND | LOCK_EX
    );
}

$berkasLog = "/tmp/php-course-terstruktur.log";
if (file_exists($berkasLog)) {
    unlink($berkasLog);
}

catat($berkasLog, "2026-01-15T08:00:00+07:00", "info", "login_berhasil", [
    "user_id" => 42,
    "ip" => "203.0.113.10",
]);
catat($berkasLog, "2026-01-15T08:00:03+07:00", "warning", "query_lambat", [
    "user_id" => 42,
    "duration_ms" => 812,
]);

echo file_get_contents($berkasLog);
unlink($berkasLog);

Output yang Dihasilkan:

text
{"waktu":"2026-01-15T08:00:00+07:00","level":"info","event":"login_berhasil","user_id":42,"ip":"203.0.113.10"}
{"waktu":"2026-01-15T08:00:03+07:00","level":"warning","event":"query_lambat","user_id":42,"duration_ms":812}

Mari kita bedah bagian-bagiannya:

  1. array_merge([...dasar], $konteks): tiga field dasar selalu hadir; konteks menambahkan field sesuai kejadian. Kejadian login membawa user_id dan ip, kejadian query lambat membawa user_id dan duration_ms — masing-masing hanya mencatat apa yang relevan.
  2. json_encode(..., JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES): flag pertama menjaga teks non-ASCII tetap terbaca; flag kedua membiarkan karakter / tampil apa adanya sehingga nilai seperti path URL tidak berubah menjadi \/.
  3. Nilai tetap menjaga tipenya: 42 dan 812 dikirim sebagai integer, bukan string — hasilnya "user_id":42 tanpa tanda kutip. Tipe yang terjaga membuat filter numerik seperti duration_ms > 500 bekerja benar di tahap parsing.
  4. file_get_contents() membuktikan round-trip: apa yang ditulis identik dengan apa yang terbaca — setiap baris adalah dokumen JSON yang valid.

Konvensi Penamaan Field #

Structured logging hanya berharga bila nama field-nya konsisten lintas kejadian. Bila satu programmer menulis userId, satu lagi user_id, dan yang ketiga pengguna, filter harus ditulis tiga kali. Konvensi minimal yang lazim dipakai ekosistem PHP:

FieldIsiContoh
waktuStempel waktu ISO 8601 dengan zona2026-01-15T08:00:00+07:00
levelTingkat keparahaninfo, warning, error
eventNama kejadian, snake_case bentuk verbalogin_berhasil, query_lambat
user_idPengenal pengguna pelaku kejadian42
request_idPengenal permintaan yang sedang diprosesreq-8f3a2b
duration_msDurasi dalam milidetik812

Dua aturan praktis menyertainya. Pertama, pilih satu gaya penamaan — snake_case adalah yang paling umum — lalu terapkan di seluruh basis kode. Kedua, tulis satuan ke dalam nama field (duration_ms, bukan duration), sehingga pembaca tidak perlu menebak apakah angka 812 itu milidetik atau mikrodetik.

Mencatat Exception Secara Terstruktur #

Saat exception ditangkap, pesannya saja tidak cukup — class dan lokasinya menentukan langkah perbaikan berikutnya. Kita kumpulkan semuanya sebagai sub-object exception, meniru struktur yang dipakai library logger besar:

php
<?php
function catatException(string $berkasLog, string $waktu, Throwable $kesalahan): void
{
    $baris = [
        "waktu" => $waktu,
        "level" => "error",
        "event" => "exception_terjadi",
        "pesan" => $kesalahan->getMessage(),
        "exception" => [
            "class" => $kesalahan::class,
            "file" => basename($kesalahan->getFile()),
            "line" => $kesalahan->getLine(),
        ],
    ];
    file_put_contents(
        $berkasLog,
        json_encode($baris, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL,
        FILE_APPEND | LOCK_EX
    );
}

function prosesPembayaran(int $orderId, string $metode): void
{
    if ($metode !== "transfer") {
        throw new InvalidArgumentException("Metode pembayaran '$metode' tidak didukung");
    }
}

$berkasLog = "/tmp/php-course-exception.log";
if (file_exists($berkasLog)) {
    unlink($berkasLog);
}

try {
    prosesPembayaran(1042, "kripto");
} catch (InvalidArgumentException $e) {
    catatException($berkasLog, "2026-01-15T08:05:00+07:00", $e);
}

echo file_get_contents($berkasLog);
unlink($berkasLog);

Output yang Dihasilkan:

text
{"waktu":"2026-01-15T08:05:00+07:00","level":"error","event":"exception_terjadi","pesan":"Metode pembayaran 'kripto' tidak didukung","exception":{"class":"InvalidArgumentException","file":".verify_tmp.php","line":25}}

Bagian penting dari pendekatan ini: ekspresi $kesalahan::class menghasilkan nama class exception (InvalidArgumentException), lalu getFile() dan getLine() menunjuk lokasi throw. Nama berkas dibungkus basename() agar log portabel — path absolut di mesin pengembangan tidak akan berarti di server produksi. Untuk kejadian yang lebih serius, getTraceAsString() bisa ditambahkan sebagai field tersendiri berisi jejak lengkap berformat teks; jejak itu dibaca manusia saat bedah kasus, bukan difilter mesin.

Correlation ID untuk Menelusuri Satu Permintaan #

Satu permintaan HTTP menyentuh banyak function, dan masing-masing mencatat log. Tanpa penghubung, baris-baris itu tersesat di antara ribuan permintaan lain yang berlangsung bersamaan. Solusinya adalah correlation ID: pengenal unik yang dibuat di pintu masuk permintaan lalu dibawa ke setiap baris log yang dihasilkannya:

php
<?php
function catatKorelasi(string $berkasLog, string $level, string $event, array $konteks): void
{
    $baris = array_merge(
        ["waktu" => "2026-01-15T08:10:00+07:00", "level" => $level, "event" => $event],
        $konteks
    );
    file_put_contents(
        $berkasLog,
        json_encode($baris, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL,
        FILE_APPEND | LOCK_EX
    );
}

function tanganiPermintaan(string $requestId, string $berkasLog): void
{
    catatKorelasi($berkasLog, "info", "request_mulai", ["request_id" => $requestId, "path" => "/checkout"]);
    validasiKeranjang($requestId, $berkasLog);
    catatKorelasi($berkasLog, "info", "request_selesai", ["request_id" => $requestId, "duration_ms" => 145]);
}

function validasiKeranjang(string $requestId, string $berkasLog): void
{
    catatKorelasi($berkasLog, "warning", "stok_menipis", ["request_id" => $requestId, "produk_id" => 77, "sisa" => 2]);
}

$berkasLog = "/tmp/php-course-korelasi.log";
if (file_exists($berkasLog)) {
    unlink($berkasLog);
}

tanganiPermintaan("req-8f3a2b", $berkasLog);
echo file_get_contents($berkasLog);
unlink($berkasLog);

Output yang Dihasilkan:

text
{"waktu":"2026-01-15T08:10:00+07:00","level":"info","event":"request_mulai","request_id":"req-8f3a2b","path":"/checkout"}
{"waktu":"2026-01-15T08:10:00+07:00","level":"warning","event":"stok_menipis","request_id":"req-8f3a2b","produk_id":77,"sisa":2}
{"waktu":"2026-01-15T08:10:00+07:00","level":"info","event":"request_selesai","request_id":"req-8f3a2b","duration_ms":145}

Tiga baris hasilnya memuat request_id yang sama persis: "req-8f3a2b". Dari value $requestId dibuat di tanganiPermintaan() — dalam aplikasi web nyata biasanya dari header X-Request-ID atau hasil bin2hex(random_bytes(8)) — lalu diteruskan sebagai argument ke validasiKeranjang() dan menempel di setiap catatKorelasi(). Waktu pada contoh sengaja dibekukan agar output konsisten; di produksi, stempel waktu tiap baris berbeda dan selisihnya menunjukkan konsumsi waktu tiap tahap. Dengan satu grep req-8f3a2b, seluruh perjalanan satu permintaan tersusun kembali menjadi kronologi utuh.

Mem-parse Log dengan grep dan jq #

Keunggulan JSON lines baru terasa ketika diuji. Berkas app.log pada bagian bawah halaman berisi empat baris log terstruktur — dua info, satu warning, satu error — persis seperti hasil logger di atas. Untuk menarik semua baris level error, grep cukup mencocokkan pasangan kunci-nilai mentahnya:

bash
grep '"level":"error"' app.log

Output yang Dihasilkan:

text
{"waktu":"2026-01-15T08:05:00+07:00","level":"error","event":"exception_terjadi","pesan":"Metode pembayaran 'kripto' tidak didukung","exception":{"class":"InvalidArgumentException","file":"pembayaran.php","line":9}}

Pola "level":"error" cocok karena json_encode() memang tidak menyisipkan spasi. Untuk pertanyaan yang lebih selektif, jq memahami struktur JSON secara penuh — filter select() menyaring baris, lalu ekspresi setelah pipa menentukan apa yang dicetak:

bash
jq -r 'select(.level == "warning") | .event' app.log

Output yang Dihasilkan:

text
query_lambat
bash
jq -c 'select(.user_id == 42) | .event' app.log

Output yang Dihasilkan:

text
"login_berhasil"
"query_lambat"
"logout"

Tiga contoh di atas memperlihatkan lapisan kemampuannya: grep mengambil baris utuh berdasarkan teks, jq dengan -r mencetak nilai field mentah, dan filter .user_id == 42 memilih baris yang memiliki field tersebut — perhatikan bahwa baris exception_terjadi tidak ikut karena memang tidak memuat user_id. Di atas lapisan ini, platform log seperti ELK atau Loki pada dasarnya melakukan hal yang sama pada skala cluster.

Retensi dan Batas Penyimpanan #

Log terstruktur mempermudah pencarian, tetapi juga menggoda untuk menyimpan segalanya selamanya — dan biaya penyimpanannya tumbuh linear mengikuti lalu lintas. Kebijakan retention menyeimbangkan keduanya: baris info mungkin cukup disimpan 14–30 hari, baris error 90 hari, sedangkan kejadian audit seperti transaksi mungkin dituntut regulasi untuk bertahun-tahun. Mekanismenya sudah kita bahas pada chapter Logging: rotasi berbasis ukuran atau tanggal, arsip terkompresi, dan penghapusan arsip yang melewati batas usia. Tentukan kebijakan ini sejak awal — bukan saat disk penuh — dan pastikan duration_ms serta request_id tetap tercatat konsisten, karena keduanya adalah field yang paling sering ditanyakan saat insiden terjadi.

Ringkasan #

Structured logging menjadikan setiap baris log sebagai data bernama: konteks digabung lewat array_merge(), tipe nilai terjaga oleh json_encode(), dan konvensi penamaan — user_id, request_id, duration_ms — menjaga konsistensi lintas kejadian. Exception dicatat lengkap dengan class, berkas, dan barisnya, sementara correlation ID menjahit baris-baris satu permintaan menjadi satu kronologi yang bisa direkonstruksi dengan grep dan jq. Dengan fondasi ini, pertanyaan berikutnya adalah soal tingkat kepatuhan: kapan sebuah kejadian layak dikategorikan warning dan bukan error — bahasan chapter Log Levels dan Context.

Referensi #