diff --git a/erpc/init.go b/erpc/init.go index f63be83f9..b9ab03949 100644 --- a/erpc/init.go +++ b/erpc/init.go @@ -127,7 +127,7 @@ func Init( return appCtx }, Addr: fmt.Sprintf(":%d", *cfg.Metrics.Port), - Handler: promhttp.Handler(), + Handler: newMetricsHandler(), ReadHeaderTimeout: 10 * time.Second, } go func() { @@ -158,3 +158,25 @@ func Init( return nil } + +// newMetricsHandler serves Prometheus on /metrics (and / for back-compat) and a +// cheap /healthz|/health that does not Gather the registry. +// +// Historically promhttp.Handler() was mounted as the root handler, so every +// path — including kubelet probes hitting /healthz — returned the full +// exposition. On hot pods that payload is tens of MB and Gather contends with +// scrapes; probe timeoutSeconds: 5 then fails and kubelet restarts the pod. +func newMetricsHandler() http.Handler { + mux := http.NewServeMux() + metrics := promhttp.Handler() + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + mux.Handle("/healthz", ok) + mux.Handle("/health", ok) + mux.Handle("/metrics", metrics) + mux.Handle("/", metrics) + return mux +} diff --git a/erpc/metrics_handler_test.go b/erpc/metrics_handler_test.go new file mode 100644 index 000000000..69ee64ef1 --- /dev/null +++ b/erpc/metrics_handler_test.go @@ -0,0 +1,57 @@ +package erpc + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNewMetricsHandler_HealthzIsLightweight(t *testing.T) { + h := newMetricsHandler() + + for _, path := range []string{"/healthz", "/health"} { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("%s: status=%d want 200", path, rr.Code) + } + body, _ := io.ReadAll(rr.Body) + if string(body) != "OK" { + t.Fatalf("%s: body=%q want OK", path, body) + } + ct := rr.Header().Get("Content-Type") + if strings.Contains(ct, "openmetrics") || strings.Contains(ct, "text/plain; version=") { + t.Fatalf("%s: got prometheus content-type %q; health must not Gather", path, ct) + } + } +} + +func TestNewMetricsHandler_MetricsStillExposition(t *testing.T) { + h := newMetricsHandler() + + for _, path := range []string{"/metrics", "/"} { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("%s: status=%d want 200", path, rr.Code) + } + body, _ := io.ReadAll(rr.Body) + if !strings.Contains(string(body), "# HELP") && !strings.Contains(string(body), "# TYPE") { + // Empty registry still usually emits process/go collectors via default registerer. + t.Fatalf("%s: expected prometheus exposition, got %q", path, truncate(string(body), 200)) + } + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +}