tenseleyflow/shithub / b701fac

Browse files

web: bypass Compress middleware on /metrics

Prometheus scrapers advertise Accept-Encoding: gzip, but Alloy 1.16
mis-handles Content-Encoding: gzip on responses — it parses the raw
0x1f magic byte as text and silently drops the scrape (up=0).

Mount /metrics on the bare router so only global middleware applies
(request_id, access_log, metrics, secure_headers). Adds regression
test that asserts no Content-Encoding header when scraper sends
Accept-Encoding: gzip.

Closes #33.
Authored by espadonne
SHA
b701face2ac2e3629246fff3cd4c7c315c40a7cb
Parents
e2c3943
Tree
706bdbd

2 changed files

StatusFile+-
M internal/web/handlers/handlers.go 10 3
A internal/web/handlers/metrics_compress_test.go 57 0
internal/web/handlers/handlers.gomodified
@@ -175,6 +175,16 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
175175
 		}),
176176
 	})
177177
 
178
+	// /metrics MUST NOT pass through Compress: Prometheus scrapers
179
+	// (Alloy 1.16, vmagent, …) advertise Accept-Encoding: gzip but
180
+	// mis-handle Content-Encoding: gzip on the response, parsing the
181
+	// raw 0x1f magic byte as text and failing the scrape (up=0).
182
+	// Mount it on the bare router so only the global middleware
183
+	// (request_id, access_log, metrics, secure_headers) applies.
184
+	if deps.MetricsHandler != nil {
185
+		r.Handle("/metrics", deps.MetricsHandler)
186
+	}
187
+
178188
 	// Static and health endpoints are CSRF-exempt; everything else passes
179189
 	// through the CSRF wrapper for state-changing methods.
180190
 	r.Group(func(r chi.Router) {
@@ -187,9 +197,6 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
187197
 		r.Get("/static/css/chroma.css", chromaCSSHandler())
188198
 		r.Get("/healthz", healthz)
189199
 		r.Handle("/readyz", readinessHandler(deps.ReadyCheck, deps.Logger))
190
-		if deps.MetricsHandler != nil {
191
-			r.Handle("/metrics", deps.MetricsHandler)
192
-		}
193200
 		if deps.APIMounter != nil {
194201
 			deps.APIMounter(r)
195202
 		}
internal/web/handlers/metrics_compress_test.goadded
@@ -0,0 +1,57 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"io"
7
+	"log/slog"
8
+	"net/http"
9
+	"net/http/httptest"
10
+	"strings"
11
+	"testing"
12
+
13
+	"github.com/go-chi/chi/v5"
14
+)
15
+
16
+// /metrics MUST be served uncompressed even when the scraper advertises
17
+// gzip support. Alloy 1.16 (and several other prom-compatible scrapers)
18
+// mis-handle Content-Encoding: gzip and parse the raw 0x1f magic byte
19
+// as text, failing the scrape silently with up=0.
20
+func TestMetricsServedUncompressedWithGzipAccept(t *testing.T) {
21
+	t.Parallel()
22
+
23
+	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24
+		w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
25
+		_, _ = io.WriteString(w, "# HELP test_metric A test metric\n# TYPE test_metric counter\ntest_metric 1\n")
26
+	})
27
+
28
+	r := chi.NewRouter()
29
+	if _, _, _, err := RegisterChi(r, Deps{
30
+		Logger:         slog.New(slog.NewTextHandler(io.Discard, nil)),
31
+		TemplatesFS:    testTemplatesFS(t),
32
+		StaticFS:       testStaticFS(t),
33
+		LogoSVG:        `<svg xmlns="http://www.w3.org/2000/svg"><title>shithub</title></svg>`,
34
+		MetricsHandler: handler,
35
+	}); err != nil {
36
+		t.Fatalf("RegisterChi: %v", err)
37
+	}
38
+
39
+	req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
40
+	req.Header.Set("Accept-Encoding", "gzip")
41
+	rec := httptest.NewRecorder()
42
+	r.ServeHTTP(rec, req)
43
+
44
+	if got := rec.Code; got != http.StatusOK {
45
+		t.Fatalf("status = %d, want 200", got)
46
+	}
47
+	if enc := rec.Header().Get("Content-Encoding"); enc != "" {
48
+		t.Errorf("Content-Encoding = %q, want empty (Prometheus scrapers expect plain text)", enc)
49
+	}
50
+	body := rec.Body.String()
51
+	if !strings.Contains(body, "test_metric 1") {
52
+		t.Errorf("body missing metric text; got %q", body)
53
+	}
54
+	if strings.HasPrefix(body, "\x1f\x8b") {
55
+		t.Errorf("body starts with gzip magic bytes — middleware compressed /metrics")
56
+	}
57
+}