feat(health): serve detailed health map on metrics server (#3806) - #4162
feat(health): serve detailed health map on metrics server (#3806)#4162ogMaverick12 wants to merge 1 commit into
Conversation
|
Hi @aryanmehrotra — this picks up the deferred second half of the #3802 redaction (tracked in #3806): the detailed health map is now served at A few design notes:
|
aryanmehrotra
left a comment
There was a problem hiding this comment.
Since this re-exposes data #3802 deliberately redacted, I checked the boundary argument rather than taking it on trust, and ran the endpoints against a live app. It holds up. Two minor notes, neither blocking.
The boundary claim checks out
The detailed map does carry real internal topology — datasource/sql/health.go:31 puts host:port/database into Details, datasource/redis/health.go:24 the redis host — so "it's fine, it's on the metrics port" needed to be more than an assertion.
It is. metrics.GetHandler already registers /debug/pprof/cmdline (which dumps os.Args), profile, trace and the pprof index on that port, all unauthenticated (metrics/handler.go:28-33). That port already carries strictly more sensitive material than a health map, so this does not lower the boundary — it puts the map behind the one that already exists.
I also appreciated that the docs say this plainly rather than implying loopback:
the metrics server binds all interfaces, so the boundary is network policy, not loopback
That matches the code (Addr: fmt.Sprintf(":%d", m.port)). Being straight about it is the right call.
Verified against a running app
Built and ran with a real redis attached, then exercised both ports:
| check | result |
|---|---|
GET /health (metrics port) |
200, application/json, full map including "host": "localhost:20081" and redis stats |
POST /health |
405 with Allow: GET |
/metrics |
200 — no regression |
/debug/pprof/cmdline |
200 — no regression |
/.well-known/health (public port) |
still redacted, {name, status} only |
GET /health on the public port |
404 — correctly not exposed |
/health/ (trailing slash) |
404 via the catch-all |
The second-to-last row is the one that mattered, and it is right.
The docs sample matches the real payload exactly — top-level keys came back as name, version, status plus the per-dependency entries, which is what appHealth produces (container/health.go:106-115).
Both new-test mutants are caught: removing the 405 branch fails non-GET is refused, and serving a redacted body instead of the full map fails GET serves the full detail map.
One thing worth calling out because it looks like it could be simplified but cannot: the manual method check is doing real work. Registering "GET /health" on the Go 1.22+ ServeMux would not yield a 405 here, because the / catch-all also matches the request, so a POST would reach the gorilla router and 404 instead. The comment in the handler says exactly this, which is the kind of note that saves the next reader a wrong "simplification".
Minor, non-blocking
1. /health does real work on every request, unauthenticated and uncached. Each hit runs Container.Health, which pings every configured datasource. That is fine at scrape rates and consistent with a port that already offers a 30-second CPU profile, but it is a different cost profile from /metrics next to it, and health probes can block (see #4156). Worth being deliberate about rather than discovering it later — a short note in the docs that this endpoint costs a round trip per dependency would be enough.
2. Path asymmetry. Public is /.well-known/health, internal is /health. I think this is the right call — the same path returning different bodies depending on port would be worse — but it is worth being a conscious decision rather than an accident, since ops tooling pointed at the wrong port gets a valid-looking response with different contents.
Verdict
Approving. Additive, correctly scoped, no existing route or shape touched, and the one claim that could easily have been hand-waved is accurate. Nice work on the docs — the network-boundary caveat is the part most PRs like this leave out.
Note that only security/snyk has reported so far; the main workflow has not run on this yet.
|
Hi @aryanmehrotra — just checking if there is anything you would like adjusted here. Happy to rework anything. Thanks! |
Description:
Fixes #3806.
The #3802 redaction left the detailed per-dependency health map served nowhere: the public
/.well-known/healthendpoint correctly reports only{name, status}, but ops tooling lost all per-dependency visibility. This PR serves the full map fromContainer.Health()atGET /healthon the metrics server (METRICS_PORT, default2121), next to/metricsand/debug/pprof.Implementation notes:
newMetricsMux(packagegofr), which mounts the existingmetrics.GetHandlerunder/. Registering it there — rather than passing the container intoGetHandler— avoids an import cycle (containeralready importsmetrics) and leaves that exported signature untouched.200with the raw JSON map regardless of aggregate status (same contract as the public endpoint — callers read thestatusfield); non-GET gets405with anAllowheader instead of falling through to the catch-all's404.METRICS_PORT=0disables the whole metrics server, this endpoint with it.monitoring-service-health, including the network-boundary caveat (the metrics port binds all interfaces — privacy means keeping it out of public ingress, same as/debug/pprof).Verified with new
TestMetricsMux_DetailedHealth(200 + body keys, POST → 405, unknown path → underlying router's 404). Fullpkg/gofr,pkg/gofr/httpandpkg/gofr/metricssuites pass;go vetclean.Breaking Changes (if applicable):
None. Additive-only: one new route on the metrics port, no existing route or response shape touched.
Additional Information:
No new dependencies.
Checklist: