// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package web import ( "context" "crypto/rand" "log/slog" "net/http" "time" ) type loggerKey struct{} // Logger returns the logger the request carries, or the process default // logger outside a request. A handler uses it instead of the package-level // slog functions, so every line it writes carries the request id and the // route it happened on. func Logger(ctx context.Context) *slog.Logger { if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok { return logger } return slog.Default() } // WithLogger returns a context carrying logger, for a layer that has // learned something worth adding to every line of the request. func WithLogger(ctx context.Context, logger *slog.Logger) context.Context { return context.WithValue(ctx, loggerKey{}, logger) } // RequestLogger gives every request an id, puts a logger carrying it into // the context, answers with the same id in X-Request-Id, and writes one // line when the request finishes. func RequestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := requestID() logger := slog.Default().With("request", id) w.Header().Set("X-Request-Id", id) ctx := WithLogger(r.Context(), logger) recorder := &statusWriter{ResponseWriter: w, status: http.StatusOK} started := time.Now() next.ServeHTTP(recorder, r.WithContext(ctx)) logger.LogAttrs(ctx, slog.LevelInfo, "request", slog.String("method", r.Method), slog.String("path", r.URL.Path), slog.Int("status", recorder.status), slog.Duration("took", time.Since(started)), ) }) } // statusWriter records the status code so the access line can carry it. type statusWriter struct { http.ResponseWriter status int written bool } func (s *statusWriter) WriteHeader(code int) { if !s.written { s.status = code s.written = true } s.ResponseWriter.WriteHeader(code) } func (s *statusWriter) Write(body []byte) (int, error) { s.written = true return s.ResponseWriter.Write(body) } // Unwrap lets http.ResponseController reach the writer underneath, so a // handler that streams or flushes still can. func (s *statusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter } func requestID() string { // Short enough to read in a log line, wide enough that two live // requests collide with negligible probability. return rand.Text()[:16] }