1package middleware
2
3import (
4 "net"
5 "net/http"
6 "strings"
7 "time"
8
9 "github.com/bnema/zerowrap"
10
11 out "github.com/bnema/gordon/internal/boundaries/out"
12)
13
14// AccessLogger is a middleware that writes one access-log entry per HTTP request
15// to the provided writer. It runs alongside (not instead of) RequestLogger.
16//
17// When excludeHealthChecks is true, requests from the Gordon health prober
18// (UA prefix out.HealthCheckUserAgentPrefix) or from loopback IPs are not logged.
19//
20// Write failures are reported as warnings through the application logger and
21// never fail the HTTP response.
22func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets []*net.IPNet) func(http.Handler) http.Handler {
23 return func(next http.Handler) http.Handler {
24 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25 start := time.Now()
26
27 // Ensure a request ID exists before calling inner handlers so both
28 // this middleware and RequestLogger share the same value.
29 requestID, r := ensureRequestID(w, r)
30
31 // Wrap the response writer to capture status code and bytes written.
32 rw := NewResponseWriter(w)
33
34 // Resolve client IP before calling the inner handler so we see the
35 // original remote address before any mutation.
36 clientIP := GetClientIP(r, trustedNets)
37
38 next.ServeHTTP(rw, r)
39
40 // Apply health-check exclusion after the response so the request
41 // always completes — we just skip writing the log entry.
42 // UA check uses the shared domain constant so the prober and this
43 // filter can never drift. Loopback check reuses localhostNets from
44 // cidr.go (same package) — no separate loopback definition needed.
45 if excludeHealthChecks {
46 if strings.HasPrefix(r.UserAgent(), out.HealthCheckUserAgentPrefix) ||
47 IsTrustedProxy(clientIP, localhostNets) {
48 return
49 }
50 }
51
52 // Capture end time once; use it for both duration and entry timestamp
53 // so the two values are derived from the same instant.
54 end := time.Now()
55 durationMS := float64(end.Sub(start).Microseconds()) / 1000.0
56
57 entry := out.AccessLogEntry{
58 Time: end.UTC(),
59 ClientIP: clientIP,
60 Method: r.Method,
61 Host: r.Host,
62 Path: r.URL.Path,
63 Query: r.URL.RawQuery,
64 Status: rw.StatusCode(),
65 BytesSent: rw.BytesWritten(),
66 DurationMS: durationMS,
67 UserAgent: r.UserAgent(),
68 Referer: r.Referer(),
69 RequestID: requestID,
70 Proto: r.Proto,
71 }
72
73 if err := writer.Write(entry); err != nil {
74 log.Warn().
75 Str(zerowrap.FieldLayer, "adapter").
76 Str(zerowrap.FieldAdapter, "http").
77 Str(zerowrap.FieldRequestID, requestID).
78 Str(zerowrap.FieldMethod, r.Method).
79 Str(zerowrap.FieldPath, r.URL.Path).
80 Int(zerowrap.FieldStatus, rw.StatusCode()).
81 Str(zerowrap.FieldClientIP, clientIP).
82 Err(err).
83 Msg("access log write failed")
84 }
85 })
86 }
87}