
Go Middleware
- 74 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
go-middleware is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- go-middleware
- AI & Agent Building
- AI-coding skill
Go Middleware by the numbers
- 74 all-time installs (skills.sh)
- Ranked #5,508 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill go-middlewareAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 74 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Go HTTP Middleware
Quick Reference
| Topic | Reference |
|---|---|
| Context keys, request IDs, user metadata | references/context-propagation.md |
| slog setup, logging middleware, child loggers | references/structured-logging.md |
| AppHandler pattern, domain errors, recovery | references/error-handling-middleware.md |
Middleware Signature
All middleware follows the standard func(http.Handler) http.Handler pattern. This is the composable building block for cross-cutting concerns in Go HTTP servers.
// Standard middleware signature
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.New().String()
}
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Type-safe context keys
type contextKey string
const requestIDKey contextKey = "request_id"
func RequestIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string)
return id
}Key points:
- Accept
http.Handler, returnhttp.Handler-- always - Call
next.ServeHTTP(w, r)to pass control to the next handler - Work before the call (pre-processing) or after (post-processing) or both
- Use
r.WithContext(ctx)to propagate new context values downstream
Context Propagation
Use context.WithValue for request-scoped data that crosses API boundaries (request IDs, authenticated users, tenant IDs). Always use typed keys to avoid collisions.
type contextKey string
const (
requestIDKey contextKey = "request_id"
userKey contextKey = "user"
)Provide typed helper functions for extraction:
func RequestIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string)
return id
}See references/context-propagation.md for user metadata patterns, downstream propagation, and timeouts.
Structured Logging
Use slog (standard library, Go 1.21+) for structured logging in middleware. Wrap http.ResponseWriter to capture the status code.
func Logger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(wrapped, r)
logger.Info("request completed",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.status,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFromContext(r.Context()),
)
})
}
}See references/structured-logging.md for JSON/text handler setup, log levels, and child loggers.
Centralized Error Handling
Define a custom handler type that returns error so handlers don't need to write error responses themselves:
type AppHandler func(w http.ResponseWriter, r *http.Request) error
func (fn AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
handleError(w, r, err)
}
}Map domain errors to HTTP status codes in a single handleError function. Never leak internal error details to clients.
See references/error-handling-middleware.md for the full pattern with AppError, errors.As, and JSON responses.
Recovery Middleware
Catch panics to prevent a single bad request from crashing the server:
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic recovered",
"panic", rec,
"stack", string(debug.Stack()),
"request_id", RequestIDFromContext(r.Context()),
)
writeJSON(w, 500, map[string]string{"error": "internal server error"})
}
}()
next.ServeHTTP(w, r)
})
}Recovery must be the outermost middleware so it catches panics from all inner middleware and handlers. See references/error-handling-middleware.md for details.
Middleware Chain Ordering
Apply middleware outermost-first. The first middleware in the chain wraps all others.
// Nested style (outermost first)
handler := Recovery(
RequestID(
Logger(
Auth(
router,
),
),
),
)
// Or with a chain helper
func Chain(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
h = middleware[i](h)
}
return h
}
handler := Chain(router, Recovery, RequestID, Logger(slog.Default()), Auth)Recommended Order
1. Recovery -- outermost; catches panics from all inner middleware 2. RequestID -- assign early so all subsequent middleware can reference it 3. Logger -- logs the completed request with ID and status 4. Auth -- after logging so failed auth attempts are recorded 5. Application-specific middleware -- rate limiting, CORS, etc.
Gates (check before merge or review)
Use these sequenced checks for objective pass/fail; do not replace them with “I verified mentally.”
1. Recovery position
- Locate where the server builds the middleware chain (e.g.
main, routerUse, or aChainhelper). - Pass: Recovery wraps all other middleware and the final handler per Middleware Chain Ordering (outermost in nested style, or correct
Chainargument order for your helper). Cite file path and the full chain snippet.
2. Status-aware middleware uses a wrapped `ResponseWriter`
- If middleware logs or records HTTP status after the handler runs, it must pass a wrapper into
next.ServeHTTP, not the original writer alone. - Pass: snippet shows
next.ServeHTTP(wrapped, r)(or equivalent) when status is observed afternextreturns.
3. Every forward path calls `next`
- Scan each middleware’s control flow.
- Pass: no branch drops the request without calling
next.ServeHTTPunless that branch intentionally sends a response (e.g. auth failure); those short-circuits are obvious in code review.
Anti-patterns
Using string or int context keys
// BAD: collisions with other packages
ctx = context.WithValue(ctx, "user", user)
// GOOD: unexported typed key
type contextKey string
const userKey contextKey = "user"
ctx = context.WithValue(ctx, userKey, user)Writing response before calling next
// BAD: writes response then continues chain
func Bad(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) // too early!
next.ServeHTTP(w, r)
})
}Forgetting to call next.ServeHTTP
// BAD: swallows the request
func Bad(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println("got request")
// forgot next.ServeHTTP(w, r)
})
}Storing large objects in context
Context values should be small, request-scoped metadata (IDs, tokens, user structs). Never store database connections, file handles, or large payloads.
Using context.WithValue for function parameters
If a function needs a value to do its job, pass it as an explicit parameter. Context is for cross-cutting metadata that passes through APIs, not for avoiding function signatures.
Recovery middleware in the wrong position
If recovery is not the outermost middleware, panics in outer middleware will crash the server. Always apply recovery first.
Context Propagation in Go Middleware
Type-Safe Context Keys
Never use plain string or int as context keys. Define an unexported type so keys from different packages cannot collide.
// Define in your middleware package
type contextKey string
const (
requestIDKey contextKey = "request_id"
userKey contextKey = "user"
tenantIDKey contextKey = "tenant_id"
)Why this matters:
context.WithValueuses interface equality for key comparison- Two packages using
"user"as a string key would overwrite each other - An unexported
contextKeytype is unique to your package
Request ID Propagation
Assign a request ID early in the middleware chain. Propagate it through context so every layer can include it in logs, error reports, and outgoing requests.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.New().String()
}
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func RequestIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string)
return id
}Usage in downstream code:
func handleOrder(w http.ResponseWriter, r *http.Request) {
reqID := RequestIDFromContext(r.Context())
slog.Info("processing order", "request_id", reqID)
// Pass to outgoing HTTP calls
outReq, _ := http.NewRequestWithContext(r.Context(), "GET", url, nil)
outReq.Header.Set("X-Request-ID", reqID)
}User Metadata
Store authenticated user information in context after validation in auth middleware.
type User struct {
ID string
Email string
Roles []string
}
const userKey contextKey = "user"
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
user, err := validateToken(token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func UserFromContext(ctx context.Context) (*User, bool) {
u, ok := ctx.Value(userKey).(*User)
return u, ok
}Multi-Tenant Context
For multi-tenant applications, propagate the tenant ID alongside the user:
const tenantIDKey contextKey = "tenant_id"
func TenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID := extractTenantID(user)
ctx := context.WithValue(r.Context(), tenantIDKey, tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func TenantIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(tenantIDKey).(string)
return id
}Typed Helper Functions
Always provide exported helper functions for extracting context values. This encapsulates the key and type assertion in one place.
// Good: callers use typed helpers
user, ok := UserFromContext(ctx)
reqID := RequestIDFromContext(ctx)
tenantID := TenantIDFromContext(ctx)
// Bad: callers reach into context directly
user := ctx.Value("user").(*User) // unsafe, untyped keyAlways check the ok return from type assertions:
func UserFromContext(ctx context.Context) (*User, bool) {
u, ok := ctx.Value(userKey).(*User)
return u, ok
}
// In handlers
user, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}Passing Context to Downstream Services
Database Queries
Pass r.Context() to database calls so they respect request cancellation:
func getUser(ctx context.Context, db *sql.DB, id string) (*User, error) {
row := db.QueryRowContext(ctx, "SELECT id, email FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.ID, &u.Email); err != nil {
return nil, fmt.Errorf("querying user %s: %w", id, err)
}
return &u, nil
}Outgoing HTTP Requests
Use http.NewRequestWithContext to propagate cancellation and pass along tracing headers:
func callDownstream(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Request-ID", RequestIDFromContext(ctx))
return http.DefaultClient.Do(req)
}Context Timeout and Cancellation
For long operations, derive a context with a timeout to prevent requests from hanging:
func slowHandler(w http.ResponseWriter, r *http.Request) {
// Give the operation 5 seconds max
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
result, err := longRunningQuery(ctx)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(result)
}Timeout Middleware
Apply a blanket timeout to all requests:
func Timeout(duration time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), duration)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}Note: this cancels the context but does not stop the handler goroutine. Handlers must check ctx.Done() or use context-aware I/O to actually stop work.
Anti-patterns
Using context.WithValue for function parameters
// BAD: hiding dependencies in context
ctx = context.WithValue(ctx, "db", db)
// ...later...
db := ctx.Value("db").(*sql.DB)
// GOOD: explicit parameter
func handleOrder(ctx context.Context, db *sql.DB, orderID string) error {
// ...
}Context is for request-scoped metadata that crosses API boundaries, not for dependency injection.
Storing large objects in context
// BAD: large payload in context
ctx = context.WithValue(ctx, "body", largeRequestBody)
// GOOD: pass as parameter or store a reference/ID
ctx = context.WithValue(ctx, requestIDKey, reqID)Not checking ok from type assertion
// BAD: panics if value is nil or wrong type
user := ctx.Value(userKey).(*User)
// GOOD: always check
user, ok := ctx.Value(userKey).(*User)
if !ok {
return ErrUnauthorized
}Centralized Error Handling and Recovery Middleware
The AppHandler Pattern
Standard http.HandlerFunc has no return value, forcing each handler to write its own error responses. The AppHandler pattern lets handlers return errors, with a single centralized function mapping errors to HTTP responses.
Custom Handler Type
// AppHandler is an http.HandlerFunc that returns an error
type AppHandler func(w http.ResponseWriter, r *http.Request) error
// ServeHTTP implements http.Handler, calling the function and handling errors
func (fn AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
handleError(w, r, err)
}
}Usage with a router:
mux := http.NewServeMux()
mux.Handle("GET /users/{id}", AppHandler(getUser))
mux.Handle("POST /users", AppHandler(createUser))
func getUser(w http.ResponseWriter, r *http.Request) error {
id := r.PathValue("id")
user, err := db.FindUser(r.Context(), id)
if err != nil {
return fmt.Errorf("finding user %s: %w", id, err)
}
if user == nil {
return ErrNotFound
}
return writeJSON(w, http.StatusOK, user)
}Handlers focus on the happy path and return errors. The centralized handleError function takes care of logging and response formatting.
Domain Errors
Define typed errors that map to HTTP status codes:
type AppError struct {
Code int `json:"-"`
Message string `json:"error"`
Detail string `json:"detail,omitempty"`
}
func (e *AppError) Error() string { return e.Message }
var (
ErrNotFound = &AppError{Code: 404, Message: "resource not found"}
ErrUnauthorized = &AppError{Code: 401, Message: "unauthorized"}
ErrForbidden = &AppError{Code: 403, Message: "forbidden"}
ErrBadRequest = &AppError{Code: 400, Message: "bad request"}
ErrConflict = &AppError{Code: 409, Message: "conflict"}
)Creating Errors with Detail
func NewBadRequest(detail string) *AppError {
return &AppError{
Code: 400,
Message: "bad request",
Detail: detail,
}
}
// In a handler
func createUser(w http.ResponseWriter, r *http.Request) error {
var input CreateUserInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
return NewBadRequest("invalid JSON body")
}
if input.Email == "" {
return NewBadRequest("email is required")
}
// ...
}Wrapping Domain Errors
Use fmt.Errorf with %w to add context while preserving the original error for errors.As:
func getOrder(w http.ResponseWriter, r *http.Request) error {
id := r.PathValue("id")
order, err := db.FindOrder(r.Context(), id)
if err != nil {
return fmt.Errorf("finding order %s: %w", id, err)
}
if order == nil {
return fmt.Errorf("order %s: %w", id, ErrNotFound)
}
return writeJSON(w, http.StatusOK, order)
}Centralized Error Handler
The handleError function maps errors to HTTP responses. Known AppError types get their specific status code; everything else is a 500.
func handleError(w http.ResponseWriter, r *http.Request, err error) {
logger := slog.Default()
reqID := RequestIDFromContext(r.Context())
var appErr *AppError
if errors.As(err, &appErr) {
logger.Warn("handled error",
"error", appErr.Message,
"detail", appErr.Detail,
"status", appErr.Code,
"request_id", reqID,
"method", r.Method,
"path", r.URL.Path,
)
writeJSON(w, appErr.Code, appErr)
return
}
// Unexpected error -- do not leak internals
logger.Error("unhandled error",
"error", err.Error(),
"request_id", reqID,
"method", r.Method,
"path", r.URL.Path,
)
writeJSON(w, 500, map[string]string{"error": "internal server error"})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}Key principles:
- Known errors (AppError) are logged at Warn level with their detail
- Unknown errors are logged at Error level with the full message
- Clients never see internal error messages for unknown errors
- Every error log includes the request ID for correlation
JSON Error Response Format
All error responses follow a consistent structure:
{
"error": "resource not found",
"detail": "order abc-123"
}The detail field is optional and omitted when empty. This consistency makes it easy for API clients to parse errors.
Recovery Middleware
Panics in Go HTTP handlers crash the server (when not using net/http's default recovery, which only logs and closes the connection). Recovery middleware catches panics and returns a proper error response.
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic recovered",
"panic", rec,
"stack", string(debug.Stack()),
"request_id", RequestIDFromContext(r.Context()),
"method", r.Method,
"path", r.URL.Path,
)
writeJSON(w, 500, map[string]string{"error": "internal server error"})
}
}()
next.ServeHTTP(w, r)
})
}Why Recovery Must Be Outermost
Recovery catches panics by wrapping the call to next.ServeHTTP in a deferred recover(). If any middleware outside of recovery panics, it won't be caught:
// CORRECT: recovery wraps everything
handler := Recovery(RequestID(Logger(router)))
// WRONG: panics in RequestID or Logger are not caught
handler := RequestID(Logger(Recovery(router)))Stack Trace Logging
runtime/debug.Stack() returns the goroutine's stack trace at the point of the panic. Log this at Error level for debugging, but never include it in the HTTP response.
import "runtime/debug"
slog.Error("panic recovered",
"panic", rec,
"stack", string(debug.Stack()),
)Never Expose Panic Details to Clients
The panic value (rec) often contains internal information -- file paths, memory addresses, or internal state. Always return a generic error message:
// GOOD: generic message to client
writeJSON(w, 500, map[string]string{"error": "internal server error"})
// BAD: leaking panic info
writeJSON(w, 500, map[string]string{"error": fmt.Sprintf("%v", rec)})Combining AppHandler with Recovery
The AppHandler pattern handles returned errors; recovery handles panics. Together they cover all failure modes:
// AppHandler catches returned errors
func getUser(w http.ResponseWriter, r *http.Request) error {
user, err := db.FindUser(r.Context(), r.PathValue("id"))
if err != nil {
return fmt.Errorf("finding user: %w", err) // caught by AppHandler
}
return writeJSON(w, 200, user)
}
// Recovery catches panics (e.g., nil pointer dereference)
// Applied as outermost middleware
handler := Recovery(
RequestID(
Logger(router),
),
)Handlers should return errors, not panic. Recovery is a safety net for unexpected situations (nil pointer dereference, index out of range, third-party library panics).
Testing Error Handling
func TestHandleError_AppError(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/test", nil)
handleError(w, r, ErrNotFound)
if w.Code != 404 {
t.Errorf("expected 404, got %d", w.Code)
}
var body map[string]string
json.NewDecoder(w.Body).Decode(&body)
if body["error"] != "resource not found" {
t.Errorf("expected 'resource not found', got %q", body["error"])
}
}
func TestHandleError_UnknownError(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/test", nil)
handleError(w, r, fmt.Errorf("database connection refused"))
if w.Code != 500 {
t.Errorf("expected 500, got %d", w.Code)
}
var body map[string]string
json.NewDecoder(w.Body).Decode(&body)
if body["error"] != "internal server error" {
t.Errorf("expected 'internal server error', got %q", body["error"])
}
}
func TestRecoveryMiddleware(t *testing.T) {
panicking := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("something went wrong")
})
handler := Recovery(panicking)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/test", nil)
handler.ServeHTTP(w, r)
if w.Code != 500 {
t.Errorf("expected 500, got %d", w.Code)
}
}Structured Logging with slog
log/slog is the standard library structured logging package (Go 1.21+). It replaces the older log package for production services.
Setting Up slog
Production: JSON Handler
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)Output:
{"time":"2024-01-15T10:30:00Z","level":"INFO","msg":"request completed","method":"GET","path":"/api/users","status":200,"duration_ms":42}Development: Text Handler
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
AddSource: true,
}))
slog.SetDefault(logger)Output:
time=2024-01-15T10:30:00Z level=INFO source=main.go:42 msg="request completed" method=GET path=/api/users status=200 duration_ms=42Choosing Based on Environment
func setupLogger(env string) *slog.Logger {
var handler slog.Handler
switch env {
case "production":
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
default:
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
AddSource: true,
})
}
return slog.New(handler)
}Log Levels
slog provides four levels:
| Level | Value | Use for |
|---|---|---|
slog.LevelDebug | -4 | Verbose diagnostic info, disabled in production |
slog.LevelInfo | 0 | Normal operations (request completed, job started) |
slog.LevelWarn | 4 | Handled errors, degraded operation, approaching limits |
slog.LevelError | 8 | Unhandled errors, panics, failed critical operations |
slog.Debug("cache miss", "key", cacheKey)
slog.Info("request completed", "method", r.Method, "status", 200)
slog.Warn("rate limit approaching", "current", count, "limit", max)
slog.Error("database connection failed", "error", err)Logging Middleware
Capture HTTP method, path, response status, and request duration for every request.
func Logger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap ResponseWriter to capture status code
wrapped := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(wrapped, r)
logger.Info("request completed",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.status,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFromContext(r.Context()),
)
})
}
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}Logging Errors vs Success at Different Levels
func Logger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(wrapped, r)
attrs := []any{
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.status,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFromContext(r.Context()),
}
switch {
case wrapped.status >= 500:
logger.Error("server error", attrs...)
case wrapped.status >= 400:
logger.Warn("client error", attrs...)
default:
logger.Info("request completed", attrs...)
}
})
}
}Adding Request ID to All Log Entries
Use slog.With to create a child logger that includes the request ID in every log call within that request's scope:
func LoggerWithContext(baseLogger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := RequestIDFromContext(r.Context())
// Create a child logger with request_id baked in
logger := baseLogger.With("request_id", reqID)
// Store logger in context for use in handlers
ctx := context.WithValue(r.Context(), loggerKey, logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
type contextKey string
const loggerKey contextKey = "logger"
func LoggerFromContext(ctx context.Context) *slog.Logger {
if logger, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return logger
}
return slog.Default()
}Usage in handlers:
func handleOrder(w http.ResponseWriter, r *http.Request) {
logger := LoggerFromContext(r.Context())
logger.Info("processing order", "order_id", orderID)
// Output includes request_id automatically
}Child Loggers with Additional Context
Build up context as you go deeper into the call stack:
func processOrder(ctx context.Context, order *Order) error {
logger := LoggerFromContext(ctx).With(
"order_id", order.ID,
"customer_id", order.CustomerID,
)
logger.Info("validating order")
if err := validate(order); err != nil {
logger.Warn("validation failed", "error", err)
return fmt.Errorf("validating order: %w", err)
}
logger.Info("charging payment")
// ...
return nil
}Structured Logging Best Practices
Use consistent key names
// Good: consistent naming across the codebase
slog.Info("query executed", "duration_ms", dur, "row_count", count)
slog.Info("request completed", "duration_ms", dur, "status", code)
// Bad: inconsistent naming
slog.Info("query executed", "elapsed", dur, "rows", count)
slog.Info("request completed", "time_ms", dur, "statusCode", code)Use slog.Group for namespaced attributes
slog.Info("request",
slog.Group("http",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", status),
),
slog.Group("timing",
slog.Int64("duration_ms", dur),
),
)
// JSON: {"msg":"request","http":{"method":"GET","path":"/api","status":200},"timing":{"duration_ms":42}}Never log sensitive data
// BAD
slog.Info("user login", "password", password, "token", authToken)
// GOOD
slog.Info("user login", "user_id", userID)Log errors with the "error" key
// Consistent error key makes searching/filtering easy
slog.Error("database query failed", "error", err, "query", queryName)
slog.Warn("cache miss", "error", err, "key", cacheKey)StatusWriter Considerations
The basic statusWriter does not implement optional http.ResponseWriter interfaces. If you need http.Flusher, http.Hijacker, or http.Pusher support, implement them explicitly:
type statusWriter struct {
http.ResponseWriter
status int
wroteHeader bool
}
func (w *statusWriter) WriteHeader(code int) {
if !w.wroteHeader {
w.status = code
w.wroteHeader = true
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, fmt.Errorf("hijack not supported")
}