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:
| Elemen | Bentuk | Fungsi |
|---|---|---|
# HELP | # HELP nama deskripsi | dokumentasi satu baris untuk manusia |
# TYPE | # TYPE nama counter|gauge|histogram | jenis metrik menentukan cara server membacanya |
| baris sampel | nama{label="nilai"} angka | satu 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.
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:
--- 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 10Mari kita telaah baris demi baris program di atas:
- Baris 14-27: dua struct data —
counterPromdengan map label→nilai,histogramPromdengan batas bucket dan observasi mentah — menjadi sumber render. - Baris 30-36 (
tulisCounter):# HELPdan# TYPEditulis sekali per keluarga;slices.Sorted(maps.Keys(...))menjamin baris sampel tercetak urut label sehingga scrape identik antar eksekusi. - Baris 39-57 (
tulisHistogram):append(slices.Clone(h.batas), math.Inf(1))menambah bucket+Inftanpa mengubah slice asli; hitungan bersifat kumulatif — bucketle="0.5"bernilai 6 karena 6 observasi ≤ 0.5, dan+Infselalu sama dengan_count. - Baris 59-70 (
formatBatas,formatAngka):+Infditulis manual, angka lain lewatstrconv.FormatFloat;_sumdibulatkan dua desimal untuk menghindari derau penjumlahan floating point. - Baris 86-93:
mux.HandleFunc("GET /metrics", ...)memakai pola method+path Go 1.22 — endpoint hanya menerima GET; headerContent-Typeversi0.0.4adalah penanda format resmi yang dibaca Prometheus. - Baris 96-101:
http.Getmelakukan scrape; status 200 dan isi respons utuh dicetak — sama dengan yang akan dilihat operator yang menjalankancurl http://host:port/metrics. - 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:
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 #
- Exposition Formats — spesifikasi resmi format teks 0.0.4.
- Prometheus Concepts — model data, job, instance, dan scrape.
- Package prometheus (client_golang)
—
CounterVec,Histogram, dan registry produksi. - Package promhttp
— handler
/metricssiap pakai.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
promtext-demo.go
Unduh berkaspackage 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))
}