HTTP request metrics middleware (Echo) #36

Open
opened 2026-10-10 21:50:08 +02:00 by Sansan · 0 comments
Owner

Background
Depends on: "Expose Prometheus /metrics on a separate port".

The Grafana MusicServer dashboard has panels for Requests/s, Requests/s by route, p95 latency by route and Error rate (5xx %). These expect:

http_requests_total{method, route, code} (counter)
http_request_duration_seconds_bucket{method, route, ...} (histogram)
What to do
Add an Echo v5 middleware to internal/metrics. Use c.Path(), the registered route pattern (for example /api/v1/music/info), not the raw URI. This keeps the label count small; query strings and IDs must never end up in labels. Status resolution uses echo.ResolveResponseStatus, the same helper Echo's own RequestLogger uses.

package metrics

import (
"strconv"
"time"

"github.com/labstack/echo/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"

)

var (
httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "http_requests_total",
Help: "HTTP requests by method, route and status code.",
}, []string{"method", "route", "code"})

httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
	Name:    "http_request_duration_seconds",
	Help:    "HTTP request latency by method and route.",
	Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 30},
}, []string{"method", "route"})

)

// Middleware records request count and latency per route.
func Middleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
start := time.Now()
err := next(c)

		route := c.Path()
		if route == "" {
			route = "unmatched" // 404s etc. - don't use the raw URL
		}
		_, status := echo.ResolveResponseStatus(c.Response(), err)
		method := c.Request().Method

		httpRequests.WithLabelValues(method, route, strconv.Itoa(status)).Inc()
		httpDuration.WithLabelValues(method, route).Observe(time.Since(start).Seconds())
		return err
	}
}

}
Register it in internal/server/routes.go right after logging.RequestLogger():

e.Use(logging.RequestLogger())
e.Use(metrics.Middleware())
Note: /assets/* and the / static handler will show up as their wildcard pattern, which is fine. If the static files get too noisy, skip them with if strings.HasPrefix(route, "/assets") { return err }.

Acceptance criteria
not done
http_requests_total and http_request_duration_seconds_bucket appear on /metrics
not done
route label values are route patterns, never full URLs with query strings
not done
Unit test: a request to a test route increments the counter with the expected labels (prometheus/testutil.ToFloat64)
not done
Grafana dashboard panels Requests/s, p95 latency, Error rate show data

Background Depends on: "Expose Prometheus /metrics on a separate port". The Grafana MusicServer dashboard has panels for Requests/s, Requests/s by route, p95 latency by route and Error rate (5xx %). These expect: http_requests_total{method, route, code} (counter) http_request_duration_seconds_bucket{method, route, ...} (histogram) What to do Add an Echo v5 middleware to internal/metrics. Use c.Path(), the registered route pattern (for example /api/v1/music/info), not the raw URI. This keeps the label count small; query strings and IDs must never end up in labels. Status resolution uses echo.ResolveResponseStatus, the same helper Echo's own RequestLogger uses. package metrics import ( "strconv" "time" "github.com/labstack/echo/v5" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) var ( httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "http_requests_total", Help: "HTTP requests by method, route and status code.", }, []string{"method", "route", "code"}) httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "http_request_duration_seconds", Help: "HTTP request latency by method and route.", Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 30}, }, []string{"method", "route"}) ) // Middleware records request count and latency per route. func Middleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { start := time.Now() err := next(c) route := c.Path() if route == "" { route = "unmatched" // 404s etc. - don't use the raw URL } _, status := echo.ResolveResponseStatus(c.Response(), err) method := c.Request().Method httpRequests.WithLabelValues(method, route, strconv.Itoa(status)).Inc() httpDuration.WithLabelValues(method, route).Observe(time.Since(start).Seconds()) return err } } } Register it in internal/server/routes.go right after logging.RequestLogger(): e.Use(logging.RequestLogger()) e.Use(metrics.Middleware()) Note: /assets/* and the / static handler will show up as their wildcard pattern, which is fine. If the static files get too noisy, skip them with if strings.HasPrefix(route, "/assets") { return err }. Acceptance criteria not done http_requests_total and http_request_duration_seconds_bucket appear on /metrics not done route label values are route patterns, never full URLs with query strings not done Unit test: a request to a test route increments the counter with the expected labels (prometheus/testutil.ToFloat64) not done Grafana dashboard panels Requests/s, p95 latency, Error rate show data
Sansan added the ops label 2026-10-15 21:56:21 +02:00
Sign in to join this conversation.