Logging Middleware in Go With slog and Status Capture
Build an access log middleware in Go with log/slog: capture status and size, pick levels by status, skip health checks, redact secrets and measure cost.

Every web service needs an access log: one line per request saying what was asked, what happened and how long it took. It is the first thing opened during an incident, the raw material for latency and error rate dashboards, and the audit trail when someone asks what a client did. Writing one is easy to get almost right. The mistakes that matter are quiet: a status code that is always 200 because the writer was not wrapped, a route label that is the raw path with an ID in it, a log line that includes an authorization token, and a health check that produces 90% of your log volume.
Tested with Go 1.25.6, Apple M1 Max, macOS on arm64
Since Go 1.21 the standard library has log/slog, a structured logger with levels, attributes and pluggable handlers, which is the right foundation. This article builds the middleware on it, with the details that separate a useful access log from a noisy one, and measures what it costs. It builds on middleware and request IDs, pairs with recovery, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6.
What goes in the line
A good access record has a small, consistent set of fields:
| Field | Source | Why |
|---|---|---|
method | r.Method | which operation |
route | r.Pattern | the template (GET /orders/{id}), so grouping works |
path | r.URL.Path | the real path, for debugging a specific request |
status | wrapped writer | the outcome |
bytes | wrapped writer | response size |
duration | timer | latency |
request_id | context | correlation (request IDs) |
remote | r.RemoteAddr or the trusted proxy header | who |
Resist adding everything. Every field costs space and, if it holds personal data, creates a retention and privacy obligation. Add user_agent when you need to tell bots from browsers, the user ID when you have one, and the response error code for APIs. Leave out headers, bodies and full query strings by default.
r.Pattern (added in Go 1.23) is the matched route pattern, such as GET /users/{id}. It is set by the mux before the handler runs, and it is the field to aggregate on, since /users/1, /users/2 and /users/3 are one route. For requests that matched nothing, it is empty; log "unmatched" in that case.
The status-capturing writer and the middleware
http.ResponseWriter has no getter for the status, so the middleware wraps it (capturing the status explains the details, and wrapping without breaking Flush the pitfalls). Here is a compact version with Unwrap, followed by the middleware:
package main
import (
"log/slog"
"net/http"
"strings"
"time"
)
type recorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *recorder) WriteHeader(code int) {
if r.status == 0 {
r.status = code
}
r.ResponseWriter.WriteHeader(code)
}
func (r *recorder) Write(b []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK // a Write with no WriteHeader is an implicit 200
}
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
// AccessLog writes one structured record per request after the handler returns.
func AccessLog(log *slog.Logger, skip func(*http.Request) bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if skip != nil && skip(r) {
next.ServeHTTP(w, r)
return
}
start := time.Now()
rec := &recorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
if rec.status == 0 {
rec.status = http.StatusOK // the handler wrote nothing at all
}
route := r.Pattern
if route == "" {
route = "unmatched"
}
level := slog.LevelInfo
switch {
case rec.status >= 500:
level = slog.LevelError
case rec.status >= 400:
level = slog.LevelWarn
}
log.LogAttrs(r.Context(), level, "request",
slog.String("method", r.Method),
slog.String("route", route),
slog.String("path", r.URL.Path),
slog.Int("status", rec.status),
slog.Int("bytes", rec.bytes),
slog.Duration("duration", time.Since(start)),
slog.String("remote", clientIP(r)),
)
})
}
}
// clientIP is the TCP peer address. Behind a proxy, read the forwarded header instead, but only
// from a proxy you trust: a client can send any X-Forwarded-For it likes.
func clientIP(r *http.Request) string {
host := r.RemoteAddr
if i := strings.LastIndexByte(host, ':'); i >= 0 {
host = host[:i]
}
return host
}Walk through the choices. The record is written after next returns, because only then are the status and duration known. The level follows the outcome: a 500 is an error someone should see, a 404 or 401 is a warning that is normal at some rate, and everything else is informational, which lets operators filter on level without parsing the status. LogAttrs is used instead of log.Info("request", "method", r.Method, ...); both work, and the typed-attribute form avoids allocating the []any and boxing each value. The context is passed so a handler can add the request ID or trace ID to every record, which is how the correlation from the request ID article reaches the access log without this middleware knowing about it.
clientIP deserves its comment. r.RemoteAddr is the connection's peer, which behind a load balancer is the balancer's address. The real client is in X-Forwarded-For or Forwarded, set by the proxy; those headers can be forged by anyone who reaches your server directly, so read them only when the connection comes from a proxy you control, and take the right-most untrusted entry (forwarded headers). Logging a spoofed address undermines every investigation that relies on the log.
Skipping noise, and the levels in action
Health checks arrive every few seconds from each load balancer and orchestrator, and without a filter they dominate the log. Pass a skip function that recognizes them. This run logs a handful of requests of each outcome through the middleware, with the timestamp and duration stripped so the output is stable:
package main
import (
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
)
func main() {
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey || a.Key == "duration" {
return slog.Attr{}
}
return a
},
}))
skipHealth := func(r *http.Request) bool { return r.URL.Path == "/healthz" }
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "ok") })
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") == "0" {
http.Error(w, "no such user", http.StatusNotFound)
return
}
fmt.Fprintf(w, "user %s", r.PathValue("id"))
})
mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "database down", http.StatusInternalServerError)
})
mux.HandleFunc("POST /silent", func(w http.ResponseWriter, r *http.Request) {}) // writes nothing
h := AccessLog(log, skipHealth)(mux)
for _, c := range [][2]string{
{"GET", "/healthz"}, {"GET", "/users/42"}, {"GET", "/users/7"}, {"GET", "/users/0"},
{"GET", "/boom"}, {"POST", "/silent"}, {"GET", "/nothing"},
} {
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(c[0], c[1], nil))
}
}level=INFO msg=request method=GET route="GET /users/{id}" path=/users/42 status=200 bytes=7 remote=192.0.2.1
level=INFO msg=request method=GET route="GET /users/{id}" path=/users/7 status=200 bytes=6 remote=192.0.2.1
level=WARN msg=request method=GET route="GET /users/{id}" path=/users/0 status=404 bytes=13 remote=192.0.2.1
level=ERROR msg=request method=GET route="GET /boom" path=/boom status=500 bytes=14 remote=192.0.2.1
level=INFO msg=request method=POST route="POST /silent" path=/silent status=200 bytes=0 remote=192.0.2.1
level=WARN msg=request method=GET route=unmatched path=/nothing status=404 bytes=19 remote=192.0.2.1The health check produced no line. /users/42 and /users/7 both log route="GET /users/{id}" with different paths, so a dashboard grouping by route sees one series. The 404 is a warning, the 500 an error, the handler that wrote nothing logs a 200 with zero bytes, and the unknown path logs route=unmatched with status 404. That last case matters: a scanner hitting thousands of nonexistent paths would otherwise create thousands of distinct "routes" in your metrics.
Redaction: what must never be logged
The easiest way to leak a secret is to log "everything": the full URL, the headers, the body. URLs carry tokens (?token=..., signed URLs, password reset links), headers carry Authorization and Cookie, and bodies carry passwords and card numbers. The rule that works is an allowlist: log the fields named above and nothing else, and add new fields deliberately. If you must log a URL, log the path and, for the query, only the parameter names or an allowlisted subset. slog supports redaction at the handler level through ReplaceAttr, which can mask by key name as a safety net (any attribute named password, token or authorization becomes [redacted]), but do not rely on it as the primary control. Also remember log injection: a path containing a newline or ANSI escape codes is data, and the structured handlers (text with quoting, JSON) escape it, which is a reason to prefer them over fmt.Printf lines.
What it costs
The cost per request is a time read, a wrapper allocation and the log record. This benchmark measures the middleware against a bare handler with a JSON handler writing to io.Discard:
package main
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
)
var ok = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })
func BenchmarkBare(b *testing.B) {
req := httptest.NewRequest("GET", "/users/1", nil)
rec := httptest.NewRecorder()
for b.Loop() {
ok.ServeHTTP(rec, req)
}
}
func BenchmarkWithAccessLog(b *testing.B) {
log := slog.New(slog.NewJSONHandler(io.Discard, nil))
h := AccessLog(log, nil)(ok)
req := httptest.NewRequest("GET", "/users/1", nil)
rec := httptest.NewRecorder()
for b.Loop() {
h.ServeHTTP(rec, req)
}
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkBare-10 75599084 17.13 ns/op 9 B/op 1 allocs/op
BenchmarkWithAccessLog-10 1000000 1468 ns/op 118 B/op 3 allocs/op
PASS
ok example.test/lab 3.104sThe bare handler took about 17 ns, and the logged one 1,468 ns with 118 bytes and 3 allocations per request, so the access log adds about 1.45 microseconds, almost all of it formatting the record. A microsecond per request is nothing next to the request itself (parsing, a database call, rendering), so the access log is not a performance problem until you serve hundreds of thousands of requests per second per core. When it does matter, the levers are known: log asynchronously through a buffered channel to a writer goroutine (accepting that a crash loses the tail), sample successful requests (log all errors, one in ten successes), and keep the handler's work small.
Tests
Test the middleware with a handler that writes into a buffer and parses the result. The properties to pin: the status is captured whether the handler calls WriteHeader, writes directly or does nothing; 5xx and 4xx map to the right levels; health checks are skipped; and no header leaks into the line.
package main
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func logged(t *testing.T, h http.HandlerFunc, req *http.Request) map[string]any {
t.Helper()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, nil))
AccessLog(log, func(r *http.Request) bool { return r.URL.Path == "/healthz" })(h).ServeHTTP(httptest.NewRecorder(), req)
if buf.Len() == 0 {
return nil
}
var line map[string]any
if err := json.Unmarshal(buf.Bytes(), &line); err != nil {
t.Fatalf("log line is not JSON: %v: %s", err, buf.String())
}
return line
}
func TestStatusAndLevel(t *testing.T) {
cases := []struct {
name string
h http.HandlerFunc
status float64
level string
}{
{"ok", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hi")) }, 200, "INFO"},
{"not found", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }, 404, "WARN"},
{"server error", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(503) }, 503, "ERROR"},
{"writes nothing", func(w http.ResponseWriter, r *http.Request) {}, 200, "INFO"},
}
for _, c := range cases {
line := logged(t, c.h, httptest.NewRequest("GET", "/x", nil))
if line["status"] != c.status || line["level"] != c.level {
t.Errorf("%s: status %v level %v, want %v %v", c.name, line["status"], line["level"], c.status, c.level)
}
}
}
func TestHealthChecksAreNotLogged(t *testing.T) {
if line := logged(t, func(w http.ResponseWriter, r *http.Request) {}, httptest.NewRequest("GET", "/healthz", nil)); line != nil {
t.Fatalf("a health check was logged: %v", line)
}
}
func TestSecretsAreNotInTheLine(t *testing.T) {
req := httptest.NewRequest("GET", "/x?token=s3cret", nil)
req.Header.Set("Authorization", "Bearer hunter2")
req.AddCookie(&http.Cookie{Name: "session", Value: "abc123"})
var buf bytes.Buffer
AccessLog(slog.New(slog.NewJSONHandler(&buf, nil)), nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).ServeHTTP(httptest.NewRecorder(), req)
for _, secret := range []string{"s3cret", "hunter2", "abc123"} {
if strings.Contains(buf.String(), secret) {
t.Errorf("the log line leaked %q: %s", secret, buf.String())
}
}
}=== RUN TestStatusAndLevel
--- PASS: TestStatusAndLevel (0.00s)
=== RUN TestHealthChecksAreNotLogged
--- PASS: TestHealthChecksAreNotLogged (0.00s)
=== RUN TestSecretsAreNotInTheLine
--- PASS: TestSecretsAreNotInTheLine (0.00s)
PASS
ok example.test/lab 1.289sThe leak test sends a token in the query, a bearer header and a cookie, and asserts that none appears in the output. It is exactly the regression you want to catch when someone adds "just the full URL" to the line. The slog documentation covers handlers and ReplaceAttr, and the Go blog's introduction to structured logging explains the design and the LogAttrs form.


