Registry metrik dari chapter sebelumnya hidup di dalam memori proses dan tidak dapat dibaca dari luar. Dunia pemantauan menjawabnya dengan satu bahasa bersama: aplikasi mengekspos seluruh metriknya pada endpoint /metrics dalam format teks yang dipahami Prometheus, lalu server Prometheus mengambil endpoint tersebut secara berkala. Pada chapter ini kita membangun eksposor format teks tersebut dari nol dengan standard library — counter dengan label, histogram dengan bucket, sampai scrape sungguhan lewat httptest — sehingga setiap byte output dapat kita amati.

Anatomi Format Eksposi #

Format teks Prometheus memuat tiga elemen per keluarga metrik. Tabel berikut merincinya:

ElemenBentukFungsi
# HELP# HELP nama deskripsidokumentasi satu baris untuk manusia
# TYPE# TYPE nama counter|gauge|histogramjenis metrik menentukan cara server membacanya
baris sampelnama{label="nilai"} angkasatu deret angka per kombinasi label

Histogram menambah akhiran khusus: _bucket{le="..."} berisi hitungan kumulatif observasi yang ≤ batas, _sum total seluruh observasi, dan _count jumlah observasi. Baris bucket wajib urut dari batas terkecil hingga +Inf. Nilai dalam label perlu meng-escape tiga karakter saja: backslash, petik ganda, dan baris baru — lebih sederhana daripada JSON.

Konvensi Penamaan #

Nama metrik adalah kontrak jangka panjang dengan seluruh dasbor dan rule alarm. Konvensi Prometheus mewajibkan: snake_case, satuan dasar sebagai akhiran (_seconds, _bytes), dan akhiran _total untuk counter. durasi_request_seconds dan pesanan_diproses_total mengikuti aturan itu; DurasiRequest atau waktu_proses akan menonjol jelek di daftar ratusan metrik layanan lain.

Model Scrape #

Prometheus memakai model pull: server pemantau mengambil (scrape) /metrics setiap interval tetap, biasanya 15-60 detik. Jika target tidak menjawab, up menjadi 0 dan alarm kegagalan menyala — deteksi kematian gratis tanpa kode tambahan. Pengecualiannya adalah batch job berumur pendek yang sering mati sebelum jadwal scrape berikutnya; job demikian mendorong hasilnya ke Pushgateway, lalu Prometheus tetap menarik dari Pushgateway tersebut.

Program Demo #

Berkas promtext-demo.go menyiapkan dua keluarga metrik, merendernya ke http.ResponseWriter dengan format teks lengkap, menyajikannya lewat httptest.NewServer, lalu melakukan scrape dengan http.Get — meniru persis alur kerja Prometheus sungguhan.

go
package main

import (
	"fmt"
	"io"
	"maps"
	"math"
	"net/http"
	"net/http/httptest"
	"slices"
	"strconv"
)

// counterProm memodelkan satu metrik counter dengan label "status".
type counterProm struct {
	nama    string
	bantuan string
	nilai   map[string]float64
}

// histogramProm memodelkan histogram: observasi mentah + batas bucket.
type histogramProm struct {
	nama      string
	bantuan   string
	batas     []float64
	observasi []float64
}

// tulisCounter merender satu keluarga counter ke format teks Prometheus.
func tulisCounter(w io.Writer, c counterProm) {
	fmt.Fprintf(w, "# HELP %s %s\n", c.nama, c.bantuan)
	fmt.Fprintf(w, "# TYPE %s counter\n", c.nama)
	for _, label := range slices.Sorted(maps.Keys(c.nilai)) {
		fmt.Fprintf(w, "%s{status=%q} %g\n", c.nama, label, c.nilai[label])
	}
}

// tulisHistogram merender histogram: _bucket kumulatif, _sum, dan _count.
func tulisHistogram(w io.Writer, h histogramProm) {
	fmt.Fprintf(w, "# HELP %s %s\n", h.nama, h.bantuan)
	fmt.Fprintf(w, "# TYPE %s histogram\n", h.nama)
	for _, batas := range append(slices.Clone(h.batas), math.Inf(1)) {
		n := 0
		for _, v := range h.observasi {
			if v <= batas {
				n++
			}
		}
		fmt.Fprintf(w, "%s_bucket{le=%q} %d\n", h.nama, formatBatas(batas), n)
	}
	jumlah := 0.0
	for _, v := range h.observasi {
		jumlah += v
	}
	fmt.Fprintf(w, "%s_sum %s\n", h.nama, formatAngka(jumlah))
	fmt.Fprintf(w, "%s_count %d\n", h.nama, len(h.observasi))
}

// formatBatas menuliskan batas bucket: "+Inf" atau angka biasa.
func formatBatas(b float64) string {
	if math.IsInf(b, 1) {
		return "+Inf"
	}
	return strconv.FormatFloat(b, 'g', -1, 64)
}

// formatAngka membulatkan ke dua desimal agar bebas derau floating point.
func formatAngka(v float64) string {
	return strconv.FormatFloat(math.Round(v*100)/100, 'g', -1, 64)
}

func main() {
	pesanan := counterProm{
		nama:    "pesanan_diproses_total",
		bantuan: "Jumlah pesanan yang selesai diproses.",
		nilai:   map[string]float64{"sukses": 8, "gagal": 2},
	}
	durasi := histogramProm{
		nama:      "durasi_request_seconds",
		bantuan:   "Distribusi durasi penanganan request.",
		batas:     []float64{0.1, 0.5, 1},
		observasi: []float64{0.05, 0.12, 0.18, 0.22, 0.31, 0.44, 0.58, 0.66, 0.74, 0.9},
	}

	// Endpoint /metrics diekspos lewat mux dengan pola method + path.
	mux := http.NewServeMux()
	mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
		tulisCounter(w, pesanan)
		tulisHistogram(w, durasi)
	})
	server := httptest.NewServer(mux)
	defer server.Close()

	// Scrape: pengambilan dilakukan klien, bukan aplikasi yang mendorong.
	resp, err := http.Get(server.URL + "/metrics")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	isi, _ := io.ReadAll(resp.Body)

	fmt.Println("--- scrape GET /metrics ---")
	fmt.Println("status:", resp.StatusCode)
	fmt.Println("content-type:", resp.Header.Get("Content-Type"))
	fmt.Println("--- isi respons ---")
	fmt.Print(string(isi))
}

Output yang Dihasilkan:

text
--- scrape GET /metrics ---
status: 200
content-type: text/plain; version=0.0.4; charset=utf-8
--- isi respons ---
# HELP pesanan_diproses_total Jumlah pesanan yang selesai diproses.
# TYPE pesanan_diproses_total counter
pesanan_diproses_total{status="gagal"} 2
pesanan_diproses_total{status="sukses"} 8
# HELP durasi_request_seconds Distribusi durasi penanganan request.
# TYPE durasi_request_seconds histogram
durasi_request_seconds_bucket{le="0.1"} 1
durasi_request_seconds_bucket{le="0.5"} 6
durasi_request_seconds_bucket{le="1"} 10
durasi_request_seconds_bucket{le="+Inf"} 10
durasi_request_seconds_sum 4.2
durasi_request_seconds_count 10

Mari kita telaah baris demi baris program di atas:

  1. Baris 14-27: dua struct data — counterProm dengan map label→nilai, histogramProm dengan batas bucket dan observasi mentah — menjadi sumber render.
  2. Baris 30-36 (tulisCounter): # HELP dan # TYPE ditulis sekali per keluarga; slices.Sorted(maps.Keys(...)) menjamin baris sampel tercetak urut label sehingga scrape identik antar eksekusi.
  3. Baris 39-57 (tulisHistogram): append(slices.Clone(h.batas), math.Inf(1)) menambah bucket +Inf tanpa mengubah slice asli; hitungan bersifat kumulatif — bucket le="0.5" bernilai 6 karena 6 observasi ≤ 0.5, dan +Inf selalu sama dengan _count.
  4. Baris 59-70 (formatBatas, formatAngka): +Inf ditulis manual, angka lain lewat strconv.FormatFloat; _sum dibulatkan dua desimal untuk menghindari derau penjumlahan floating point.
  5. Baris 86-93: mux.HandleFunc("GET /metrics", ...) memakai pola method+path Go 1.22 — endpoint hanya menerima GET; header Content-Type versi 0.0.4 adalah penanda format resmi yang dibaca Prometheus.
  6. Baris 96-101: http.Get melakukan scrape; status 200 dan isi respons utuh dicetak — sama dengan yang akan dilihat operator yang menjalankan curl http://host:port/metrics.
  7. Baris 103-107: hasil scrape ditampilkan tiga bagian: status, content-type, dan isi respons mentah.

Versi Produksi: client_golang #

Demo di atas menulis formatnya sendiri agar dapat berjalan hanya dengan standard library. Aplikasi produksi memakai package resmi client_golang yang menyediakan registry thread-safe, tipe metrik lengkap, dan handler HTTP siap pakai:

