Files
summercms/modules/surf/accesslog.go
Jakub Zych 4660f95e5c fix(14.2-03): access log records a cancelled request's 5xx as 499
A client that goes away mid-request cancels the context; a handler then
fails on context.Canceled and writes a 500 nobody receives. The request
log now records it as 499 (client closed request) so a grep for 5xx shows
real server errors only.
2026-10-11 20:27:56 +02:00

127 lines
4.1 KiB
Go

package surf
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// accessLogKey is the config key that turns the request log on.
const accessLogKey = "http.access_log"
// accessLogMessage is the message of every request log record.
const accessLogMessage = "http request"
// accessEntry is the request-scoped slot the matched route writes its
// pattern into. A pointer travels in the context, so the pattern reaches
// the access log even when stripLocalePrefix hands ServeMux a copied
// request whose Pattern the outer middleware never sees.
type accessEntry struct {
pattern string
}
type accessEntryKey struct{}
// statusClientClosedRequest is nginx's 499 "client closed request": the
// status the access log records for a request whose client went away before
// the handler answered.
const statusClientClosedRequest = 499
// accessLogEnabled reports whether http.access_log is true.
func accessLogEnabled(app *backpack.App) bool {
return app != nil && app.Config != nil && app.Config.Bool(accessLogKey)
}
// accessLog logs one Info record "http request" per request after it is
// served, with method, pattern (the matched route, "" when none matched),
// status and duration_ms (whole milliseconds). Only when no route matched
// (a ServeMux 404 or 405, or a CORS preflight answered before routing) is
// path added, set to the URL path without the query string. The query
// string, headers, the remote address and a matched route's raw path are
// never logged: share and invitation tokens travel in paths. A 5xx written
// after the client cancelled the request (its context ends with
// context.Canceled) is logged as 499: the error came from the cancellation,
// and no client received it. The logger is
// the app's published *slog.Logger, else slog.Default (which the generated
// main wraps in the redacting sunscreen handler).
func accessLog(app *backpack.App, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
start := time.Now()
entry := &accessEntry{}
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, req.WithContext(context.WithValue(req.Context(), accessEntryKey{}, entry)))
status := rec.status
if status == 0 {
status = http.StatusOK
}
if status >= http.StatusInternalServerError && errors.Is(req.Context().Err(), context.Canceled) {
status = statusClientClosedRequest
}
attrs := []slog.Attr{
slog.String("method", req.Method),
slog.String("pattern", entry.pattern),
slog.Int("status", status),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
}
if entry.pattern == "" {
attrs = append(attrs, slog.String("path", req.URL.Path))
}
accessLogger(app).LogAttrs(req.Context(), slog.LevelInfo, accessLogMessage, attrs...)
})
}
// accessLogger is the app's published logger, else slog.Default.
func accessLogger(app *backpack.App) *slog.Logger {
if app != nil {
if log, ok := app.Lookup[*slog.Logger](); ok && log != nil {
return log
}
}
return slog.Default()
}
// recordPattern stores pattern in the request's access log entry, when the
// access log put one into the context, before serving the route.
func recordPattern(pattern string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if entry, ok := req.Context().Value(accessEntryKey{}).(*accessEntry); ok && entry != nil {
entry.pattern = pattern
}
next.ServeHTTP(w, req)
})
}
// statusRecorder remembers the first status written; a Write without a
// WriteHeader is a 200. Flush and Unwrap keep http.ResponseController
// working through it.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (w *statusRecorder) WriteHeader(status int) {
if w.status == 0 {
w.status = status
}
w.ResponseWriter.WriteHeader(status)
}
func (w *statusRecorder) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
return w.ResponseWriter.Write(p)
}
func (w *statusRecorder) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *statusRecorder) Unwrap() http.ResponseWriter { return w.ResponseWriter }