Log adalah data yang paling sering diabaikan padahal paling banyak tersedia: setiap request web, setiap galat aplikasi, dan setiap transaksi meninggalkan jejak baris teks. Mengubah tumpukan baris tersebut menjadi informasi — berapa persen request gagal, path mana yang paling sering dikunjungi, kapan lonjakan trafik terjadi — adalah pekerjaan parsing yang sangat cocok untuk PHP CLI. Pada Chapter ini kita akan mem-parsing access log bergaya Apache/Nginx dari baris mentah menjadi data terstruktur, lalu mengagregasinya, dan menutup dengan pola generator untuk berkas log berukuran besar.

Anatomi Access Log #

Apache dan Nginx secara bawaan menulis access log dalam format yang disebut combined. Perhatikan satu barisnya:

text
192.168.1.15 - budi [15/Jan/2026:08:30:02 +0700] "GET /produk/kopi HTTP/1.1" 200 5120 "https://example.com/" "Mozilla/5.0 (X11; Linux x86_64)"

Baris tersebut memuat sembilan bagian: alamat IP klien, identitas RFC 1413 (hampir selalu -), userid autentikasi, waktu request dalam kurung siku, baris request dalam tanda kutip, kode status HTTP, ukuran respons dalam byte, referer, dan user agent. Dua bagian terakhir adalah pembeda format combined dari format common yang lebih pendek. Waktu memakai format d/M/Y:H:i:s O — tanggal singkat Inggris diikuti offset zona waktu — format inilah yang nanti harus kita terjemahkan ke objek waktu PHP.

Parsing Satu Baris dengan Regex #

Karena formatnya berulang dan tidak sesederhana koma, alat yang tepat adalah preg_match() dengan named capture group — grup penangkap yang diberi nama melalui sintaks (?P<nama>...):

php
<?php
$baris = '192.168.1.15 - - [15/Jan/2026:08:30:02 +0700] "GET /produk/kopi HTTP/1.1" 200 5120';

$pola = '/^(?P<ip>\S+) \S+ \S+ \[(?P<waktu>[^\]]+)\] "(?P<metode>\S+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) (?P<ukuran>\S+)$/';

if (preg_match($pola, $baris, $m) === 1) {
    echo "IP     : {$m['ip']}\n";
    echo "Waktu  : {$m['waktu']}\n";
    echo "Metode : {$m['metode']}\n";
    echo "Path   : {$m['path']}\n";
    echo "Status : {$m['status']}\n";
    echo "Ukuran : {$m['ukuran']} byte\n";
}

Output yang Dihasilkan:

text
IP     : 192.168.1.15
Waktu  : 15/Jan/2026:08:30:02 +0700
Metode : GET
Path   : /produk/kopi
Status : 200
Ukuran : 5120 byte

Mari kita bedah polanya bagian demi bagian:

  1. (?P<ip>\S+): menangkap run karakter non-spasi pertama sebagai $m['ip'] — alamat IP klien.
  2. \S+ \S+ (tanpa nama): melewati field identitas dan userid yang hampir selalu -; tidak perlu menangkap data yang tidak dipakai.
  3. \[(?P<waktu>[^\]]+)\]: isi kurung siku ditangkap sebagai $m['waktu']; [^\]]+ berarti “semua karakter sampai tanda ] berikutnya”, aman dari isi yang bisa berubah panjang.
  4. "(?P<metode>\S+) (?P<path>\S+) [^"]*": bagian dalam tanda kutip dipecah menjadi metode, path, dan protokol yang sengaja dilewati.
  5. (?P<status>\d{3}): tepat tiga digit untuk kode status, dan (?P<ukuran>\S+) menangkap ukuran respons yang bisa berupa - ketika respons tidak bermuatan.

Tanda ^ di awal dan $ di akhir mengunci pola pada seluruh baris, mencegah kecocokan parsial yang menyesatkan. Berkat nama grup, kode pembaca hasil menggunakan $m['status'] — jauh lebih jelas daripada $m[9] yang menuntut kita menghitung posisi grup.

Agregasi Banyak Baris #

Parsing satu baris baru berguna ketika diulang untuk seluruh berkas. Pola berikut membaca banyak baris, menghitung permintaan per kode status, dan mengabaikan baris yang tidak cocok dengan pola:

php
<?php
$isiLog = <<<LOG
192.168.1.15 - - [15/Jan/2026:08:30:02 +0700] "GET /produk HTTP/1.1" 200 5120
192.168.1.20 - - [15/Jan/2026:08:30:05 +0700] "POST /login HTTP/1.1" 302 -
192.168.1.15 - - [15/Jan/2026:08:31:11 +0700] "GET /admin HTTP/1.1" 403 900
203.0.113.7 - - [15/Jan/2026:08:32:00 +0700] "GET /produk HTTP/1.1" 200 5110
192.168.1.20 - - [15/Jan/2026:08:33:45 +0700] "GET /halaman-tidak-ada HTTP/1.1" 404 300
LOG;

$pola = '/^(?P<ip>\S+) \S+ \S+ \[(?P<waktu>[^\]]+)\] "(?P<metode>\S+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) (?P<ukuran>\S+)/';

$jumlahPerStatus = [];
foreach (explode("\n", $isiLog) as $baris) {
    if (preg_match($pola, $baris, $m) !== 1) {
        continue;
    }
    $status = $m['status'];
    $jumlahPerStatus[$status] = ($jumlahPerStatus[$status] ?? 0) + 1;
}

ksort($jumlahPerStatus);
foreach ($jumlahPerStatus as $status => $jumlah) {
    echo "Status $status: $jumlah permintaan\n";
}

Output yang Dihasilkan:

text
Status 200: 2 permintaan
Status 302: 1 permintaan
Status 403: 1 permintaan
Status 404: 1 permintaan

Tiga hal penting terjadi di perulangan tersebut. Pertama, pemeriksaan preg_match($pola, $baris, $m) !== 1 dengan continue memastikan baris rusak atau format lain dilewati tanpa menghentikan proses. Kedua, ekspresi ($jumlahPerStatus[$status] ?? 0) + 1 menambah penghitung status tanpa perlu memeriksa dulu apakah kuncinya sudah ada. Ketiga, ksort() mengurutkan hasil berdasarkan kode status sehingga laporan mudah dibaca. Pola yang sama bisa menghitung permintaan per IP atau per path — cukup ganti kunci array dengan $m['ip'] atau $m['path'].

Mengubah Timestamp Log menjadi Objek Waktu #

Kolom waktu log berformat khusus, sehingga kita memakai DateTimeImmutable::createFromFormat() untuk menerjemahkannya. Contoh berikut memakai nilai beku agar outputnya deterministik:

php
<?php
$waktuLog = '15/Jan/2026:08:30:02 +0700';

$waktu = DateTimeImmutable::createFromFormat('d/M/Y:H:i:s O', $waktuLog);
if ($waktu === false) {
    echo "Format waktu tidak dikenali\n";
} else {
    echo $waktu->format('Y-m-d H:i:s') . "\n";
    echo $waktu->format('l, d F Y') . "\n";
    echo $waktu->getTimezone()->getName() . "\n";
}

Output yang Dihasilkan:

text
2026-01-15 08:30:02
Thursday, 15 January 2026
+07:00

Format d/M/Y:H:i:s O dipetakan persis mengikuti anatomi timestamp log: tanggal dua digit, singkatan bulan Inggris, tahun empat digit, lalu offset zona waktu +0700 yang ditangkap oleh O. Hasilnya adalah objek DateTimeImmutable berzona +07:00 yang siap diformat ulang, dibandingkan, maupun dikonversi zona. Dengan objek waktu ini, agregasi per jam atau per hari menjadi sederhana: cukup $waktu->format('Y-m-d H:00') sebagai kunci array penghitung. Pembahasan mendalam tentang pemformatan tanggal tersedia pada Section Date Time dan Localization, khususnya Chapter Parsing dan Formatting Dates.

Berkas Log Besar dengan Generator #

Berkas log produksi bisa berukuran gigabyte, jadi kita membutuhkan pembacaan baris demi baris yang rapi. Generator memungkinkan kita membungkus logika pembacaan menjadi perulangan foreach yang bersih tanpa memuat seluruh berkas ke memori:

php
<?php
function bacaBaris(string $path): Generator
{
    $handle = fopen($path, "r");
    try {
        while (($baris = fgets($handle)) !== false) {
            yield rtrim($baris, "\n");
        }
    } finally {
        fclose($handle);
    }
}

$path = "/tmp/access-besar.log";
$handle = fopen($path, "w");
for ($i = 0; $i < 20000; $i++) {
    $status = [200, 302, 404][$i % 3];
    $ip = "10.0.0." . ($i % 254 + 1);
    fwrite($handle, "$ip - - [15/Jan/2026:08:00:00 +0700] \"GET /halaman/$i HTTP/1.1\" $status 1024\n");
}
fclose($handle);

$totalBaris = 0;
$total404 = 0;
foreach (bacaBaris($path) as $baris) {
    $totalBaris++;
    if (str_contains($baris, " 404 ")) {
        $total404++;
    }
}

echo "Total baris: $totalBaris\n";
echo "Total 404  : $total404\n";

Output yang Dihasilkan:

text
Total baris: 20000
Total 404  : 6666

Bagian tengah hanya menyiapkan berkas uji 20.000 baris. Inti polanya ada di fungsi bacaBaris(): kata kunci yield mengembalikan satu baris setiap kali perulangan meminta nilai berikutnya, dan eksekusi fungsi berhenti sementara sampai permintaan itu datang — berkas dibaca sungguhan satu baris per satu baris. Blok finally menjamin fclose() tetap berjalan meskipun konsumen generator berhenti lebih awal. Di perulangan utama, kita bisa menaruh regex chapter ini menggantikan str_contains() untuk agregasi yang lebih kaya.

Entri Multibaris dan Format Lain #

Dua situasi menuntut penanganan khusus. Pertama, log aplikasi modern sering memuat entri multibaris — stack trace exception menempati belasan baris setelah baris galat utamanya. Strateginya adalah mengenali baris pembuka (misalnya yang memuat level ERROR dan timestamp) lalu mengumpulkan baris-baris berikutnya yang tidak cocok pola pembuka ke dalam satu entri sampai baris pembuka berikutnya muncul. Kedua, banyak aplikasi kini menulis log terstruktur satu baris per event dalam format JSON (JSON lines); untuk format tersebut, kita tidak perlu regex sama sekali — cukup json_decode() per baris sebagaimana dibahas pada Chapter Membaca dan Menulis JSON.

Ringkasan #

Access log diparsing dengan preg_match() menggunakan named capture group, diagregasi dengan array asosiatif dan operator ??, dan timestamp-nya diterjemahkan dengan DateTimeImmutable::createFromFormat(). Generator menjaga konsumsi memori tetap satu baris untuk berkas log berukuran besar, sementara entri multibaris dan JSON lines membutuhkan strategi khusus. Data hasil parsing ini baru benar-benar berguna apabila isinya bisa dipercaya — pada Chapter berikutnya kita akan membangun lapisan validasi data untuk memeriksa kelengkapan, tipe, dan koherensi sebelum data diproses lebih lanjut.

Referensi #