Log adalah catatan yang ditulis aplikasi sambil jalan, satu baris per kejadian: kapan terjadi, seberapa penting, apa yang terjadi, dan konteksnya. Bayangin buku laporan shift di minimarket. Tiap kejadian ditulis dengan jam, tanda pentingnya (biasa, perhatian, gawat), dan detailnya. Manajer yang sibuk cuma membaca yang bertanda gawat. Waktu ada selisih kas jam tiga sore, dia membaca semua catatan di sekitar jam itu. Membaca log di server persis seperti itu: saring menurut tingkat penting, lalu menurut waktu.
catatan-api sekarang jalan sebagai container di web-1 (pelajaran Docker Compose dan CI/CD). Waktu masih jalan langsung lewat systemd, log-nya ditulis ke storage/logs/app.log dan kamu membacanya dengan tail dan grep (pelajaran Command Line Essentials). Di pelajaran ini log-nya kita rapikan: tiap baris berbentuk JSON, punya level, dan ditulis ke stdout.
🧪 Tebakan cepat: di Lab ada 16 baris log catatan-api: 3 debug, 7 info, 3 warn, 3 error. Kamu pasang filter level
warn. Berapa baris yang tampil, 3 atau 6? Jawabannya ada di bagian "Level: seberapa penting".
Satu baris log, empat bagian
Ini satu baris yang ditulis catatan-api setelah request selesai:
{"time":"2026-10-09T10:41:02.114Z","level":"info","msg":"request completed","reqId":"r-101","method":"GET","path":"/catatan","status":200,"ms":38,"userId":42}
time: kapan, dalam format ISO 8601. HurufZartinya UTC, jadi 10:41 UTC = 17:41 WIB. Server dan layanan log sebaiknya memakai UTC semua, supaya jam dari banyak sumber bisa diurutkan.level: seberapa penting kejadiannya (bagian berikutnya).msg: apa yang terjadi, dengan kalimat yang tetap. Semua request yang selesai menulis"request completed", jadi kamu bisa menghitungnya.- konteks: detail yang berubah-ubah, masing-masing di field sendiri.
reqId(request ID) menyambungkan semua baris dari satu request,userIdmenunjukkan siapa yang kena.
Log seperti ini disebut structured logging: tiap baris berformat terstruktur (JSON) yang bisa di-query per field, misalnya "semua baris dengan status 500 dan path /catatan". Bandingkan dengan teks biasa User 42 GET /catatan 200 38ms: untuk mengambil angka 38 kamu harus menebak posisinya. Jadi minimal tiap baris punya timestamp, level, message, dan konteks (request ID, user ID).
Level: seberapa penting
| Level | Dipakai untuk | Contoh di catatan-api |
|---|---|---|
debug |
detail untuk developer saat melacak bug | isi query SQL, cache hit |
info |
kejadian normal yang ingin dihitung | request selesai, user login |
warn |
belum gagal, tapi tanda bahaya | query 870 ms, pool koneksi 9 dari 10 |
error |
satu operasi gagal, app masih jalan | koneksi database timeout, request 500 |
fatal |
app gak bisa lanjut, proses berhenti | DATABASE_URL kosong saat start |
Urutannya debug < info < warn < error < fatal. Logger diberi level minimum, biasanya dari environment variable LOG_LEVEL. Baris di bawah minimum langsung dibuang, sisanya dicetak. Filter warn berarti warn dan semua yang lebih penting, jadi 3 warn + 3 error = 6 baris. Itu jawaban tebakannya. Di production biasanya info, di laptop debug.
Sebagian sistem punya delapan level, misalnya syslog dan Monolog (dipakai Laravel): debug, info, notice, warning, error, critical, alert, emergency. Tiga yang terakhir menandai kegagalan serius yang harus segera ditangani, misalnya database utama mati atau seluruh app down.
Logger kecil dengan level minimum
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
function createLogger(minLevel) {
function log(level, message, data) {
if (LEVELS[level] < LEVELS[minLevel]) return null; // di bawah minimum: dibuang
const entry = { level, message, data, timestamp: new Date().toISOString() };
console.log(JSON.stringify(entry)); // satu baris JSON ke stdout
return entry;
}
return {
debug: (message, data) => log("debug", message, data),
info: (message, data) => log("info", message, data),
warn: (message, data) => log("warn", message, data),
error: (message, data) => log("error", message, data),
};
}
| Panggilan | Perbandingan | Hasil |
|---|---|---|
createLogger("info").info("test", { a: 1 }) |
1 < 1? gak | object { level: "info", message: "test", data: { a: 1 }, timestamp } |
createLogger("info").debug("query", {}) |
0 < 1? ya | null |
createLogger("warn").info("test", {}) |
1 < 2? ya | null |
createLogger("error").warn("warning", {}) |
2 < 3? ya | null |
LEVELSmengubah nama level jadi angka, supaya bisa dibandingkan dengan<.- Keempat method memanggil satu function
log, jadi aturan level cukup ditulis sekali. - Object-nya disusun dengan urutan key
level,message,data,timestamp.JSON.stringifymengikuti urutan itu. - Di app sungguhan,
minLeveldiambil dariprocess.env.LOG_LEVEL. Di Node biasanya dipakai library pino, di LaravelLog::info("...", [...]). Idenya sama.
Di latihan, timestamp ditulis tetap "2024-01-01" supaya hasilnya bisa dicek, dan kamu cukup me-return object-nya.
Request log catatan-api dan cara memfilternya
Pukul 10:42 UTC beberapa user melapor catatannya gagal dimuat. Lab memuat 16 baris log dari 10:41 sampai 10:44. Untuk mencari penyebabnya, ambil baris warn ke atas di menit 10:42:
function filterLogs(lines, minLevel, from, to) {
return lines
.map((line) => JSON.parse(line))
.filter((log) => LEVELS[log.level] >= LEVELS[minLevel])
.filter((log) => {
const t = Date.parse(log.time);
return t >= Date.parse(from) && t < Date.parse(to);
});
}
filterLogs(lines, "warn", "2026-10-09T10:42:00.000Z", "2026-10-09T10:43:00.000Z");
| time | level | msg | konteks |
|---|---|---|---|
| 10:42:03.215 | warn | db pool almost full | used 9, max 10 |
| 10:42:07.402 | error | db connection timeout | reqId r-105, timeoutMs 5000 |
| 10:42:07.405 | error | request failed | reqId r-105, GET /catatan, 500, 5.003 ms |
| 10:42:19.044 | error | request failed | reqId r-107, POST /catatan, 500, 5.001 ms |
JSON.parsemengubah tiap baris jadi object, supaya bisa dibaca per field.- Filter pertama memakai
>=: level yang diminta ikut masuk, bersama yang di atasnya. Date.parsemengubah waktu jadi angka milidetik. Jendelanya[from, to):fromikut,togak, jadi menit-menit yang bersebelahan gak tumpang tindih.
Dari 16 baris tersisa 4, dan urutannya bercerita: pool koneksi hampir penuh, satu koneksi timeout setelah 5 detik, lalu dua request jadi 500. reqId r-105 menyambungkan error database dengan request yang gagal. Di menit 10:41 ada warn query 870 ms: query lambat itu yang mulai menahan koneksi.
Yang gak boleh masuk log
Log dibaca banyak orang dan disalin ke banyak tempat: disk server, layanan log, backup. Jangan pernah menulis:
- password, PIN, kode OTP;
- token, API key, isi header
Authorization, cookie session; - nomor kartu lengkap dan isi file
.env.
Data pribadi seperti email dan nomor HP ditulis seperlunya saja, atau disamarkan. Kesalahan klasiknya me-log seluruh request body. Baris login gagal di Lab membawa password Sari sebagai teks biasa. Hapus di sumbernya, sebelum baris dicetak. Di pino caranya opsi redact:
const logger = pino({ level: process.env.LOG_LEVEL || "info", redact: ["body.password", "req.headers.authorization"] });
Field yang disebut ditulis sebagai "[Redacted]". Pelajaran Security Logging & Monitoring di track Security membahas sisi sebaliknya: kejadian apa yang wajib dicatat untuk keamanan, seperti login gagal dan perubahan hak akses.
Di container: tulis ke stdout
App di container sebaiknya gak menulis file log sendiri. Tulis ke stdout (yang dicetak console.log), lalu biarkan platform yang menyimpan dan mengirimnya. Docker menyimpan stdout tiap container, dan kamu membacanya dengan:
deploy@web-1:/srv/catatan-api$ docker compose logs --since 10m app
app-1 | {"time":"2026-10-09T10:41:02.114Z","level":"info","msg":"request completed","reqId":"r-101", ...}
-f mengikuti log secara live, --no-log-prefix membuang awalan app-1 | . Supaya disk gak penuh, batasi ukurannya di docker-compose.yml:
services:
app:
image: ghcr.io/budi/catatan-api:e4b2d90
environment:
LOG_LEVEL: info
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Log yang disimpan Docker ikut hilang saat container dihapus, dan tiap server punya log sendiri. Begitu app jalan di banyak container atau server, log dikirim oleh shipper (Vector, Fluent Bit, Grafana Alloy) ke satu tempat terpusat seperti Loki, Elasticsearch, atau CloudWatch Logs. Gunanya: request yang lewat beberapa service bisa disambungkan lewat request ID, log tetap ada walau container-nya sudah hilang, dan semuanya di-query dari satu tempat. Polanya dibahas lebih dalam di pelajaran Structured Logging Patterns dan OpenTelemetry Fundamentals.
⚠️ Jebakan yang sering ketemu
- Pesan yang dirangkai dari variabel.
console.log("User " + id + " gagal bayar " + total)bikin tiap baris unik, jadi gak bisa dihitung atau difilter. Pesan tetap, detail di field sendiri. LOG_LEVEL=debugdibiarkan di production. Volume log membengkak, biaya penyimpanan naik, dan detail sensitif ikut tercetak. Turunkan ke debug sebentar saat menyelidiki, lalu kembalikan.- Me-log request body atau header utuh. Password dan token ikut tercetak. Redact di logger, jangan di dashboard.
- Di latihan:
<=atau>di tempat yang salah, atau urutan key berbeda.createLogger("warn").info(...)harusnull(bukanundefined), dan object-nya harus{ level, message, data, timestamp }persis dengan urutan itu, karena hasilnya dibandingkan sebagai teks JSON.
TL;DR: Log yang baik satu baris JSON per kejadian dengan time (ISO 8601, UTC), level, msg yang tetap, dan konteks seperti reqId dan userId. Level urut debug < info < warn < error < fatal. Level minimum dari LOG_LEVEL membuang yang di bawahnya, dan filter warn berarti warn ke atas. Di syslog dan Monolog, critical, alert, dan emergency menandai kegagalan serius yang harus segera ditangani. Untuk mencari penyebab masalah, saring per level lalu per jendela waktu, dan sambungkan baris lewat request ID. Jangan pernah me-log password, token, header Authorization, atau cookie: redact di logger sebelum dicetak. Di container, tulis ke stdout, baca dengan docker compose logs, batasi ukurannya, dan kirim ke log terpusat (Loki, Elasticsearch, CloudWatch) begitu server-nya lebih dari satu.