Skip to content

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 go directive in this module's go.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

go get gitlab.com/phpboyscout/go/transport-metrics

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:

go run .

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) '
go_goroutines 6
go_memstats_heap_alloc_bytes 396432
process_resident_memory_bytes 1.4286848e+07

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 at 1, 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

curl -s localhost:8080/metrics | grep '^build_info'

Under go run you'll get placeholders:

build_info{goversion="go1.26.5",revision="unknown",version="(devel)"} 1

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

go build -o hello . && ./hello
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:

metrics.Register(mux,
    metrics.WithBuildInfo(metrics.BuildInfo{Version: version, Name: "hello"}),
)

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:

curl -s localhost:8080/metrics | grep '^hello_'
hello_greetings_total 3

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:

scrape_configs:
  - job_name: hello
    static_configs:
      - targets: ["127.0.0.1:8080"]

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