Health check menjawab “sehat atau tidak” — tetapi saat insiden terjadi, pertanyaannya bertumpuk: peristiwa apa saja yang terjadi, seberapa sering, dan waktu tersita di lapisan mana? Observability memadukan tiga sumber jawaban: log merekam peristiwa tunggal, metrics menghitung agregat dari jutaan peristiwa, dan trace mengikuti satu request menembus seluruh lapisan. Spring Boot menyatukan ketiganya lewat Micrometer dan Observation API — satu instrumentasi, tiga keluaran. Pada Chapter ini kita bedah tiap pilar beserta konvensinya, lalu membangun ketiganya dalam satu program Java murni.
Tiga Pilar dan Pertanyaannya #
| Pilar | Format | Menjawab | Contoh pertanyaan |
|---|---|---|---|
| Log | baris peristiwa | apa yang terjadi? | “kesalahan apa yang muncul pukul 02:14?” |
| Metrics | angka teragregasi | seberapa sering? | “berapa laju error per menit minggu ini?” |
| Trace | pohon span bersarang | waktu ke mana? | “kenapa request ini 900 ms?” |
Ketiganya saling mengunci: grafik latensi yang menanjak (metrics) menunjuk rentang waktu, log terstruktur pada rentang itu menunjuk request gagal, dan traceId-nya membuka pohon span yang memperlihatkan query database lambat. Tanpa kunci penghubung — trace_id pada setiap baris log — investigasi berubah menjadi teka-teki silang lintas berkas.
Log Terstruktur #
Log tradisional berbentuk kalimat bebas; grep manusia membacanya, mesin tidak. Log terstruktur memecah tiap baris menjadi pasangan kunci-nilai (atau JSON) sehingga bisa disaring berdasarkan field, bukan pola teks:
tingkat=INFO trace_id=4bf92f... span_id=00f067... pesan="request diterima" method=POSTKunci trace_id dan span_id mengalir otomatis lewat MDC (Mapped Diagnostic Context) — peta thread-lokal yang dibaca appender Logback pada setiap baris. Spring Boot menyuntikkan nilai itu serta service.name secara otomatis saat tracing aktif; konfigurasi pola log mengeksposnya:
logging:
pattern:
level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"Keluarannya INFO [toko-api,4bf92f3577b34da6,00f067aa0ba902b7] — cukup satu format ini agar setiap baris log bisa dikorelasikan ke trace-nya.
Metrik dengan Micrometer #
Micrometer adalah fasad metrik dengan meter berjenis tegas: Counter hanya naik (pesanan.dibuat), Gauge melaporkan nilai terkini (antrian.panjang), Timer mencatat durasi beserta jumlah, dan DistributionSummary menampung distribusi nilai. Satu registrasi, banyak backend — Prometheus, Datadog, CloudWatch — dipilih lewat dependensi tanpa mengubah kode:
@Service
public class LayananPesanan {
private final Counter pesananDibuat;
private final MeterRegistry registry;
public LayananPesanan(MeterRegistry registry) {
this.registry = registry;
this.pesananDibuat = Counter.builder("pesanan.dibuat")
.description("Jumlah pesanan yang dibuat")
.register(registry);
}
public void simpan(Pesanan p) {
Timer.builder("pesanan.durasi")
.publishPercentileHistogram()
.register(registry)
.record(() -> repo.simpan(p));
pesananDibuat.increment();
}
}Tag berperan sebagai dimensi — status=201, endpoint=/api/pesanan — tetapi cardinality-nya harus dijaga: tag berisi userId atau pesananId membuat deret waktu membengkak menjadi jutaan seri. Aturan Micrometer: nilai berbatas kecil untuk tag, nilai bebas untuk log.
Trace dan Observation API #
Trace tersusun dari span bersarang: satu span untuk request HTTP, span anak untuk pemanggilan service, span cucu untuk query database — masing-masing membawa trace_id yang sama dan span_id unik. Antar-service, identitas itu merambat lewat header standar W3C traceparent: 00-{traceId}-{spanId}-01. Sejak Spring Boot 3, seluruh instrumentasi ini dibangun di atas satu abstraksi — Observation API — yang sekaligus memancarkan metrik, event log, dan span:
@RequestMapping("/api/pesanan")
public class KontrolPesanan {
private final ObservationRegistry observasi;
@PostMapping
public ResponseEntity<Pesanan> buat(@RequestBody DtoPesanan dto) {
return Observation.createNotStarted("controller.post", observasi)
.lowCardinalityKeyValue("endpoint", "/api/pesanan")
.observe(() -> ResponseEntity.status(201).body(service.buat(dto)));
}
}Satu pembungkus observe(...) mencatat durasi sebagai Timer http.server.requests, event log awal/akhir, dan span di trace aktif — filter servlet, RestTemplate, dan Kafka sudah memakai API yang sama sehingga trace tersambung otomatis.
Tiga Pilar dalam Satu Program #
Instrumentasi sungguhan membutuhkan Micrometer dan backend tracing, tetapi mekanisme intinya bisa dibangun dengan Java murni. Program berikut mengalirkan satu request POST /api/pesanan melalui tiga lapisan: setiap langkah menulis log terstruktur dengan trace_id/span_id yang berganti mengikuti span aktif, mengisi registri metrik (counter, gauge, timer), lalu merangkai pohon span:
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
public class ObservasiMini {
// ==== Pilar 1: log terstruktur dengan konteks jejak (padanan MDC) ====
record KonteksJejak(String traceId, String spanId) {
KonteksJejak gantiSpan(String spanBaru) {
return new KonteksJejak(traceId, spanBaru);
}
}
static void log(String tingkat, KonteksJejak ctx, String pesan,
Object... kv) {
StringBuilder baris = new StringBuilder("tingkat=" + tingkat)
.append(" trace_id=").append(ctx.traceId())
.append(" span_id=").append(ctx.spanId())
.append(" pesan=\"").append(pesan).append('"');
for (int i = 0; i + 1 < kv.length; i += 2) {
baris.append(' ').append(kv[i]).append('=').append(kv[i + 1]);
}
System.out.println(baris);
}
// ==== Pilar 2: registri metrik mini ala Micrometer ====
static class RegistriMetrik {
private final Map<String, Double> counter = new TreeMap<>();
private final Map<String, Long> gauge = new TreeMap<>();
private final Map<String, List<Double>> timer = new TreeMap<>();
void counterNaik(String nama) {
counter.merge(nama, 1.0, Double::sum);
}
void gauge(String nama, long nilai) {
gauge.put(nama, nilai);
}
void timerCatat(String nama, double detik) {
timer.computeIfAbsent(nama, k -> new ArrayList<>()).add(detik);
}
// Eksposur ala Prometheus: nama terurut, satu baris per meter.
String ekspos() {
StringBuilder teks = new StringBuilder();
for (Map.Entry<String, Double> e : counter.entrySet()) {
teks.append(e.getKey()).append(' ')
.append(String.format(Locale.ROOT, "%.1f", e.getValue()))
.append('\n');
}
for (Map.Entry<String, Long> e : gauge.entrySet()) {
teks.append(e.getKey()).append(' ').append(e.getValue())
.append('\n');
}
for (Map.Entry<String, List<Double>> e : timer.entrySet()) {
double maks = e.getValue().stream().mapToDouble(Double::doubleValue).max().orElse(0);
teks.append(e.getKey()).append("_count ").append(e.getValue().size())
.append('\n')
.append(e.getKey()).append("_max ")
.append(String.format(Locale.ROOT, "%.3f", maks))
.append('\n');
}
return teks.toString();
}
}
// ==== Pilar 3: pohon span (padanan trace terdistribusi) ====
record Span(String id, String nama, double durasiDetik, List<Span> anak) {
Span(String id, String nama, double durasiDetik) {
this(id, nama, durasiDetik, new ArrayList<>());
}
void tambahAnak(Span s) {
anak.add(s);
}
}
static void cetakSpan(String prefiks, String label, Span s) {
System.out.printf("%s%s%s %s (%.3fs)%n", prefiks, label, s.id(), s.nama(),
s.durasiDetik());
for (int i = 0; i < s.anak().size(); i++) {
boolean terakhir = i == s.anak().size() - 1;
cetakSpan(prefiks + (terakhir ? " " : " | "),
terakhir ? "`-- " : "|-- ", s.anak().get(i));
}
}
public static void main(String[] args) {
RegistriMetrik metrik = new RegistriMetrik();
// ---- Satu request mengalir: log + trace + metrik tercatat bersama ----
System.out.println("== Pilar 1: log terstruktur (tercetak selama alur) ==");
String traceId = "4bf92f3577b34da6a3ce929d0e0e4736";
KonteksJejak ctx = new KonteksJejak(traceId, "00f067aa0ba902b7");
log("INFO", ctx, "request diterima", "method", "POST", "path", "/api/pesanan");
KonteksJejak ctxLayanan = ctx.gantiSpan("00f067aa0ba902b8");
log("INFO", ctxLayanan, "menyimpan pesanan", "pesananId", "P-101");
KonteksJejak ctxDb = ctxLayanan.gantiSpan("00f067aa0ba902b9");
log("INFO", ctxDb, "insert ke tabel", "tabel", "pesanan", "baris", 1);
log("INFO", ctx, "request selesai", "status", 201);
metrik.counterNaik("pesanan_dibuat_total");
metrik.timerCatat("pesanan_durasi_seconds", 0.012);
// Dua request berikutnya hanya menambah metrik (log dilewati agar ringkas).
metrik.counterNaik("pesanan_dibuat_total");
metrik.timerCatat("pesanan_durasi_seconds", 0.007);
metrik.counterNaik("pesanan_dibuat_total");
metrik.timerCatat("pesanan_durasi_seconds", 0.025);
metrik.gauge("antrian_kirim_panjang", 7);
System.out.println();
System.out.println("== Pilar 2: metrik (eksposur ala Prometheus) ==");
System.out.print(metrik.ekspos());
System.out.println("== Pilar 3: pohon trace ==");
System.out.println("TRACE " + traceId);
Span akar = new Span("00f067aa0ba902b7", "POST /api/pesanan", 0.012);
Span layanan = new Span("00f067aa0ba902b8", "LayananPesanan.simpan", 0.012);
Span db = new Span("00f067aa0ba902b9", "db.insert", 0.007);
layanan.tambahAnak(db);
akar.tambahAnak(layanan);
cetakSpan("", "", akar);
}
}Output yang Dihasilkan:
== Pilar 1: log terstruktur (tercetak selama alur) ==
tingkat=INFO trace_id=4bf92f3577b34da6a3ce929d0e0e4736 span_id=00f067aa0ba902b7 pesan="request diterima" method=POST path=/api/pesanan
tingkat=INFO trace_id=4bf92f3577b34da6a3ce929d0e0e4736 span_id=00f067aa0ba902b8 pesan="menyimpan pesanan" pesananId=P-101
tingkat=INFO trace_id=4bf92f3577b34da6a3ce929d0e0e4736 span_id=00f067aa0ba902b9 pesan="insert ke tabel" tabel=pesanan baris=1
tingkat=INFO trace_id=4bf92f3577b34da6a3ce929d0e0e4736 span_id=00f067aa0ba902b7 pesan="request selesai" status=201
== Pilar 2: metrik (eksposur ala Prometheus) ==
pesanan_dibuat_total 3.0
antrian_kirim_panjang 7
pesanan_durasi_seconds_count 3
pesanan_durasi_seconds_max 0.025
== Pilar 3: pohon trace ==
TRACE 4bf92f3577b34da6a3ce929d0e0e4736
00f067aa0ba902b7 POST /api/pesanan (0.012s)
`-- 00f067aa0ba902b8 LayananPesanan.simpan (0.012s)
`-- 00f067aa0ba902b9 db.insert (0.007s)Mari kita bedah tiap pilar pada keluarannya:
log(tingkat, ctx, pesan, kv...): setiap baris memuattrace_idyang sama (4bf92f...4736) denganspan_idyang berganti —...b7di controller,...b8di service,...b9di database. Inilah kelakuan MDC — konteks mengikuti alur eksekusi; satu kuncitrace_idcukup untuk merekonstruksi kronologi request.RegistriMetrik: tigaTreeMap— counter, gauge, timer — danTreeMapdipilih agar iterasi terurut nama (eksposur Prometheus mensyaratkan grup per nama).counterNaikmemakaimergeyang menambah 1.0;timerCatatmenyimpan daftar sampel sehingga_count 3dan_max 0.025diturunkan dari data, bukan dihardcode.- Eksposur ala Prometheus: keluaran satu baris per meter dengan nama terurut — keluarnya persis format yang di-scrape Prometheus.
pesanan_dibuat_total 3.0berasal daricounterNaikyang dipanggil tiga kali (satu dari alur bernalar, dua request lain). Suffix_total,_count,_maxmengikuti konvensi Prometheus untuk counter dan summary. cetakSpan(prefiks, label, span): pohon span tercetak dengan penanda “-- " dan indentasi empat spasi yang bertambah tiap kedalaman — durasidb.insert0.007s bersarang di dalamLayananPesanan.simpan0.012s yang bersarang diPOST /api/pesanan` 0.012s. Angka tetap (bukan pengukuran nyata) agar keluaran dapat diverifikasi; Micrometer nyata menyimpan persentil, bukan setiap sampel.gantiSpan(spanBaru): konteks jejak bersifat immutable record — memasuki span baru tidak mengubah konteks pemanggil, persis semantik penutupan scope span pada tracer nyata: saat span anak selesai, konteks kembali ke span induk (baris keempat log kembali memakaispan_id ...b7).
Observability melengkapi aplikasi dengan indra. Satu langkah tersisa sebelum produksi: mengemas aplikasi menjadi artefak yang dapat dijalankan, mengamankan konfigurasinya, dan memasang pagar pelindung terakhirnya — pokok bahasan Chapter penutup Section ini, Kesiapan Produksi Spring Boot.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ObservasiMini.java
Unduh berkasimport java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
public class ObservasiMini {
// ==== Pilar 1: log terstruktur dengan konteks jejak (padanan MDC) ====
record KonteksJejak(String traceId, String spanId) {
KonteksJejak gantiSpan(String spanBaru) {
return new KonteksJejak(traceId, spanBaru);
}
}
static void log(String tingkat, KonteksJejak ctx, String pesan,
Object... kv) {
StringBuilder baris = new StringBuilder("tingkat=" + tingkat)
.append(" trace_id=").append(ctx.traceId())
.append(" span_id=").append(ctx.spanId())
.append(" pesan=\"").append(pesan).append('"');
for (int i = 0; i + 1 < kv.length; i += 2) {
baris.append(' ').append(kv[i]).append('=').append(kv[i + 1]);
}
System.out.println(baris);
}
// ==== Pilar 2: registri metrik mini ala Micrometer ====
static class RegistriMetrik {
private final Map<String, Double> counter = new TreeMap<>();
private final Map<String, Long> gauge = new TreeMap<>();
private final Map<String, List<Double>> timer = new TreeMap<>();
void counterNaik(String nama) {
counter.merge(nama, 1.0, Double::sum);
}
void gauge(String nama, long nilai) {
gauge.put(nama, nilai);
}
void timerCatat(String nama, double detik) {
timer.computeIfAbsent(nama, k -> new ArrayList<>()).add(detik);
}
// Eksposur ala Prometheus: nama terurut, satu baris per meter.
String ekspos() {
StringBuilder teks = new StringBuilder();
for (Map.Entry<String, Double> e : counter.entrySet()) {
teks.append(e.getKey()).append(' ')
.append(String.format(Locale.ROOT, "%.1f", e.getValue()))
.append('\n');
}
for (Map.Entry<String, Long> e : gauge.entrySet()) {
teks.append(e.getKey()).append(' ').append(e.getValue())
.append('\n');
}
for (Map.Entry<String, List<Double>> e : timer.entrySet()) {
double maks = e.getValue().stream().mapToDouble(Double::doubleValue).max().orElse(0);
teks.append(e.getKey()).append("_count ").append(e.getValue().size())
.append('\n')
.append(e.getKey()).append("_max ")
.append(String.format(Locale.ROOT, "%.3f", maks))
.append('\n');
}
return teks.toString();
}
}
// ==== Pilar 3: pohon span (padanan trace terdistribusi) ====
record Span(String id, String nama, double durasiDetik, List<Span> anak) {
Span(String id, String nama, double durasiDetik) {
this(id, nama, durasiDetik, new ArrayList<>());
}
void tambahAnak(Span s) {
anak.add(s);
}
}
static void cetakSpan(String prefiks, String label, Span s) {
System.out.printf("%s%s%s %s (%.3fs)%n", prefiks, label, s.id(), s.nama(),
s.durasiDetik());
for (int i = 0; i < s.anak().size(); i++) {
boolean terakhir = i == s.anak().size() - 1;
cetakSpan(prefiks + (terakhir ? " " : " | "),
terakhir ? "`-- " : "|-- ", s.anak().get(i));
}
}
public static void main(String[] args) {
RegistriMetrik metrik = new RegistriMetrik();
// ---- Satu request mengalir: log + trace + metrik tercatat bersama ----
System.out.println("== Pilar 1: log terstruktur (tercetak selama alur) ==");
String traceId = "4bf92f3577b34da6a3ce929d0e0e4736";
KonteksJejak ctx = new KonteksJejak(traceId, "00f067aa0ba902b7");
log("INFO", ctx, "request diterima", "method", "POST", "path", "/api/pesanan");
KonteksJejak ctxLayanan = ctx.gantiSpan("00f067aa0ba902b8");
log("INFO", ctxLayanan, "menyimpan pesanan", "pesananId", "P-101");
KonteksJejak ctxDb = ctxLayanan.gantiSpan("00f067aa0ba902b9");
log("INFO", ctxDb, "insert ke tabel", "tabel", "pesanan", "baris", 1);
log("INFO", ctx, "request selesai", "status", 201);
metrik.counterNaik("pesanan_dibuat_total");
metrik.timerCatat("pesanan_durasi_seconds", 0.012);
// Dua request berikutnya hanya menambah metrik (log dilewati agar ringkas).
metrik.counterNaik("pesanan_dibuat_total");
metrik.timerCatat("pesanan_durasi_seconds", 0.007);
metrik.counterNaik("pesanan_dibuat_total");
metrik.timerCatat("pesanan_durasi_seconds", 0.025);
metrik.gauge("antrian_kirim_panjang", 7);
System.out.println();
System.out.println("== Pilar 2: metrik (eksposur ala Prometheus) ==");
System.out.print(metrik.ekspos());
System.out.println("== Pilar 3: pohon trace ==");
System.out.println("TRACE " + traceId);
Span akar = new Span("00f067aa0ba902b7", "POST /api/pesanan", 0.012);
Span layanan = new Span("00f067aa0ba902b8", "LayananPesanan.simpan", 0.012);
Span db = new Span("00f067aa0ba902b9", "db.insert", 0.007);
layanan.tambahAnak(db);
akar.tambahAnak(layanan);
cetakSpan("", "", akar);
}
}