go 110 lines · 3 tabs

Instrumenting Go HTTP Handlers with Prometheus Counters and Histograms

Shared by codesnips Jul 2026
3 tabs
package main

import (
	"log"
	"net/http"
	"time"

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

	"example.com/app/telemetry"
)

func getUser(w http.ResponseWriter, r *http.Request) {
	time.Sleep(15 * time.Millisecond)
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"id":1,"name":"ada"}`))
}

func listOrders(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`[]`))
}

func main() {
	mux := http.NewServeMux()

	// Route labels are stable templates, never raw paths with IDs.
	mux.HandleFunc("/users/", telemetry.Instrument("/users/:id", getUser))
	mux.HandleFunc("/orders", telemetry.Instrument("/orders", listOrders))

	mux.Handle("/metrics", promhttp.Handler())

	srv := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	log.Println("listening on :8080")
	log.Fatal(srv.ListenAndServe())
}
3 files · go Explain with highlit

This snippet shows how request-level metrics are collected in a Go HTTP service and exposed on a /metrics endpoint that Prometheus can scrape. The core pattern is to register a small set of metric vectors once, update them from middleware on every request, and let the client library serialize them into the Prometheus text exposition format.

In metrics.go, three collectors are declared as package-level vars via promauto, which registers them with the default registry at init time. httpRequestsTotal is a CounterVec labelled by method, path, and status — counters only ever increase, which matches the semantics of a request count. httpRequestDuration is a HistogramVec: rather than storing every latency, a histogram buckets observations into predefined ranges, letting Prometheus compute quantiles like p95 with histogram_quantile. The buckets here use prometheus.DefBuckets, sensible defaults centered on sub-second web latencies. httpRequestsInFlight is a plain Gauge that goes up and down as requests enter and leave.

The cardinality trap is important: labels multiply series, so path must be a low-cardinality route template (/users/:id), never a raw URL with IDs baked in. That is why the middleware takes an explicit route string instead of reading r.URL.Path.

In middleware.go, Instrument wraps a handler. It increments the in-flight gauge with defer to guarantee decrement even on panic, starts a timer, and uses a statusRecorder to capture the status code — necessary because http.ResponseWriter does not expose the code after WriteHeader. When the inner handler returns, WithLabelValues selects the right series and Observe/Inc record the data. time.Since(start).Seconds() feeds the histogram in seconds, the Prometheus convention.

In main.go, routes are registered wrapped in Instrument with stable route labels, and promhttp.Handler() serves the exposition endpoint. That handler does the actual encoding, so no manual formatting is needed. This approach is cheap at runtime, safe for concurrent use since the client library's collectors are goroutine-safe, and scales to production as long as label cardinality stays bounded.


Related snips

Share this code

Here's the card — post it anywhere.

Instrumenting Go HTTP Handlers with Prometheus Counters and Histograms — share card
Link copied