textmachine/platform/internal/reqid/reqid.go

65 lines
2 KiB
Go

// Package reqid stamps each request with an id and carries it into every log record made with a
// context. It is its own package so that layers below HTTP (auth, ingest) can correlate their
// errors with an access-log line without importing the HTTP layer.
package reqid
import (
"context"
"crypto/rand"
"encoding/base32"
"log/slog"
"net/http"
)
// Header is where the id is echoed. It is generated here, never taken from the request: an id
// accepted from a caller lets them poison our logs and correlate other users' lines.
const Header = "X-Request-Id"
// Key is the log attribute name.
const Key = "request_id"
type ctxKey struct{}
// Middleware stamps the request and the response.
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := New()
w.Header().Set(Header, id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, id)))
})
}
// New mints an id.
func New() string {
var b [10]byte
rand.Read(b[:])
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])
}
// FromContext returns the id stamped by Middleware, or "".
func FromContext(ctx context.Context) string {
id, _ := ctx.Value(ctxKey{}).(string)
return id
}
// WithContext wraps a slog handler so that every *Context log call inside a request carries its id.
// Without it each call site has to remember the attribute, and the ones that forget are exactly the
// error paths nobody exercises.
func WithContext(h slog.Handler) slog.Handler { return &handler{h} }
type handler struct{ slog.Handler }
func (h *handler) Handle(ctx context.Context, r slog.Record) error {
if id := FromContext(ctx); id != "" {
r.AddAttrs(slog.String(Key, id))
}
return h.Handler.Handle(ctx, r)
}
func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &handler{h.Handler.WithAttrs(attrs)}
}
func (h *handler) WithGroup(name string) slog.Handler {
return &handler{h.Handler.WithGroup(name)}
}