Getting started: your first /metrics endpoint¶
By the end of this you'll have a Go service exposing a scrapeable Prometheus
/metrics endpoint, and you'll have read real numbers off it with curl. About ten
minutes, and it needs no Prometheus server and no collector.
What you need first¶
- A Go toolchain of at least 1.26.5 — that is the
godirective in this module'sgo.mod. An older toolchain will fetch a matching one for you if toolchain switching is on, and refuse the build if it is not. curl, or any HTTP client.
There is no agent to install and no endpoint to configure. The metrics endpoint lives inside your own process.
Install the module¶
Mount /metrics on your mux¶
Register builds a private Prometheus registry, adds the Go runtime, process and
build-info collectors to it, and mounts a GET /metrics handler on the mux you pass.
Save this as main.go:
package main
import (
"log"
"net/http"
metrics "gitlab.com/phpboyscout/go/transport-metrics"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/hello", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"message":"hi"}`))
})
// Mount GET /metrics. Binding to loopback below is what guards it here.
if err := metrics.Register(mux); err != nil {
log.Fatal(err)
}
log.Println("listening on 127.0.0.1:8080")
log.Fatal(http.ListenAndServe("127.0.0.1:8080", mux))
}
Run it:
You'll see a warning alongside the listening line:
WARN metrics endpoint mounted without guard middleware; ensure it is bound to loopback or fronted by auth path=/metrics
That is expected here, and it is a reminder rather than an error — you have guarded
the endpoint, by binding the listener to 127.0.0.1. The module cannot see the bind
address, so it warns whenever no middleware was supplied.
Guard the endpoint covers the other posture, where you pass your
service's auth middleware instead.
Read the runtime numbers¶
In another terminal:
curl -s localhost:8080/metrics | grep -E '^(go_goroutines|go_memstats_heap_alloc_bytes|process_resident_memory_bytes) '
Three families arrive with no configuration:
go_*— goroutines, heap, GC pause quantiles, OS threads,GOMAXPROCS.process_*— resident memory, CPU seconds, open file descriptors, process start time.build_info— a gauge fixed at1, carrying the version, VCS revision and Go version as labels.
The metrics reference lists every series in full.
Check which build you are looking at¶
Under go run you'll get placeholders:
(devel) is what Go reports as the version of a main module built from source, and
unknown is this module's stand-in for a label it could not resolve — go run does
not stamp VCS information into the binary. Build instead, from inside a git checkout,
and both fill in:
build_info{goversion="go1.26.5",revision="08ac4e46fedf3d836f5be2f187863089b4634466",version="v0.0.0-20260802185707-08ac4e46fedf+dirty"} 1
The point of build_info is joining a metric to the build that produced it, so "did
the new release cause this?" becomes a query rather than a guess. If you stamp your
own version with -ldflags, hand it over explicitly:
Anything you leave empty still falls back to the build's embedded metadata.
Add a metric of your own¶
The runtime collectors tell you how the process is doing. Your own counters tell you
what it is doing. Pass them at construction with WithCollectors:
greetings := prometheus.NewCounter(prometheus.CounterOpts{
Name: "hello_greetings_total",
Help: "Total greetings served.",
})
mux.HandleFunc("GET /v1/hello", func(w http.ResponseWriter, _ *http.Request) {
greetings.Inc()
_, _ = w.Write([]byte(`{"message":"hi"}`))
})
if err := metrics.Register(mux, metrics.WithCollectors(greetings)); err != nil {
log.Fatal(err)
}
Hit /v1/hello three times, then:
One warning before you reach for labels. Every distinct combination of label values is a separate time series, and a label fed from a URL path or a user id grows without bound until it takes the monitoring system down with it. Keep label values to a small fixed set — Cardinality safety explains why, and what to do instead when you want per-request detail.
Point Prometheus at it¶
Nothing about the endpoint is specific to a particular scraper, so a minimal
prometheus.yml is all it takes:
Prometheus scrapes /metrics by default, which is where the module mounts unless you
pass WithMetricsPath. If your service is bound to loopback and Prometheus is not on
the same host, you need one of the guarded postures instead — see
Guard the endpoint.
Where to go next¶
- Catch a memory leak — the same endpoint plus pprof, used to find a leak end to end.
- Instrument HTTP requests — request rate, error rate and latency per route.
- Options reference — every option, its default, and what happens when it is wrong.