Structured logging berarti tiap baris log adalah satu objek JSON dengan field yang namanya disepakati semua service, supaya log dari nginx, catatan-api, dan service lain bisa dicari, digabung, dan dihitung seperti tabel. Bayangin puskesmas. Kalau tiap perawat menulis kalimat bebas di buku, pertanyaan "berapa pasien poli gigi yang menunggu lebih dari satu jam hari ini?" makan waktu seharian. Kalau semua perawat mengisi formulir yang sama (nama, jam datang, poli, lama tunggu) dan tiap formulir ditempeli nomor antrean yang sama dari loket sampai apotek, pertanyaan itu terjawab dalam semenit, dan perjalanan satu pasien bisa diikuti dari meja ke meja. Formulir itu skema log-mu, nomor antreannya request_id.
Di pelajaran Application Logging, catatan-api sudah menulis JSON ke stdout. Sekarang nginx di depannya juga menulis JSON, dan kita mulai bertanya ke log seperti ke database.
🧪 Tebakan cepat: di Lab ada dua baris ini. nginx:
"timestamp":"2026-10-12T10:16:07+07:00". catatan-api:"timestamp":"2026-10-12T03:16:07.402Z". Kejadiannya beda tujuh jam, atau sebenarnya satu request yang sama? Jawabannya ada di bagian "request_id: satu nomor dari nginx sampai catatan-api".
Satu skema untuk semua service
Bandingkan dua cara mencatat login yang gagal:
console.log("User 123 failed login from 192.168.1.1");
logger.warn("login failed", { user_id: 123, ip: "192.168.1.1" });
Baris pertama cuma kalimat. Untuk mengambil 123 kamu harus menebak posisinya di teks. Baris kedua menyimpan tiap informasi di field terpisah, jadi bisa di-query dan di-filter: "semua login failed dari ip ini". Level warn gak otomatis mengirim notifikasi, ukurannya malah sedikit lebih besar, dan isinya gak terenkripsi. Kelebihannya cuma satu, tapi itu yang penting: datanya bisa ditanya.
Begitu ada lebih dari satu service, field-nya harus sama nama, sama arti. Di Application Logging, catatan-api menulis reqId dan time. Kalau nginx menulis request_id dan service lain requestId, satu query gak akan pernah menemukan ketiganya. Tim catatan-api menyepakati lima field inti di setiap baris:
| Field | Isi | Contoh |
|---|---|---|
timestamp |
waktu ISO 8601 dengan zona waktu | 2026-10-12T03:15:02.114Z |
level |
info, warn, error |
info |
service |
siapa yang menulis | catatan-api, nginx |
request_id |
nomor request (bagian berikutnya) | 5cba9e2c945971c5e3fb8c7b70a6c075 |
msg |
kalimat tetap, bukan dirangkai | request completed |
catatan-api menambah user_id (null kalau belum login), lalu field per kejadian: method, route, status, duration_ms. nginx menulis path (path mentah seperti /catatan/91) dan request_time (detik). Field tambahan boleh beda antar service, asal namanya jujur: route di catatan-api adalah pola /catatan/:id, jadi namanya beda dengan path mentah milik nginx.
Level dipakai dengan arti yang sama juga. Request 2cfc8f60 di Lab (Lab menampilkan 8 karakter pertama request_id): query pertama lambat (2.000 ms) sehingga di-retry sekali, lalu berhasil. Itu warn, bukan error: belum ada yang gagal, tapi polanya layak dipantau sebelum jadi error sungguhan. error untuk operasi yang gagal (request 500), info untuk kejadian normal.
request_id: satu nomor dari nginx sampai catatan-api
Satu request lewat beberapa tempat, dan tiap tempat menulis log sendiri. Supaya semuanya bisa dikumpulkan, nomor yang sama dibawa di header ke setiap service. Nomor ini disebut correlation ID atau request ID. nginx membuatnya, karena nginx yang pertama menerima request:
map $status $log_level {
~^5 error;
~^4 warn;
default info;
}
log_format json_log escape=json '{"timestamp":"$time_iso8601","level":"$log_level",'
'"service":"nginx","request_id":"$request_id","msg":"access",'
'"method":"$request_method","path":"$uri","status":$status,"request_time":$request_time}';
server {
server_name catatan.example.com;
access_log /var/log/nginx/catatan.access.log json_log;
location / {
proxy_set_header X-Request-Id $request_id;
proxy_pass http://127.0.0.1:3000;
}
}
$request_idadalah 32 karakter hex acak yang dibuat nginx untuk tiap request.proxy_set_header X-Request-Idmeneruskan nomor itu ke catatan-api sebagai header.mapmengubah status jadi level: 5xxerror, 4xxwarn, sisanyainfo.escape=jsonmembuat nilai yang mengandung tanda kutip tetap jadi JSON yang sah.
Di catatan-api, middleware pertama membaca header itu (atau membuat nomor baru kalau request datang tanpa nginx) dan membuat child logger: logger yang otomatis menempelkan request_id ke setiap baris.
const crypto = require("node:crypto");
const logger = require("./logger");
app.use((req, res, next) => {
req.requestId = req.get("x-request-id") || crypto.randomUUID().replaceAll("-", "");
req.log = logger.child({ request_id: req.requestId, user_id: null });
res.set("X-Request-Id", req.requestId); // user bisa menyebut nomor ini saat lapor
next();
});
// setelah token dicek: req.log = req.log.child({ user_id: user.id });
// memanggil service lain: fetch(url, { headers: { "X-Request-Id": req.requestId } });
Sekarang dua baris di tebakan tadi bisa dicek. Keduanya membawa request_id e6ccb0bf4c98f527e3af585fbce90221, jadi itu satu request yang sama. 10:16:07+07:00 (WIB, zona waktu server web-1) dan 03:16:07.402Z (UTC, zona waktu container) adalah detik yang sama. nginx cuma mencatat sampai detik, catatan-api sampai milidetik. Itu jawaban tebakannya. Pelajarannya dua: cocokkan pakai request_id, dan urutkan waktu pakai Date.parse, jangan sebagai teks.
Query log sungguhan
Lab memuat 21 baris dari Senin, 12 Oktober 2026, pukul 10:15 sampai 10:16 WIB: 8 request, 10 baris info, 5 warn, 6 error. Sari melapor simpan catatan gagal. Karena tiap baris JSON, log bisa diolah dengan JavaScript biasa:
const logs = lines.map((line) => JSON.parse(line));
// 1. Semua baris dari request Sari, urut waktu
const requestSari = logs
.filter((log) => log.request_id === "e6ccb0bf4c98f527e3af585fbce90221")
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
// 2. Request dan error 5xx per route, dari baris ringkasan catatan-api
const perRoute = {};
for (const log of logs) {
if (log.service !== "catatan-api" || log.status === undefined) continue;
const key = log.method + " " + log.route;
if (!perRoute[key]) perRoute[key] = { request: 0, error5xx: 0, terlama: 0 };
perRoute[key].request++;
if (log.status >= 500) perRoute[key].error5xx++;
perRoute[key].terlama = Math.max(perRoute[key].terlama, log.duration_ms);
}
Query pertama mengembalikan 4 baris yang bercerita: db pool exhausted (10 dari 10 koneksi terpakai), access nginx status 500, db query failed dengan error timeout exceeded when trying to connect, lalu request completed status 500 setelah 5.004 ms. Query kedua menghasilkan (diurutkan dari error terbanyak):
| Route | Request | Error 5xx | Terlama |
|---|---|---|---|
POST /catatan |
3 | 2 | 5.004 ms |
GET /catatan |
2 | 0 | 2.140 ms |
POST /auth/login |
1 | 0 | 9 ms |
GET /catatan/populer |
1 | 0 | 4 ms |
GET /catatan/:id |
1 | 0 | 12 ms |
JSON.parsemengubah tiap baris jadi object yang bisa dibaca per field.- Kunci grup memakai
route(pola), bukan path mentah. Kalau pakai path,/catatan/91dan/catatan/92jadi dua baris tabel yang berbeda. if (!perRoute[key])membuat isi awal grup sekali saja, saat route itu pertama kali muncul.
Di Loki, query yang sama ditulis dengan LogQL: {service="catatan-api"} | json | status >= 500. Idenya identik: parse JSON, lalu filter per field.
Sampling: simpan sebagian, jangan buang error
1.000 request per menit berarti 1,44 juta request sehari, sekitar 3,7 juta baris log, kira-kira 940 MB per hari. Sebagian besar isinya info dari request yang sukses dan mirip satu sama lain. Sampling berarti cuma menyimpan sebagian. Aturannya dua:
function bucket(id) {
let hash = 0;
for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) % 100;
return hash;
}
function simpan(log, persenInfo) {
if (log.level === "warn" || log.level === "error") return true; // gak pernah dibuang
return bucket(log.request_id) < persenInfo;
}
warndanerrorselalu disimpan. Justru itu yang kamu cari saat ada masalah.- Keputusan untuk
infodiambil darirequest_id, bukan diundi per baris.bucketadalah hash yang sama dengan bucket di pelajaran Feature Flags: id yang sama selalu dapat angka yang sama. Jadi satu request yang terpilih tersimpan lengkap, dari nginx sampai catatan-api.
Di Lab, sampling info 10% menyimpan 13 dari 21 baris: semua 11 baris warn dan error, plus 2 baris request ff715f1a (bucket 8). Di traffic sungguhan, yang 97%-nya info, hasilnya tinggal sekitar 13%, kira-kira 120 MB per hari. Aturan ini bisa dijalankan di logger, atau di shipper sebelum log dikirim. Vector, misalnya, punya transform sample dengan key_field untuk memilih per request_id.
Redaksi PII sebelum baris ditulis
PII (personally identifiable information) adalah data yang menunjuk ke orang tertentu: nama, email, nomor HP, alamat, NIK, alamat IP. Di Indonesia, pengelolaannya diatur UU Pelindungan Data Pribadi (UU 27/2022). Log disalin ke banyak tempat: disk, Loki, backup. Jadi PII harus dibersihkan di logger, sebelum baris ditulis. Menyamarkannya di dashboard sudah terlambat.
Baris login failed di Lab lahir dari req.log.warn("login failed", req.body): email dan password Dewi ikut tercetak. Logger catatan-api membersihkannya:
function redact(fields) {
const out = { ...fields };
if ("password" in out) out.password = "[Redacted]";
if (typeof out.email === "string") {
const [nama, domain] = out.email.split("@");
out.email = nama[0] + "***@" + domain; // [email protected] jadi d***@example.com
}
return out;
}
Tiga cara, dari yang paling aman: buang (password, token, header Authorization: gak pernah perlu ada di log), samarkan (email cukup d***@example.com untuk debugging), dan ganti dengan id (user_id: 7 alih-alih email, karena id bisa dicocokkan ke database oleh orang yang berhak). Paling aman lagi: jangan pernah me-log req.body utuh, sebutkan field yang boleh masuk.
⚠️ Jebakan yang sering ketemu
- Nama field beda antar service.
reqIddi satu service,request_iddi service lain: query-mu diam-diam melewatkan separuh log. Sepakati skema, tulis di README tim. - Mengurutkan waktu sebagai teks.
10:16:07+07:00dan03:16:07.402Zadalah detik yang sama. Urutkan denganDate.parse, atau seragamkan semua ke UTC. - Sampling diundi per baris. Request jadi bolong: baris nginx tersimpan, baris catatan-api-nya hilang. Putuskan per
request_id, dan jangan pernah membuangwarndanerror. - Redaksi di dashboard. Password sudah tersimpan di disk dan backup. Redaksi di logger, sebelum baris ditulis.
TL;DR: Structured logging = satu objek JSON per baris dengan skema yang disepakati semua service: timestamp (ISO 8601 dengan zona), level, service, request_id, msg yang tetap, lalu field kejadian seperti user_id, route, status, duration_ms. Field terpisah membuat log bisa di-query dan di-filter. Retry yang akhirnya berhasil itu warn, request 500 itu error. nginx membuat $request_id dan meneruskannya lewat header X-Request-Id, catatan-api menempelkannya ke setiap baris lewat child logger, jadi satu filter mengumpulkan semua baris satu request dari semua service. Query-nya bisa JavaScript biasa (filter, grup per route) atau LogQL di Loki. Sampling info per request_id, simpan semua warn dan error. Redaksi PII di logger: buang password dan token, samarkan email, pakai user_id.