87 lines
2.7 KiB
Go
87 lines
2.7 KiB
Go
|
|
package httpapi
|
||
|
|
|
||
|
|
import (
|
||
|
|
"net/http"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
// LoggingMiddleware emits one structured log line per request.
|
||
|
|
//
|
||
|
|
// The line carries the correlation id, the resolved client address and the
|
||
|
|
// authenticated subject, which together are enough to answer "who did what, from
|
||
|
|
// where, and which request" without correlating across systems.
|
||
|
|
func LoggingMiddleware(log Logger) func(http.Handler) http.Handler {
|
||
|
|
return func(next http.Handler) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
start := time.Now()
|
||
|
|
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||
|
|
|
||
|
|
next.ServeHTTP(recorder, r)
|
||
|
|
|
||
|
|
elapsed := time.Since(start)
|
||
|
|
args := []any{
|
||
|
|
"method", r.Method,
|
||
|
|
"path", r.URL.Path,
|
||
|
|
"status", recorder.status,
|
||
|
|
"duration", elapsed.Round(time.Microsecond).String(),
|
||
|
|
"durationMs", float64(elapsed.Microseconds()) / 1000,
|
||
|
|
"bytes", recorder.written,
|
||
|
|
"requestId", requestIDFrom(r.Context()),
|
||
|
|
}
|
||
|
|
if ip := clientIPFrom(r.Context()); ip != "" {
|
||
|
|
args = append(args, "clientIp", ip)
|
||
|
|
}
|
||
|
|
if subject, _ := actorFrom(r.Context()); subject != "" && subject != "anonymous" {
|
||
|
|
args = append(args, "subject", subject)
|
||
|
|
}
|
||
|
|
if route := chi.RouteContext(r.Context()).RoutePattern(); route != "" {
|
||
|
|
args = append(args, "route", route)
|
||
|
|
}
|
||
|
|
if query := r.URL.RawQuery; query != "" && len(query) < 256 {
|
||
|
|
args = append(args, "query", query)
|
||
|
|
}
|
||
|
|
|
||
|
|
switch {
|
||
|
|
case recorder.status >= 500:
|
||
|
|
log.Error("request", args...)
|
||
|
|
case recorder.status >= 400:
|
||
|
|
// A 4xx is a client mistake and is logged at warn so it is visible
|
||
|
|
// without being mistaken for a service fault.
|
||
|
|
log.Warn("request", args...)
|
||
|
|
case elapsed > time.Second:
|
||
|
|
// A slow success matters operationally, so it is surfaced even
|
||
|
|
// though it is not an error.
|
||
|
|
log.Warn("slow request", args...)
|
||
|
|
default:
|
||
|
|
log.Info("request", args...)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MetricsMiddleware records request metrics.
|
||
|
|
func MetricsMiddleware(metrics *Metrics) func(http.Handler) http.Handler {
|
||
|
|
return func(next http.Handler) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if metrics == nil {
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
start := time.Now()
|
||
|
|
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||
|
|
next.ServeHTTP(recorder, r)
|
||
|
|
|
||
|
|
// The route pattern, not the concrete path, is the label: a thousand
|
||
|
|
// distinct theme slugs must not become a thousand time series.
|
||
|
|
route := chi.RouteContext(r.Context()).RoutePattern()
|
||
|
|
if route == "" {
|
||
|
|
route = "unmatched"
|
||
|
|
}
|
||
|
|
metrics.ObserveRequest(r.Method, route, recorder.status, time.Since(start))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|