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 #

PilarFormatMenjawabContoh pertanyaan
Logbaris peristiwaapa yang terjadi?“kesalahan apa yang muncul pukul 02:14?”
Metricsangka teragregasiseberapa sering?“berapa laju error per menit minggu ini?”
Tracepohon span bersarangwaktu 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:

text
tingkat=INFO trace_id=4bf92f... span_id=00f067... pesan="request diterima" method=POST

Kunci 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:

yaml
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:

java
@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:

java
@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:

text
== 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:

  1. log(tingkat, ctx, pesan, kv...): setiap baris memuat trace_id yang sama (4bf92f...4736) dengan span_id yang berganti — ...b7 di controller, ...b8 di service, ...b9 di database. Inilah kelakuan MDC — konteks mengikuti alur eksekusi; satu kunci trace_id cukup untuk merekonstruksi kronologi request.
  2. RegistriMetrik: tiga TreeMap — counter, gauge, timer — dan TreeMap dipilih agar iterasi terurut nama (eksposur Prometheus mensyaratkan grup per nama). counterNaik memakai merge yang menambah 1.0; timerCatat menyimpan daftar sampel sehingga _count 3 dan _max 0.025 diturunkan dari data, bukan dihardcode.
  3. Eksposur ala Prometheus: keluaran satu baris per meter dengan nama terurut — keluarnya persis format yang di-scrape Prometheus. pesanan_dibuat_total 3.0 berasal dari counterNaik yang dipanggil tiga kali (satu dari alur bernalar, dua request lain). Suffix _total, _count, _max mengikuti konvensi Prometheus untuk counter dan summary.
  4. cetakSpan(prefiks, label, span): pohon span tercetak dengan penanda “-- " dan indentasi empat spasi yang bertambah tiap kedalaman — durasi db.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.
  5. 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 memakai span_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 berkas
java
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);
    }
}