Files
n30nex dab228e9bb feat(logging): configure levels and structured runtime output (#144)
* feat(logging): configure levels and structured runtime output

* fix(logging): bound request-derived log fields

* fix(logging): retain malformed IATA source attributes safely

* test(logging): capture structured REST rate-limit records
2026-09-19 10:36:26 -04:00

64 lines
2.0 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import (
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
)
// RequestLogger uses chi's response wrapper to preserve streaming/hijacking support.
// Place it after TrustedProxyIP and RequestID, and before Recoverer.
func RequestLogger(next http.Handler) http.Handler {
return chimw.RequestLogger(requestLogFormatter{})(next)
}
type requestLogFormatter struct{}
type requestLogEntry struct{ request *http.Request }
func (requestLogFormatter) NewLogEntry(r *http.Request) chimw.LogEntry {
return &requestLogEntry{request: r}
}
func (e *requestLogEntry) logger() *slog.Logger {
path := "unmatched"
if ctx := chi.RouteContext(e.request.Context()); ctx != nil && ctx.RoutePattern() != "" {
path = ctx.RoutePattern()
}
return slog.Default().With("component", "http", "method", singleLineLogValue(e.request.Method), "path", path,
"client_ip", singleLineLogValue(e.request.RemoteAddr), "request_id", singleLineLogValue(chimw.GetReqID(e.request.Context())))
}
// Strip line breaks before request fields reach a handler or downstream log consumer.
func singleLineLogValue(value string) string {
value = strings.ReplaceAll(value, "\r", "")
return strings.ReplaceAll(value, "\n", "")
}
func (e *requestLogEntry) Write(status, bytes int, _ http.Header, elapsed time.Duration, _ any) {
if status == 0 {
status = http.StatusOK
}
level := slog.LevelInfo
if status >= 500 {
level = slog.LevelError
} else if status >= 400 {
level = slog.LevelWarn
}
if !slog.Default().Enabled(e.request.Context(), level) {
return
}
e.logger().Log(e.request.Context(), level, "request complete", "status", status, "bytes", bytes, "duration_ms", float64(elapsed)/float64(time.Millisecond))
}
func (e *requestLogEntry) Panic(value any, stack []byte) {
e.logger().ErrorContext(e.request.Context(), "request panic", "panic", fmt.Sprint(value), "stack", string(stack))
}