Skip to content

Failure modes: what happens when it is wrong

Every way this module reports a problem — the errors it returns, the one warning it logs, and the two places it panics — with the trigger and the fix. Message text is quoted from a real run.

The one warning it logs

level=WARN msg="metrics endpoint mounted without guard middleware; ensure it is bound to loopback or fronted by auth" path=/metrics

Trigger. MountOn (and therefore Register) was called with no WithMiddleware option.

What it means. The module cannot see your listener's bind address, so it cannot tell a loopback-bound service from an open port. It warns on the only signal it has.

When to ignore it. If the listener is bound to 127.0.0.1 or an internal address, the posture is already safe and the warning is advisory. It is emitted at WARN because the failure it guards against is an unauthenticated metrics or pprof port on the public internet.

How to silence it. Pass the middleware you intended to pass. There is no suppression option — supplying any middleware stops it. Route it through your own logger with WithLogger; it goes to slog.Default() otherwise.

If you passed middleware and still see it, the option did not reach the constructor — check you are not building the Metrics in one place and adding options in another.

Errors returned by New and Register

New returns a wrapped error and no Metrics; Register returns the same error without mounting anything.

Message Trigger
register go collector: duplicate metrics collector registration attempted A registry passed to WithRegistry already carries a Go collector.
register build-info collector: ... Something else already registered build_info on the supplied registry.
register collector: ... A collector passed to WithCollectors clashes with one already present, or two of them describe the same metric.

All three come from prometheus.Registry.Register, so the underlying cause is always the same: two collectors describing the same fully-qualified metric name with the same label dimensions. Supply a clean registry, or drop the duplicate.

A registry you share with the rest of your app

WithRegistry is designed for this, but the runtime collectors go on afterwards. If your app already registered collectors.NewGoCollector() on that registry, add WithoutRuntimeCollectors() so the module does not try again.

Errors returned by the instrument constructors

HTTPMiddleware, NewServerMetrics, NewOperations, NewDB, NewCache, NewCircuitBreaker, NewQueue and DB.RegisterPool all return an error rather than panicking, and the cause is nearly always a second registration of the same instrument set:

Call Message on a repeat
HTTPMiddleware register HTTP instrument: duplicate metrics collector registration attempted
NewServerMetrics register gRPC instrument: duplicate metrics collector registration attempted
NewOperations register operation instruments: duplicate metrics collector registration attempted
NewDB (same name) register db instruments: duplicate metrics collector registration attempted
NewCache (same name) register cache instruments: duplicate metrics collector registration attempted
NewCircuitBreaker (same name) register circuit-breaker instruments: duplicate metrics collector registration attempted
NewQueue (same name) register queue instruments: duplicate metrics collector registration attempted
DB.RegisterPool register db pool collector: duplicate metrics collector registration attempted

Two things make a repeat legal:

  • A different identifying name. NewDB("orders") and NewDB("billing") coexist — the db label distinguishes them.
  • A different namespace. HTTPMiddleware() and HTTPMiddleware(WithHTTPNamespace("admin")) both succeed, because the namespace changes the metric names. The same applies to every helper.

RegisterPool can be called once per DB; a second call is a duplicate registration even with an identical callback.

Ignoring these errors is the most common cause of a missing metric. Every one of these constructors returns nil alongside the error, so db, _ := m.NewDB("orders") hands you a nil pointer that panics on first use — and _, _ = m.HTTPMiddleware() discards middleware that was never registered. Handle the error, or nothing you instrument through the result will appear.

Panics, and the two ways to cause one

The module itself never panics. Both cases below come from http.ServeMux rejecting a pattern, at mount time rather than at request time.

A metrics path with no leading slash

metrics.New(metrics.WithMetricsPath("metrics"))  // then MountOn
panic: parsing "GET metrics": at offset 4: host/path missing /

WithMetricsPath is not validated. The value is concatenated straight into a "GET " + path mux pattern, so it must begin with /.

Mounting two Metrics on one mux at the same path

panic: pattern "GET /metrics" ... conflicts with pattern "GET /metrics":
GET /metrics matches the same requests as GET /metrics

Two Metrics instances mean two private registries, and a mux cannot serve both at /metrics. Either mount one of them at a different WithMetricsPath, or — usually better — build one Metrics and register everything on it.

Silent misconfiguration, with no error at all

These produce no error and no warning. They are the ones worth checking first when something is missing.

A pprof path without a trailing slash

WithPprofPath documents that the prefix must end in /, and nothing enforces it. The sub-handlers are mounted by string concatenation, so WithPprofPath("/debug/pprof") gives you:

Request Result
GET /debug/pprof 200 — the index
GET /debug/pprofcmdline 200 — the cmdline handler, at a path nobody would guess
GET /debug/pprof/cmdline 404

go tool pprof will fail against such an endpoint. Keep the trailing slash.

A route label from a non-stdlib router

http.Request.Pattern is populated by http.ServeMux. With chi, gin, echo or any other router it is empty, so every request lands in the single <other> bucket and per-route latency is lost. This is safe — it does not explode cardinality — but it is useless. Supply WithRouteLabelFunc reading your router's matched template.

Exemplars recorded but never displayed

Exemplars ride on the OpenMetrics exposition format. A plain curl gets the Prometheus text format and shows none, whether or not they were recorded. Ask for OpenMetrics explicitly:

curl -s -H 'Accept: application/openmetrics-text; version=1.0.0' localhost:8080/metrics

Prometheus itself also needs --enable-feature=exemplar-storage to keep them.

Metrics registered somewhere this module does not serve

promauto.NewCounter and prometheus.MustRegister write to the global default registry. This module never touches the global registry — every Metrics owns a private one — so those series will not appear on its /metrics. Register them through WithCollectors, WithRegistry, or m.Registry().

Non-GET requests to /metrics

The endpoint is mounted as GET /metrics, which in http.ServeMux also matches HEAD. Anything else is rejected by the mux, not by this module:

Method Response
GET 200
HEAD 200
POST 405, Allow: GET, HEAD
OPTIONS 405, Allow: GET, HEAD

Values that resolve to a placeholder rather than failing

build_info never has an empty label. Anything unresolved becomes the literal string unknown:

build_info{goversion="go1.26.5",revision="unknown",version="(devel)"} 1
  • version="(devel)" — Go's own value for a main module built from source. Pass WithBuildInfo or install via go install module@version to get a real one.
  • revision="unknown" — no vcs.revision in the build. go run does not stamp one, and neither does go build -buildvcs=false or a build from outside a VCS checkout (many container builds).