| 1 |
|
package chimw |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"log/slog" |
| 6 |
|
"net/http" |
| 7 |
|
"time" |
| 8 |
|
|
| 9 |
|
chimiddleware "github.com/go-chi/chi/v5/middleware" |
| 10 |
|
|
| 11 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/middleware" |
| 12 |
|
) |
| 13 |
|
|
| 14 |
|
// defaultLogMessage is the message of the request record. It is a constant |
| 15 |
|
// rather than a formatted sentence because everything that varies is an |
| 16 |
|
// attribute: a structured record whose message changed per request would make |
| 17 |
|
// the one field a log pipeline groups by useless. |
| 18 |
|
const defaultLogMessage = "request" |
| 19 |
|
|
| 20 |
|
// SlogFormatter is chi's middleware.LogFormatter emitting slog records instead |
| 21 |
|
// of chi's own line. |
| 22 |
|
// |
| 23 |
|
// chi's Logger writes through a package-level stdlib log.Logger to *stdout*, in |
| 24 |
|
// a colourised human format, with no fields. On this instance that is the |
| 25 |
|
// highest-volume line a service emits and the only one that is not structured |
| 26 |
|
// and not on stderr: an operator who greps the journal for a request id finds |
| 27 |
|
// every panic and every audit line of the daemon, and none of its requests. |
| 28 |
|
// Replacing it is the entire point of this type. |
| 29 |
|
// |
| 30 |
|
// It is a LogFormatter rather than a middleware of our own because chi already |
| 31 |
|
// owns the parts that are tedious to get right — wrapping the ResponseWriter so |
| 32 |
|
// that status and byte count are observable at all, and writing the entry from a |
| 33 |
|
// defer so that a request that panics is still logged. What was missing was |
| 34 |
|
// somewhere to send the result. |
| 35 |
|
// |
| 36 |
|
// The zero value works: records go to slog.Default(), under the message |
| 37 |
|
// "request", for every request. Use it as |
| 38 |
|
// |
| 39 |
|
// r.Use(chimw.RequestLogger(chimw.SlogFormatter{})) |
| 40 |
|
type SlogFormatter struct { |
| 41 |
|
// Logger is where records go. Nil means slog.Default(), read at write time, |
| 42 |
|
// so a service that calls slog.SetDefault after building its router still |
| 43 |
|
// gets its own handler — this is middleware.RecoverPanics's rule, for the |
| 44 |
|
// reason given in the package doc. |
| 45 |
|
Logger *slog.Logger |
| 46 |
|
|
| 47 |
|
// Message overrides the record's message. Empty means "request". |
| 48 |
|
Message string |
| 49 |
|
|
| 50 |
|
// Skip decides which requests produce no line. Nil logs everything. |
| 51 |
|
// |
| 52 |
|
// The decision is the caller's and not this package's, deliberately. The |
| 53 |
|
// noise worth dropping is a probe hitting /healthz every second — but |
| 54 |
|
// "/healthz" is this instance's spelling, a service may also be polled at |
| 55 |
|
// /metrics, at a badge a README embeds, or at a static prefix, and which of |
| 56 |
|
// those is noise depends on what the operator is looking for that week. A |
| 57 |
|
// list baked in here would silently drop the one line somebody debugging |
| 58 |
|
// the probe needs, in a package they would have to read the source of to |
| 59 |
|
// find out why. SkipPaths covers the common case in one call. |
| 60 |
|
// |
| 61 |
|
// It is evaluated once per request, before the handler runs, and it |
| 62 |
|
// silences only the request line: a panic on a skipped path is still |
| 63 |
|
// reported, because a probe path that panics is not noise. |
| 64 |
|
Skip func(r *http.Request) bool |
| 65 |
|
} |
| 66 |
|
|
| 67 |
|
// RequestLogger is the middleware for a formatter: chi's RequestLogger with this |
| 68 |
|
// package's formatter already in it, so that a service need not import chi's |
| 69 |
|
// middleware package to install one. |
| 70 |
|
// |
| 71 |
|
// It goes outermost, ahead of middleware.RecoverPanics — see the package doc for |
| 72 |
|
// why — and after chi's RequestID and RealIP, which must have run before the |
| 73 |
|
// entry is built for the record to carry an id and for RemoteAddr to be the |
| 74 |
|
// viewer's. |
| 75 |
20 |
func RequestLogger(f SlogFormatter) func(http.Handler) http.Handler { |
| 76 |
20 |
return chimiddleware.RequestLogger(f) |
| 77 |
20 |
} |
| 78 |
|
|
| 79 |
|
// SkipPaths builds a Skip predicate matching a fixed set of exact paths, which |
| 80 |
|
// is what a probe endpoint is. It matches on the path alone: a query string |
| 81 |
|
// cannot turn /healthz into something worth a line. |
| 82 |
3 |
func SkipPaths(paths ...string) func(r *http.Request) bool { |
| 83 |
3 |
set := make(map[string]struct{}, len(paths)) |
| 84 |
3 |
for _, p := range paths { |
| 85 |
2 |
set[p] = struct{}{} |
| 86 |
2 |
} |
| 87 |
4 |
return func(r *http.Request) bool { |
| 88 |
4 |
_, ok := set[r.URL.Path] |
| 89 |
4 |
return ok |
| 90 |
4 |
} |
| 91 |
|
} |
| 92 |
|
|
| 93 |
|
// NewLogEntry captures what is known before the handler runs. It implements |
| 94 |
|
// chi's middleware.LogFormatter. |
| 95 |
21 |
func (f SlogFormatter) NewLogEntry(r *http.Request) chimiddleware.LogEntry { |
| 96 |
21 |
ctx := r.Context() |
| 97 |
21 |
return &logEntry{ |
| 98 |
21 |
formatter: f, |
| 99 |
21 |
ctx: ctx, |
| 100 |
21 |
method: r.Method, |
| 101 |
21 |
path: r.URL.Path, |
| 102 |
21 |
requestID: chimiddleware.GetReqID(ctx), |
| 103 |
21 |
quiet: f.Skip != nil && f.Skip(r), |
| 104 |
21 |
} |
| 105 |
21 |
} |
| 106 |
|
|
| 107 |
|
// logEntry is one request's record, filled in when the response is done. |
| 108 |
|
// |
| 109 |
|
// The request itself is not held on to. What is logged is copied out here, at |
| 110 |
|
// the top of the chain, where r.Method is still the method that arrived and the |
| 111 |
|
// path has not been rewritten by anything mounted below; keeping the *http.Request |
| 112 |
|
// would mean logging whatever the last middleware to rewrite it decided. |
| 113 |
|
type logEntry struct { |
| 114 |
|
formatter SlogFormatter |
| 115 |
|
ctx context.Context |
| 116 |
|
method string |
| 117 |
|
path string |
| 118 |
|
requestID string |
| 119 |
|
quiet bool |
| 120 |
|
} |
| 121 |
|
|
| 122 |
|
// Write emits the request line. chi calls it from a defer, so it runs for a |
| 123 |
|
// handler that returned normally, one that panicked, and one whose client hung |
| 124 |
|
// up halfway. |
| 125 |
|
// |
| 126 |
|
// The attributes are the five that answer "what happened to this request" — |
| 127 |
|
// method, path, status, bytes, duration — plus the request id when chi's |
| 128 |
|
// RequestID middleware is installed above this one, which is the field that ties |
| 129 |
|
// the line to the panic report and to whatever the handler logged in between. |
| 130 |
|
// |
| 131 |
|
// path is r.URL.Path and deliberately not RequestURI: the query string of these |
| 132 |
|
// services carries search terms a viewer typed and, on the pages that come back |
| 133 |
|
// from an OAuth round trip, parameters nobody wants written to disk twice. The |
| 134 |
|
// route pattern is not logged either — it is available from the route context by |
| 135 |
|
// the time this runs, but it is derivable from the path by anyone reading, and |
| 136 |
|
// the path is the thing an operator has in front of them when a report comes in. |
| 137 |
|
// |
| 138 |
|
// The header is ignored. It is the response's, it is large, and the two fields |
| 139 |
|
// of it worth having (status and length) are already arguments. |
| 140 |
21 |
func (e *logEntry) Write(status, bytes int, _ http.Header, elapsed time.Duration, _ any) { |
| 141 |
21 |
if e.quiet { |
| 142 |
2 |
return |
| 143 |
2 |
} |
| 144 |
|
|
| 145 |
19 |
status = e.reportedStatus(status) |
| 146 |
19 |
|
| 147 |
19 |
attrs := make([]slog.Attr, 0, 6) |
| 148 |
19 |
attrs = append(attrs, |
| 149 |
19 |
slog.String("method", e.method), |
| 150 |
19 |
slog.String("path", e.path), |
| 151 |
19 |
slog.Int("status", status), |
| 152 |
19 |
slog.Int("bytes", bytes), |
| 153 |
19 |
slog.Duration("duration", elapsed), |
| 154 |
19 |
) |
| 155 |
19 |
if e.requestID != "" { |
| 156 |
1 |
attrs = append(attrs, slog.String("request_id", e.requestID)) |
| 157 |
1 |
} |
| 158 |
|
|
| 159 |
19 |
e.logger().LogAttrs(e.ctx, levelFor(status), e.message(), attrs...) |
| 160 |
|
} |
| 161 |
|
|
| 162 |
|
// Panic is what chi's Recoverer reports through when a log entry is in context; |
| 163 |
|
// without it that report is printed to stdout as a pretty-coloured stack, which |
| 164 |
|
// is the same escape from the log this type exists to close. |
| 165 |
|
// |
| 166 |
|
// It does not double up with middleware.RecoverPanics. That one recovers the |
| 167 |
|
// panic and renders a page, so the only panics still travelling when Recoverer |
| 168 |
|
// looks are the ones it re-raises deliberately — http.ErrAbortHandler, which |
| 169 |
|
// chi's Recoverer re-panics without calling this method. |
| 170 |
|
// |
| 171 |
|
// Skip does not silence it. A request nobody wanted a line for is still a |
| 172 |
|
// request whose panic somebody needs. |
| 173 |
2 |
func (e *logEntry) Panic(v any, stack []byte) { |
| 174 |
2 |
attrs := make([]slog.Attr, 0, 5) |
| 175 |
2 |
attrs = append(attrs, |
| 176 |
2 |
slog.String("method", e.method), |
| 177 |
2 |
slog.String("path", e.path), |
| 178 |
2 |
slog.Any("panic", v), |
| 179 |
2 |
slog.String("stack", string(stack)), |
| 180 |
2 |
) |
| 181 |
2 |
if e.requestID != "" { |
| 182 |
0 |
attrs = append(attrs, slog.String("request_id", e.requestID)) |
| 183 |
0 |
} |
| 184 |
|
|
| 185 |
2 |
e.logger().LogAttrs(e.ctx, slog.LevelError, "panic serving a request", attrs...) |
| 186 |
|
} |
| 187 |
|
|
| 188 |
|
// reportedStatus turns chi's "nothing was written" into the status the client |
| 189 |
|
// actually saw. |
| 190 |
|
// |
| 191 |
|
// chi's wrapped writer reports 0 when no WriteHeader and no Write ever happened. |
| 192 |
|
// Two things produce that. A handler that returned without touching the writer, |
| 193 |
|
// which net/http answers with an empty 200 — so 200 is what the client got, and |
| 194 |
|
// logging a 0 would send an operator looking for a bug in a redirect that worked. |
| 195 |
|
// And a handler that gave up because the caller was already gone, which is what a |
| 196 |
|
// cancelled request context means here: no status line was sent because there was |
| 197 |
|
// nobody left to send it to. |
| 198 |
|
// |
| 199 |
|
// That second case is reported as middleware.StatusClientClosedRequest — nginx's |
| 200 |
|
// 499, the code the log pipelines in front of this instance already read as "the |
| 201 |
|
// client hung up". It is deliberately not any 5xx, which is what levelFor turns |
| 202 |
|
// into an Error record and what the alert rate is written against: a viewer who |
| 203 |
|
// navigated away mid-render and a CI job that pressed ^C must not page anybody. |
| 204 |
|
// That reasoning is the constant's own (middleware.StatusClientClosedRequest); |
| 205 |
|
// this is the place it gets applied to the highest-volume line the service emits. |
| 206 |
|
// |
| 207 |
|
// A handler that answered 499 itself — the donors' status mapping does, on both |
| 208 |
|
// surfaces — needs no help from here: 499 is below 500, so it is already an Info |
| 209 |
|
// record, which is the whole reason that number was picked over 500. |
| 210 |
19 |
func (e *logEntry) reportedStatus(status int) int { |
| 211 |
19 |
if status != 0 { |
| 212 |
15 |
return status |
| 213 |
15 |
} |
| 214 |
4 |
if e.ctx.Err() != nil { |
| 215 |
1 |
return middleware.StatusClientClosedRequest |
| 216 |
1 |
} |
| 217 |
3 |
return http.StatusOK |
| 218 |
|
} |
| 219 |
|
|
| 220 |
|
// levelFor picks the level of a request line from its status. |
| 221 |
|
// |
| 222 |
|
// Everything is Info except a 5xx, which is Error. A request line is not a |
| 223 |
|
// finding — it is the record that something was served — so the default is the |
| 224 |
|
// level a service's ordinary progress is logged at, and a deployment that |
| 225 |
|
// dropped it to Warn would be trading away the only per-request evidence it has. |
| 226 |
|
// |
| 227 |
|
// A 5xx is promoted because it is the one status the service is confessing to: |
| 228 |
|
// nothing the viewer typed produces it, and the record is worth its own line in |
| 229 |
|
// an operator's filter without them having to know the field name for status. |
| 230 |
|
// 4xx stays at Info on purpose. A 404 is a crawler, a stale bookmark or somebody |
| 231 |
|
// mistyping a repository name, a 403 is the visibility rules working, and |
| 232 |
|
// promoting either would hand a stranger with a URL bar the ability to set the |
| 233 |
|
// warning rate of the instance. |
| 234 |
|
// |
| 235 |
|
// Nothing here special-cases 499: it is below 500 and lands at Info by the same |
| 236 |
|
// rule as a 404, which is what makes it the right code for a client that hung up. |
| 237 |
19 |
func levelFor(status int) slog.Level { |
| 238 |
19 |
if status >= http.StatusInternalServerError { |
| 239 |
4 |
return slog.LevelError |
| 240 |
4 |
} |
| 241 |
15 |
return slog.LevelInfo |
| 242 |
|
} |
| 243 |
|
|
| 244 |
21 |
func (e *logEntry) logger() *slog.Logger { |
| 245 |
21 |
if e.formatter.Logger != nil { |
| 246 |
19 |
return e.formatter.Logger |
| 247 |
19 |
} |
| 248 |
2 |
return slog.Default() |
| 249 |
|
} |
| 250 |
|
|
| 251 |
19 |
func (e *logEntry) message() string { |
| 252 |
19 |
if e.formatter.Message != "" { |
| 253 |
1 |
return e.formatter.Message |
| 254 |
1 |
} |
| 255 |
18 |
return defaultLogMessage |
| 256 |
|
} |