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)) }) } }