Referensi cepat observability: structured logging, Loki stack, metrics Prometheus, dashboard Grafana, alerting rules, dan SLO dengan error budget. Perfect buat DevOps engineer.
Monitoring adalah mengumpulkan data terstruktur lalu bertindak saat threshold terlampaui. Observability adalah properti sistem: seberapa baik kamu bisa menjawab pertanyaan baru tentang sistem dari output yang dihasilkannya, tanpa deploy ulang atau tambah logging baru.
| Aspek | Monitoring | Observability |
|---|---|---|
| Pertanyaan | "Apakah sistem sehat?" | "Kenapa sistem tidak sehat?" |
| Data | Metrik utamanya | Metrik, log, dan trace bersamaan |
| Sifat | Threshold dan alarm | Eksploratif saat debugging |
| Waktu pakai | Sebelum dan saat insiden | Saat mencari akar masalah |
| Sinyal | Menjawab | Contoh pertanyaan |
|---|---|---|
| Metrics | Seberapa sering dan seberapa parah | "Error rate checkout berapa persen?" |
| Logs | Apa yang terjadi persisnya | "Request ord_123 kenapa gagal?" |
| Traces | Di mana waktunya habis | "Checkout lambat gara-gara service mana?" |
Ketiganya saling melengkapi. Alert datang dari metrics, investigasi dimulai dari trace, dan detail kronologi ada di log. Buat detail instrumentasi OpenTelemetry (SDK, span, propagation, sampler), lihat cheat sheet /cheat-sheets/opentelemetry.
Log teks bebas itu enak dibaca manusia, tapi susah diquery. Log JSON bisa diparse jadi field: level, message, user_id, duration_ms. Saat insiden, kamu filter level=error AND service=checkout dalam hitungan detik, bukan grep manual satu per satu di dua puluh server.
# Susah diquery
2026-09-19 10:15:32 ERROR payment failed for user 42
# Siap diquery dan diparse
{"ts":"2026-09-19T10:15:32Z","level":"error","msg":"payment_failed","user_id":42,"order_id":"ord_123","amount":150000,"duration_ms":842,"service":"checkout"}| Level | Makna | Contoh |
|---|---|---|
| TRACE | Alur internal detail | Hanya untuk development lokal |
| DEBUG | Diagnostik teknis | Query SQL yang jalan, matikan di produksi |
| INFO | Event bisnis normal | Order dibuat, request selesai |
| WARN | Anomali yang pulih sendiri | Retry sukses setelah gagal sekali |
| ERROR | Operasi gagal, butuh perhatian | Pembayaran gagal, pesan queue ditolak |
| FATAL | Aplikasi tidak bisa lanjut | Konfigurasi hilang, koneksi database awal gagal |
Aturan praktisnya begini: ERROR berarti "kalau ini terus terjadi, seseorang harus turun tangan". Kalau ERROR di kode kamu cuma jadi catatan dan nggak ada yang peduli, turunkan ke WARN. Jangan bikin orang on-call trauma karena alert ERROR yang tidak butuh aksi.
pino (Node.js), paling cepat dan default di banyak proyek:
const pino = require("pino");
const logger = pino({
level: process.env.LOG_LEVEL || "info",
base: { service: "checkout-api", env: process.env.NODE_ENV },
});
logger.info({ user_id: 42, order_id: "ord_123", duration_ms: 120 }, "order_completed");structlog (Python):
import structlog
logger = structlog.get_logger()
logger.info("order_completed", user_id=42, order_id="ord_123", duration_ms=120)zap (Go):
import "go.uber.org/zap"
logger, _ := zap.NewProduction()
defer logger.Sync()
logger.Info("order_completed",
zap.Int("user_id", 42),
zap.String("order_id", "ord_123"),
)Konsistensi nama field itu wajib: user_id semua, jangan campur userId, UserID, dan user. Tool query tidak akan bisa menggeneralisasi kalau skema tiap service beda.
Setiap request dapat ID unik yang dibawa ke semua log di semua service yang dilalui. Log dengan request_id sama bisa direkonstruksi jadi kronologi lengkap. Kalau kamu sudah pakai OpenTelemetry, propagasikan trace_id dan taruh di setiap baris log; itu kunci menghubungkan log dengan trace.
// Middleware Express: satu child logger per request
app.use((req, res, next) => {
req.log = logger.child({ request_id: crypto.randomUUID() });
next();
});
app.get("/orders/:id", (req, res) => {
req.log.info({ order_id: req.params.id }, "get_order");
res.json(getOrder(req.params.id));
});Loki adalah log aggregation buatan Grafana. Bedanya dengan Elasticsearch: Loki hanya mengindeks label, bukan seluruh isi log. Isi log dikompresi jadi chunk di object storage. Hasilnya biaya storage jauh lebih murah, cocok buat tim yang nggak mau bangun cluster log mahal.
Komponen utamanya:
Konfigurasi Alloy untuk kumpulkan log container Docker:
discovery.docker "containers" {
host = "unix:///var/run/docker.sock"
}
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.docker.containers.targets
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}Untuk kasus simpel, ada juga Docker logging driver resmi:
docker plugin install loki/docker-driver:latest --alias loki
docker run --log-driver=loki \
--log-opt loki-url="http://loki:3100/loki/api/v1/push" \
--log-opt labels="service" \
-l service="checkout-api" \
nginx:1.27Label itu index, jadi jumlahnya sedikit dan nilainya terbatas: cluster, env, service, job. Jangan pernah pakai user_id, request_id, atau email sebagai label. Nilainya tak terbatas (high cardinality) dan bikin index Loki membengkak sampai tidak bisa dipakai. Data seperti itu tetap ada di dalam baris log, tinggal difilter sebagai konten.
# Benar: nilai label sedikit dan tetap
{cluster="prod-1", env="prod", service="checkout"}
# Salah: user_id dan trace_id jangan jadi label
{user_id="42", trace_id="abc123def"}# Stream selector plus filter kata
{service="checkout"} |= "error"
# Filter regex lalu parse isi JSON
{service="api"} |~ "timeout|deadline" | json
# Akses field hasil parse
{service="api"} | json | level="error" | line_format "{{.msg}}"
# Parse format logfmt lalu bandingkan angka
{service="gateway"} | logfmt | status >= 500Urutan operasinya penting: selector label dulu (murah, pakai index), filter baris kemudian (scan), parse terakhir. Ini kebalikan dari kebiasaan grep: di Loki, makin sempit stream selector makin cepat query kamu.
Log bisa dihitung jadi metrik langsung dari Loki:
# Baris error per service per detik
sum by (service) (rate({env="prod"} |= "error" [5m]))
# Latensi p99 dari field duration_ms
quantile_over_time(0.99,
{service="api"} | json | unwrap duration_ms [1m]
) by (service)Trik ini berguna buat service lama yang belum ekspor metrik tapi sudah log JSON terstruktur.
limits_config:
retention_period: 168h # 7 hari; naikkan untuk kebutuhan audit
compactor:
retention_enabled: trueLog 30 hari di object storage itu murah; log 30 hari di SSD itu mahal. Kalau ada kebutuhan audit panjang, arsipkan ke S3 atau GCS, jangan panjangkan retensi aktif sembarangan.
| Aspek | Loki | ELK (Elasticsearch) |
|---|---|---|
| Indexing | Hanya label | Full-text semua field |
| Biaya storage | Rendah, kompresi tinggi | Tinggi |
| Pencarian full-text bebas | Terbatas, linear scan | Kuat |
| Setup | Ringan, satu binary plus object storage | Berat, cluster tersendiri |
| Cocok untuk | Tim kecil sampai menengah, budget hemat | Pencarian lanjutan, audit kompleks |
Kalau kebutuhanmu "filter cepat per service lalu baca lognya", Loki cukup dan lebih murah. Kalau butuh agregasi bebas di semua field dan pencarian full-text kompleks, ELK masih menang.
| Tipe | Sifat | Contoh |
|---|---|---|
| Counter | Naik monoton, reset saat restart | Total request, total error |
| Gauge | Naik turun bebas | Memori aktif, ukuran queue |
| Histogram | Distribusi dalam bucket | Latensi request per bucket |
| Summary | Quantile dihitung sisi klien | Latensi p95 langsung dari app |
Untuk latensi, pilih histogram: bisa diagregasi lintas instance dan quantile-nya dihitung saat query. Summary tidak bisa dijumlah antar instance.
# Request per detik
rate(http_requests_total[5m])
# Rasio error 5xx
sum(rate(http_requests_total{status=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m]))
# Latensi p95 dari histogram
histogram_quantile(0.95,
sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
)
# Memori kerja per pod dalam GB
container_memory_working_set_bytes{container!=""}
/ (1024 * 1024 * 1024)Query agregasi berat yang dipakai berulang sebaiknya disimpan jadi time series baru:
groups:
- name: http
interval: 30s
rules:
- record: job:http_error_ratio:rate5m
expr: |
sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
/ sum by (job) (rate(http_requests_total[5m]))Dashboard dan alert jadi lebih cepat karena agregasi sudah dihitung duluan secara berkala, bukan diulang setiap kali ada query.
Pipeline observability modern biasanya begini: aplikasi pakai SDK OpenTelemetry, kirim data ke OTel Collector, Collector meneruskan ke backend masing-masing sinyal. Kode aplikasi tidak perlu tahu backend apa yang dipakai, jadi backend bisa ditukar tanpa deploy ulang.
app (OTel SDK) -> OTel Collector --> Prometheus / Mimir (metrics)
|-> Loki (logs)
|-> Tempo / Jaeger (traces)Detail SDK, span, context propagation, sampling, dan konfigurasi Collector ada di cheat sheet /cheat-sheets/opentelemetry.
Empat golden signals dari buku Google SRE: latency, traffic, errors, saturation. Dua kerangka turunannya:
Dashboard service pakai RED; dashboard infrastruktur (node, disk, network) pakai USE.
groups:
- name: checkout-alerts
rules:
- alert: CheckoutHighErrorRate
expr: |
sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
/ sum by (job) (rate(http_requests_total[5m])) > 0.05
for: 10m
labels:
severity: page
team: payments
annotations:
summary: "Error rate {{ $labels.job }} di atas 5%"
runbook_url: "https://runbooks.internal/checkout-high-error-rate"Peran tiap field:
route:
receiver: email-default
group_by: [alertname, job]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- matchers: [severity="page"]
receiver: pagerduty
- matchers: [severity="ticket"]
receiver: jira
inhibit_rules:
- source_matchers: [severity="page"]
target_matchers: [severity="ticket"]
equal: [job]group_by menggabungkan alert serupa jadi satu notifikasi. Inhibition menutup alert severity rendah kalau alert severity tinggi dengan konteks sama sudah menyala, jadi on-call tidak kebanjiran notifikasi duplikat.
Error budget adalah 100% dikurangi SLO: jumlah gangguan yang boleh kamu pakai dalam satu periode.
| SLO | Downtime maksimal per 30 hari |
|---|---|
| 99% | 7 jam 12 menit |
| 99.5% | 3 jam 36 menit |
| 99.9% | 43 menit 12 detik |
| 99.99% | 4 menit 19 detik |
Untuk service berbasis request, hitung dari error ratio. SLO 99.9% berarti maksimal 0.1% request boleh gagal. Kalau traffic 10 juta request per bulan, 10.000 request boleh gagal. Angka itu error budget kamu.
Budget dipakai buat keputusan, bukan pajangan. Sisa masih banyak: rilis agresif boleh jalan. Sudah terkuras 80%: tunda rilis fitur, fokus perbaikan reliabilitas. Aturan main seperti ini yang bikin SLO punya gigi, bukan cuma angka di slide.
Burn rate adalah laju konsumsi budget dibanding laju normal. Burn 1 berarti budget habis tepat jatah di akhir periode. Burn 14.4 berarti 2% budget terkuras dalam 1 jam. Pola multiwindow multi-burn-rate dari Google SRE Workbook:
| Burn rate | Window panjang | Window pendek | Aksi |
|---|---|---|---|
| 14.4 | 1 jam | 5 menit | Page |
| 6 | 6 jam | 30 menit | Page |
| 3 | 1 hari | 2 jam | Ticket |
| 1 | 3 hari | 6 jam | Ticket |
Dua window dipakai bersamaan supaya alert hanya menyala kalau masalah benar-benar berlanjut, bukan sekadar spike tiga menit.
- alert: ErrorBudgetFastBurn
expr: |
(
sum(rate(http_requests_total{status=~"5.."}[1h]))
/ sum(rate(http_requests_total[1h])) > 14.4 * 0.001
)
and
(
sum(rate(http_requests_total{status=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m])) > 14.4 * 0.001
)
for: 2m
labels:
severity: page
annotations:
summary: "Error budget terkuras cepat (fast burn)"
runbook_url: "https://runbooks.internal/error-budget-burn"Angka 0.001 di expr adalah 1 dikurangi SLO (99.9%). Kalau SLO kamu 99.5%, ganti dengan 0.005. Konstanta burn rate-nya tidak berubah.
Login atau daftar akun gratis untuk membaca cheat sheet ini.