feature: expose /metrics with prometheus client_golang

Adds Counter (http_requests_total) and Histogram
(http_request_duration_seconds) labeled by method, chi route pattern,
and status. Wired through a new Metrics middleware (after Recovery so
panics-turned-500s are still counted) and an unauthenticated /metrics
endpoint at the root for Prometheus to scrape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 20:40:39 +02:00
parent 67f01f4a38
commit 67c8a6bafb
4 changed files with 116 additions and 8 deletions

57
api/middleware/metrics.go Normal file
View File

@@ -0,0 +1,57 @@
package middleware
import (
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total HTTP requests processed, labeled by method, route, and status",
},
[]string{"method", "route", "status"},
)
httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request latency in seconds, labeled by method and route",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "route"},
)
)
// Metrics records request count and latency for every request
func Metrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := newResponseWriter(w)
next.ServeHTTP(wrapped, r)
route := chi.RouteContext(r.Context()).RoutePattern()
if route == "" {
route = "unknown"
}
duration := time.Since(start).Seconds()
status := strconv.Itoa(wrapped.statusCode)
httpRequestsTotal.WithLabelValues(r.Method, route, status).Inc()
httpRequestDuration.WithLabelValues(r.Method, route).Observe(duration)
})
}
// MetricsHandler returns the Prometheus scrape endpoint
func MetricsHandler() http.Handler {
return promhttp.Handler()
}

View File

@@ -29,6 +29,7 @@ func SetupRoutes(
r.Use(middleware.Logger)
r.Use(middleware.CORS())
r.Use(middleware.Recovery)
r.Use(middleware.Metrics)
r.Use(middleware.Auth(jwtManager))
// Broad IP-level rate limit: 200 req/min per IP across all endpoints.
r.Use(middleware.RateLimitByIP(200, time.Minute))
@@ -50,6 +51,7 @@ func SetupRoutes(
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
})
r.Handle("/metrics", middleware.MetricsHandler())
r.Route("/api/v1", func(r chi.Router) {
// Health — accept HEAD for cheap probes from monitoring tools.