Skip to content

feat(health): serve detailed health map on metrics server (#3806) - #4162

Open
ogMaverick12 wants to merge 1 commit into
gofr-dev:developmentfrom
ogMaverick12:feat/health-metrics-endpoint-3806
Open

feat(health): serve detailed health map on metrics server (#3806)#4162
ogMaverick12 wants to merge 1 commit into
gofr-dev:developmentfrom
ogMaverick12:feat/health-metrics-endpoint-3806

Conversation

@ogMaverick12

Copy link
Copy Markdown

Description:

Fixes #3806.

The #3802 redaction left the detailed per-dependency health map served nowhere: the public /.well-known/health endpoint correctly reports only {name, status}, but ops tooling lost all per-dependency visibility. This PR serves the full map from Container.Health() at GET /health on the metrics server (METRICS_PORT, default 2121), next to /metrics and /debug/pprof.

Implementation notes:

  • The route is registered in newMetricsMux (package gofr), which mounts the existing metrics.GetHandler under /. Registering it there — rather than passing the container into GetHandler — avoids an import cycle (container already imports metrics) and leaves that exported signature untouched.
  • The handler answers 200 with the raw JSON map regardless of aggregate status (same contract as the public endpoint — callers read the status field); non-GET gets 405 with an Allow header instead of falling through to the catch-all's 404.
  • METRICS_PORT=0 disables the whole metrics server, this endpoint with it.
  • Docs updated in 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). Full pkg/gofr, pkg/gofr/http and pkg/gofr/metrics suites pass; go vet clean.

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:

  • All new code is covered by unit tests.
  • I have reviewed the code comments and documentation for clarity.
  • This PR does not decrease the overall code coverage.
  • No new dependencies were added.

@ogMaverick12

Copy link
Copy Markdown
Author

Hi @aryanmehrotra — this picks up the deferred second half of the #3802 redaction (tracked in #3806): the detailed health map is now served at GET /health on the metrics port, so ops tooling gets its visibility back while the public endpoint stays redacted.

A few design notes:

  • The route is registered in newMetricsMux rather than inside metrics.GetHandler — passing the container in would create an import cycle (container already imports metrics), and this keeps that exported signature untouched.
  • Non-GET gets 405 with an Allow header instead of falling through to the catch-all 404, matching /metrics next to it (GET-only) and the spirit of the static-endpoint handling.
  • METRICS_PORT=0 disables the whole metrics server, this endpoint with it — and the docs call out that the privacy boundary is ingress policy, same as /debug/pprof.

TestMetricsMux_DetailedHealth covers the 200 body/keys, the 405, and delegation of unknown paths to the underlying router. Would love your review when you have a moment. Thanks!

@aryanmehrotra aryanmehrotra left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ogMaverick12

Copy link
Copy Markdown
Author

Hi @aryanmehrotra — just checking if there is anything you would like adjusted here. Happy to rework anything. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serve detailed health map on the metrics port (follow-up to #3802)

2 participants