Files
archivdms/internal/api/observability.go
T
patrick 89de794356
CI / Backend (go vet, go test -cover) (push) Has been cancelled
CI / Frontend (ESLint, tsc, next build) (push) Has been cancelled
FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
- FDN-02: Rollback-fähige Down-Migrationen (024-026), archivdms seed dev CLI
- FDN-03: internal/objectstore Interface + lokaler WORM-Treiber, signierte Download-URLs
- FDN-07: go.mod/go.sum vervollständigt (fehlender go-ldap/v3-Eintrag), CI-Pipeline (.gitea/workflows/ci.yml, bereits in FDN-01 committet) damit lauffähig
- FDN-08: Request-ID-Middleware, /metrics-Endpoint, Panic-Recovery, Login/Logout/Me technisches Logging inkl. Access-Log je Anfrage
2026-08-11 22:27:52 +02:00

405 lines
11 KiB
Go

// FDN-08 — Logging, Metriken & Fehler-Tracking.
//
// Dieses File enthält die drei Bausteine, die bisher gefehlt haben:
//
// 1. requestIDMiddleware: erzeugt (oder übernimmt aus X-Request-ID) eine
// Korrelations-ID, hängt sie an den context.Context und gibt sie im
// Response-Header zurück. Über loggerFromCtx(ctx) bekommt jede Log-Zeile
// im Request-Lebenszyklus das Feld request_id, ohne dass jede Call-Site
// umgeschrieben werden muss.
// 2. metricsMiddleware: zählt Requests je (Methode, normalisierter Pfad,
// Status) und summiert die Latenz in Histogramm-Buckets. Ausgabe über
// GET /metrics im Prometheus-Textformat (kein prometheus/client_golang).
// 3. recoverMiddleware: zentrales Panic-Recovery. net/http's ServeMux hat
// keins; ohne das reißt ein Panic in einem Handler die Verbindung ab und
// der Fehler taucht nirgends auf.
//
// Bewusst KEIN Logging von Query-Strings, Headern oder Request-Bodies:
// dort stecken Tokens (Signed-URL-Signatur, Share-Token, Bearer-Keys) und
// potenziell Passwörter. Geloggt werden ausschließlich Methode, normalisierter
// Pfad (IDs/Tokens durch Platzhalter ersetzt), Status, Dauer und Client-IP.
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
)
// stackTrace liefert einen gekürzten Stacktrace für das Panic-Log.
func stackTrace() string {
const maxStack = 4096
buf := debug.Stack()
if len(buf) > maxStack {
buf = buf[:maxStack]
}
return string(buf)
}
const (
requestIDKey contextKey = "request_id"
loggerKey contextKey = "logger"
requestIDHeader = "X-Request-ID"
maxRequestIDLen = 64
)
// newRequestID erzeugt eine zufällige 16-stellige Hex-ID. Fällt bei einem
// (praktisch unmöglichen) Fehler der Entropiequelle auf einen Zeitstempel
// zurück — eine Anfrage darf daran nie scheitern.
func newRequestID() string {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
return hex.EncodeToString(buf)
}
// sanitizeRequestID übernimmt eine vom Client/Proxy gelieferte ID nur, wenn
// sie kurz und druckbar-alphanumerisch ist. Verhindert Log-Injection über
// Zeilenumbrüche im Header.
func sanitizeRequestID(v string) string {
v = strings.TrimSpace(v)
if v == "" || len(v) > maxRequestIDLen {
return ""
}
for _, r := range v {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case r == '-' || r == '_' || r == '.':
default:
return ""
}
}
return v
}
// requestIDFromCtx liefert die Korrelations-ID der laufenden Anfrage oder "".
func requestIDFromCtx(ctx context.Context) string {
if ctx == nil {
return ""
}
v, _ := ctx.Value(requestIDKey).(string)
return v
}
// loggerFromCtx liefert den Request-Logger (inkl. request_id-Feld). Außerhalb
// eines HTTP-Requests — oder wenn kein Logger hinterlegt wurde — kommt ein
// no-op-freier Fallback zurück, damit Aufrufer nie auf nil prüfen müssen.
func loggerFromCtx(ctx context.Context) *slog.Logger {
if ctx != nil {
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok && l != nil {
return l
}
}
return slog.Default()
}
// reqLog ist der Einstieg für Handler: nutzt den Request-Logger aus dem
// Context, fällt aber auf den Server-Logger zurück, wenn die Middleware nicht
// durchlaufen wurde (z.B. in Tests, die Handler direkt aufrufen).
func (s *Server) reqLog(ctx context.Context) *slog.Logger {
if ctx != nil {
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok && l != nil {
return l
}
}
if s.logger != nil {
return s.logger
}
return slog.Default()
}
// requestIDMiddleware setzt die Korrelations-ID und den Request-Logger.
func (s *Server) requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := sanitizeRequestID(r.Header.Get(requestIDHeader))
if rid == "" {
rid = newRequestID()
}
base := s.logger
if base == nil {
base = slog.Default()
}
reqLogger := base.With("request_id", rid)
ctx := context.WithValue(r.Context(), requestIDKey, rid)
ctx = context.WithValue(ctx, loggerKey, reqLogger)
w.Header().Set(requestIDHeader, rid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// statusRecorder merkt sich Statuscode und geschriebene Bytes, damit die
// Metrik-Middleware nach dem Handler auswerten kann.
type statusRecorder struct {
http.ResponseWriter
status int
written int64
wrote bool
}
func (rec *statusRecorder) WriteHeader(code int) {
if !rec.wrote {
rec.status = code
rec.wrote = true
}
rec.ResponseWriter.WriteHeader(code)
}
func (rec *statusRecorder) Write(b []byte) (int, error) {
if !rec.wrote {
rec.status = http.StatusOK
rec.wrote = true
}
n, err := rec.ResponseWriter.Write(b)
rec.written += int64(n)
return n, err
}
// Flush reicht http.Flusher durch (Downloads/Streaming-Endpunkte).
func (rec *statusRecorder) Flush() {
if f, ok := rec.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// recoverMiddleware fängt Panics zentral ab: Log mit Korrelations-ID +
// Stacktrace, sauberer 500 an den Client. Ohne das stürzt zwar nicht der
// Prozess (net/http fängt pro Verbindung ab), der Fehler bleibt aber
// unsichtbar und der Client bekommt einen abgebrochenen Stream.
func (s *Server) recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
if rec == http.ErrAbortHandler {
panic(rec)
}
s.metrics.incPanic()
loggerFromCtx(r.Context()).Error("panic in http handler",
"method", r.Method,
"path", normalizeRoute(r.URL.Path),
"remote_ip", s.remoteIP(r),
"panic", rec,
"stack", stackTrace(),
)
if sr, ok := w.(*statusRecorder); ok && sr.wrote {
return // Header sind raus, mehr geht nicht
}
writeError(w, http.StatusInternalServerError, "interner Serverfehler")
}()
next.ServeHTTP(w, r)
})
}
// metricsMiddleware misst Dauer und Status jeder Anfrage.
func (s *Server) metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
s.metrics.incInFlight()
defer func() {
s.metrics.decInFlight()
d := time.Since(start)
route := normalizeRoute(r.URL.Path)
s.metrics.observe(r.Method, route, rec.status, d)
// Zugriffs-Log: JEDE Anfrage erzeugt genau eine Zeile mit
// request_id (AK1). Level nach Status gestaffelt, damit im
// Normalbetrieb (Info) nur Auffälligkeiten sichtbar sind:
// 5xx=Error, 4xx=Warn, Rest=Debug.
lvl := slog.LevelDebug
msg := "request completed"
switch {
case rec.status >= 500:
lvl, msg = slog.LevelError, "request failed"
case rec.status >= 400:
lvl, msg = slog.LevelWarn, "request rejected"
}
loggerFromCtx(r.Context()).Log(r.Context(), lvl, msg,
"method", r.Method, "route", route,
"status", rec.status, "duration_ms", d.Milliseconds(),
"bytes", rec.written,
"remote_ip", s.remoteIP(r))
}()
next.ServeHTTP(rec, r)
})
}
// --- Pfad-Normalisierung ---
// tokenSegments sind Pfadabschnitte, deren FOLGENDES Segment ein Geheimnis
// ist (Share-Token). Die dürfen niemals in Logs oder Metrik-Labels landen.
var tokenSegments = map[string]bool{"share": true}
// normalizeRoute ersetzt variable Pfadsegmente durch Platzhalter. Das hält
// die Label-Kardinalität der Metriken klein UND verhindert, dass IDs oder
// Share-Tokens in Logs/Metriken auftauchen. Query-Strings werden nie
// betrachtet (dort stehen Signaturen und API-Keys).
func normalizeRoute(path string) string {
if path == "" {
return "/"
}
parts := strings.Split(path, "/")
prevSecret := false
for i, p := range parts {
if p == "" {
continue
}
if prevSecret {
parts[i] = "{token}"
prevSecret = false
continue
}
prevSecret = tokenSegments[p]
if isVariableSegment(p) {
parts[i] = "{id}"
}
}
out := strings.Join(parts, "/")
if len(out) > 120 {
return "/other"
}
return out
}
// isVariableSegment erkennt IDs, Hashes und sonstige nicht-statische
// Pfadbestandteile. Konservativ: im Zweifel maskieren.
func isVariableSegment(seg string) bool {
if _, err := strconv.ParseInt(seg, 10, 64); err == nil {
return true
}
if len(seg) > 40 {
return true
}
hasDigit := false
for _, r := range seg {
switch {
case r >= '0' && r <= '9':
hasDigit = true
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '-', r == '_', r == '.':
default:
// Alles Ungewöhnliche (Sonderzeichen, Umlaute, %-Encoding) ist mit
// Sicherheit kein statisches Routensegment.
return true
}
}
// Statische Routensegmente sind reine Wörter ("delete-requests",
// "classification-templates"). Längere Mischungen aus Buchstaben und
// Ziffern sind Hashes/Tokens/Dateinamen -> maskieren.
return hasDigit && len(seg) >= 8
}
// --- Metrik-Registry ---
// latencyBuckets sind die oberen Grenzen (Sekunden) des Latenz-Histogramms.
var latencyBuckets = []float64{0.005, 0.025, 0.1, 0.5, 1, 2.5, 5, 10, 30}
type routeKey struct {
method string
route string
status int
}
type routeStat struct {
count uint64
sumSeconds float64
bucketCount []uint64 // len(latencyBuckets), kumulativ erst beim Rendern
}
// metricsRegistry ist eine minimale, prozesslokale Metrik-Sammlung. Keine
// globale Variable: hängt als Feld am Server (Dependency Injection).
type metricsRegistry struct {
mu sync.Mutex
routes map[routeKey]*routeStat
inFlight int64
panics uint64
started time.Time
}
func newMetricsRegistry() *metricsRegistry {
return &metricsRegistry{
routes: make(map[routeKey]*routeStat),
started: time.Now(),
}
}
func (m *metricsRegistry) observe(method, route string, status int, d time.Duration) {
if m == nil {
return
}
k := routeKey{method: method, route: route, status: status}
secs := d.Seconds()
m.mu.Lock()
defer m.mu.Unlock()
// Kardinalitätsbremse: unbekannte Pfade nicht unbegrenzt sammeln.
st := m.routes[k]
if st == nil {
if len(m.routes) >= 500 {
k = routeKey{method: method, route: "/other", status: status}
st = m.routes[k]
}
if st == nil {
st = &routeStat{bucketCount: make([]uint64, len(latencyBuckets))}
m.routes[k] = st
}
}
st.count++
st.sumSeconds += secs
for i, ub := range latencyBuckets {
if secs <= ub {
st.bucketCount[i]++
}
}
}
func (m *metricsRegistry) incInFlight() {
if m == nil {
return
}
m.mu.Lock()
m.inFlight++
m.mu.Unlock()
}
func (m *metricsRegistry) decInFlight() {
if m == nil {
return
}
m.mu.Lock()
m.inFlight--
m.mu.Unlock()
}
func (m *metricsRegistry) incPanic() {
if m == nil {
return
}
m.mu.Lock()
m.panics++
m.mu.Unlock()
}
// snapshot liefert eine Kopie der Zähler für das Rendern.
func (m *metricsRegistry) snapshot() (map[routeKey]routeStat, int64, uint64) {
m.mu.Lock()
defer m.mu.Unlock()
out := make(map[routeKey]routeStat, len(m.routes))
for k, v := range m.routes {
cp := routeStat{count: v.count, sumSeconds: v.sumSeconds, bucketCount: make([]uint64, len(v.bucketCount))}
copy(cp.bucketCount, v.bucketCount)
out[k] = cp
}
return out, m.inFlight, m.panics
}