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:
[2026-01-15 08:00:03] Query lambat untuk user 42 selama 812 ms{"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.
Menulis Log Terstruktur dengan Konteks #
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
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:
{"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:
array_merge([...dasar], $konteks): tiga field dasar selalu hadir; konteks menambahkan field sesuai kejadian. Kejadian login membawauser_iddanip, kejadian query lambat membawauser_iddanduration_ms— masing-masing hanya mencatat apa yang relevan.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\/.- Nilai tetap menjaga tipenya:
42dan812dikirim sebagai integer, bukan string — hasilnya"user_id":42tanpa tanda kutip. Tipe yang terjaga membuat filter numerik sepertiduration_ms > 500bekerja benar di tahap parsing. 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:
| Field | Isi | Contoh |
|---|---|---|
waktu | Stempel waktu ISO 8601 dengan zona | 2026-01-15T08:00:00+07:00 |
level | Tingkat keparahan | info, warning, error |
event | Nama kejadian, snake_case bentuk verba | login_berhasil, query_lambat |
user_id | Pengenal pengguna pelaku kejadian | 42 |
request_id | Pengenal permintaan yang sedang diproses | req-8f3a2b |
duration_ms | Durasi dalam milidetik | 812 |
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
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:
{"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
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:
{"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:
grep '"level":"error"' app.logOutput yang Dihasilkan:
{"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:
jq -r 'select(.level == "warning") | .event' app.logOutput yang Dihasilkan:
query_lambatjq -c 'select(.user_id == 42) | .event' app.logOutput yang Dihasilkan:
"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.