go
import (
	"net/http"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var pesananDiproses = prometheus.NewCounterVec(
	prometheus.CounterOpts{
		Name: "pesanan_diproses_total",
		Help: "Jumlah pesanan yang selesai diproses.",
	},
	[]string{"status"},
)

func main() {
	prometheus.MustRegister(pesananDiproses)
	pesananDiproses.WithLabelValues("sukses").Inc()

	http.Handle("/metrics", promhttp.Handler())
	http.ListenAndServe(":8080", nil)
}

Fragmen di atas membutuhkan dependency github.com/prometheus/client_golang sehingga tidak diletakkan sebagai berkas di bundle chapter. promhttp.Handler() merender seluruh metrik terdaftar — termasuk metrik runtime Go seperti jumlah goroutine dan pemakaian memori — dengan format identik dengan yang kita tulis manual.

Dengan log terstruktur dan metrik terekspos, dua pilar observability sudah berdiri. Pilar ketiga menambahkan dimensi yang tidak dimiliki keduanya: urutan sebab-akibat satu request lintas layanan — dibahas mulai chapter Distributed Tracing .

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

promtext-demo.go

Unduh berkas
go
package main

import (
	"fmt"
	"io"
	"maps"
	"math"
	"net/http"
	"net/http/httptest"
	"slices"
	"strconv"
)

// counterProm memodelkan satu metrik counter dengan label "status".
type counterProm struct {
	nama    string
	bantuan string
	nilai   map[string]float64
}

// histogramProm memodelkan histogram: observasi mentah + batas bucket.
type histogramProm struct {
	nama      string
	bantuan   string
	batas     []float64
	observasi []float64
}

// tulisCounter merender satu keluarga counter ke format teks Prometheus.
func tulisCounter(w io.Writer, c counterProm) {
	fmt.Fprintf(w, "# HELP %s %s\n", c.nama, c.bantuan)
	fmt.Fprintf(w, "# TYPE %s counter\n", c.nama)
	for _, label := range slices.Sorted(maps.Keys(c.nilai)) {
		fmt.Fprintf(w, "%s{status=%q} %g\n", c.nama, label, c.nilai[label])
	}
}

// tulisHistogram merender histogram: _bucket kumulatif, _sum, dan _count.
func tulisHistogram(w io.Writer, h histogramProm) {
	fmt.Fprintf(w, "# HELP %s %s\n", h.nama, h.bantuan)
	fmt.Fprintf(w, "# TYPE %s histogram\n", h.nama)
	for _, batas := range append(slices.Clone(h.batas), math.Inf(1)) {
		n := 0
		for _, v := range h.observasi {
			if v <= batas {
				n++
			}
		}
		fmt.Fprintf(w, "%s_bucket{le=%q} %d\n", h.nama, formatBatas(batas), n)
	}
	jumlah := 0.0
	for _, v := range h.observasi {
		jumlah += v
	}
	fmt.Fprintf(w, "%s_sum %s\n", h.nama, formatAngka(jumlah))
	fmt.Fprintf(w, "%s_count %d\n", h.nama, len(h.observasi))
}

// formatBatas menuliskan batas bucket: "+Inf" atau angka biasa.
func formatBatas(b float64) string {
	if math.IsInf(b, 1) {
		return "+Inf"
	}
	return strconv.FormatFloat(b, 'g', -1, 64)
}

// formatAngka membulatkan ke dua desimal agar bebas derau floating point.
func formatAngka(v float64) string {
	return strconv.FormatFloat(math.Round(v*100)/100, 'g', -1, 64)
}

func main() {
	pesanan := counterProm{
		nama:    "pesanan_diproses_total",
		bantuan: "Jumlah pesanan yang selesai diproses.",
		nilai:   map[string]float64{"sukses": 8, "gagal": 2},
	}
	durasi := histogramProm{
		nama:      "durasi_request_seconds",
		bantuan:   "Distribusi durasi penanganan request.",
		batas:     []float64{0.1, 0.5, 1},
		observasi: []float64{0.05, 0.12, 0.18, 0.22, 0.31, 0.44, 0.58, 0.66, 0.74, 0.9},
	}

	// Endpoint /metrics diekspos lewat mux dengan pola method + path.
	mux := http.NewServeMux()
	mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
		tulisCounter(w, pesanan)
		tulisHistogram(w, durasi)
	})
	server := httptest.NewServer(mux)
	defer server.Close()

	// Scrape: pengambilan dilakukan klien, bukan aplikasi yang mendorong.
	resp, err := http.Get(server.URL + "/metrics")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	isi, _ := io.ReadAll(resp.Body)

	fmt.Println("--- scrape GET /metrics ---")
	fmt.Println("status:", resp.StatusCode)
	fmt.Println("content-type:", resp.Header.Get("Content-Type"))
	fmt.Println("--- isi respons ---")
	fmt.Print(string(isi))
}