Skip to content
gohacks.dev
Web

Expvar and Simple Metrics Endpoints in Go Services

Publish counters and gauges from a Go service with the standard library expvar package, mount its handler safely and see when Prometheus is the better choice.

4 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Before reaching for a metrics stack, there is a package in the standard library that answers "how many requests has this process served, and how many goroutines does it have?" with about ten lines. expvar keeps a registry of named variables and serves all of them as one JSON document. It is not Prometheus, and it does not try to be, but for an internal tool, a small service or a quick look during an incident it is exactly enough.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

main.goGo
package main

import (
	"encoding/json"
	"expvar"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"runtime"
)

var (
	requests = expvar.NewInt("requests_total")
	byStatus = expvar.NewMap("responses_by_status")
)

func main() {
	expvar.Publish("goroutines", expvar.Func(func() any { return runtime.NumGoroutine() }))

	app := http.NewServeMux()
	app.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) {
		requests.Add(1)
		byStatus.Add("200", 1)
		fmt.Fprint(w, "ok")
	})
	app.HandleFunc("/missing", func(w http.ResponseWriter, r *http.Request) {
		requests.Add(1)
		byStatus.Add("404", 1)
		http.NotFound(w, r)
	})

	// The metrics live on their own mux, which is what we would bind to a private address.
	ops := http.NewServeMux()
	ops.Handle("/debug/vars", expvar.Handler())

	appSrv, opsSrv := httptest.NewServer(app), httptest.NewServer(ops)
	defer appSrv.Close()
	defer opsSrv.Close()

	for _, p := range []string{"/ok", "/ok", "/ok", "/missing"} {
		res, _ := http.Get(appSrv.URL + p)
		res.Body.Close()
	}

	res, _ := http.Get(opsSrv.URL + "/debug/vars")
	defer res.Body.Close()
	raw, _ := io.ReadAll(res.Body)

	var all map[string]json.RawMessage
	json.Unmarshal(raw, &all)
	for _, k := range []string{"requests_total", "responses_by_status"} {
		fmt.Printf("%s = %s\n", k, all[k])
	}
	var g int
	json.Unmarshal(all["goroutines"], &g)
	fmt.Println("goroutines is a number:", g > 0)
	_, hasMem := all["memstats"]
	_, hasCmd := all["cmdline"]
	fmt.Println("memstats and cmdline come for free:", hasMem, hasCmd)
}
Text
requests_total = 4
responses_by_status = {"200": 3, "404": 1}
goroutines is a number: true
memstats and cmdline come for free: true true

NewInt and NewMap register the variable under a name and return a handle you can keep in a package variable. Add on an Int is atomic, and Map.Add creates the key when it is missing, so no locking appears in the handlers. expvar.Func is the escape hatch: the function runs when the endpoint is read, which suits values that are cheap to compute and pointless to track, like the goroutine count. The handler also publishes memstats (the full runtime.MemStats) and cmdline without being asked.

That last item is the warning. The package's init registers /debug/vars on http.DefaultServeMux, exactly like net/http/pprof does, so a server that passes nil as its handler publishes your command line and memory statistics to anyone. The demo mounts expvar.Handler() on a separate mux and keeps it on a private listener; the same hardening applies as in exposing pprof safely. cmdline in particular can hold secrets passed as flags, which is one more reason to keep secrets in the environment.

Know where it stops. There are no labels, no histograms and no standard exposition format, so Prometheus cannot scrape it directly without an adapter. When you need latency percentiles or per-route breakdowns across a fleet, move to a real metrics library, as in the observability setup for Go services. Until then, expvar plus curl localhost:6060/debug/vars | jq is a respectable first dashboard. The API is documented in the package reference.

A pattern that makes expvar more useful than a bare counter is a small middleware that records every response by status class, so the numbers appear without touching any handler. Wrap the ResponseWriter to capture the status, as in the response writer wrapper article, and call byStatus.Add(strconv.Itoa(status), 1) after the handler returns. Add a second expvar.Map for latency buckets by incrementing the key "<10ms", "<100ms" or ">=100ms" depending on the elapsed time, and you have a crude histogram that costs nothing and answers "is it getting slower" at a glance.

Two details to remember. Variables are published once, by name, and registering the same name twice panics, so create them in package-level var blocks or in one init, not inside a constructor you may call twice in a test. And everything is global: two servers in one process share the same registry, which is fine for one service and awkward for tests that run in parallel, where counters from one test show up in another. Compare deltas rather than absolute values in a test, or reset with Set(0) at its start.

Also inDevOps
Taggedquick tip
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.