Files
patrick dbf8fe9e54 FDN-08: logging, metriken & fehler-tracking
Strukturiertes Logging (log/slog) mit Korrelations-ID ueber HTTP-Anfragen
(Middleware, X-Request-Id) und Hintergrund-Jobs (Job-ID als ID im Kontext,
jobqueue.Worker und dms-worker-Retention-Pfad) hinweg. Logger ist
Konstruktor-Pflichtfeld (internal/errortracking.New panic'ed ohne Logger),
um das im Ticket beschriebene stille Fehlen von Debug-Logs auszuschliessen.

Metriken (internal/metrics) im Prometheus-Textformat unter /metrics:
HTTP-Anfragen nach Status, Fehlerrate, durchschnittliche Dauer,
Job-Queue-Laenge (neue jobqueue.Queue.QueueLength). Kein neuer
Prometheus-Client als Abhaengigkeit, da vor dieser Kachel keine
Metrik-Bibliothek im Einsatz war.

Zentrales Fehler-Tracking (internal/errortracking): In-Prozess-Ringpuffer
+ strukturiertes Error-Log, Schwellwert-Alarm (N Fehler in Zeitfenster)
mit Acknowledge. In cmd/app per Recover-Middleware, in cmd/worker im
Retention-Register-Requeue-Pfad verdrahtet.
2026-09-18 13:30:46 +02:00

155 lines
4.6 KiB
Go

// Package errortracking implementiert FDN-08 Akzeptanzkriterium 3:
// zentrale Erfassung unbehandelter Fehler inklusive Schwellwert-Alarmierung.
// Kein SaaS-Dienst (Sentry o.ae.) angebunden — im dms/-Baum existierte vor
// dieser Kachel keine solche Integration, daher die kleinste Loesung:
// In-Prozess-Ringpuffer + strukturiertes Error-Log als abfragbare Quelle.
package errortracking
import (
"context"
"log/slog"
"sync"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/logging"
)
// Event ist ein erfasster unbehandelter Fehler.
type Event struct {
Time time.Time
RequestID string
Component string
Message string
}
// Alert markiert, dass innerhalb des Fensters AlertWindow mindestens
// AlertThreshold Fehler aufgetreten sind.
type Alert struct {
TriggeredAt time.Time
Count int
Acknowledged bool
}
// Tracker erfasst Fehler zentral. Logger ist Konstruktor-Pflichtfeld (siehe
// internal/logging) — der bekannte Fehler aus dem Vorgaengerprojekt
// (Logger nie korrekt verdrahtet) wird so ausgeschlossen: ohne Logger
// kompiliert kein Aufrufer von New.
type Tracker struct {
logger *slog.Logger
mu sync.Mutex
events []Event
maxEvents int
alertThreshold int
alertWindow time.Duration
activeAlert *Alert
}
// New erzeugt einen Tracker. logger ist Pflicht (siehe Paket-Kommentar).
// alertThreshold/alertWindow legen fest, ab wann ein Alarm ausgeloest wird
// (z. B. 5 Fehler innerhalb 1 Minute).
func New(logger *slog.Logger, alertThreshold int, alertWindow time.Duration) *Tracker {
if logger == nil {
panic("errortracking: logger ist Pflichtfeld (siehe internal/logging.New)")
}
return &Tracker{
logger: logger,
maxEvents: 1000,
alertThreshold: alertThreshold,
alertWindow: alertWindow,
}
}
// Capture erfasst einen unbehandelten Fehler zentral (Akzeptanzkriterium 3)
// und loggt ihn strukturiert mit Korrelations-ID aus dem Kontext, sofern
// vorhanden. Nur Fehlertext/Komponente werden geloggt — niemals Request-
// Header oder -Body, damit keine Zugangsdaten in Logs gelangen (Pruefung 2).
func (t *Tracker) Capture(ctx context.Context, component string, err error) {
requestID := logging.RequestIDFromContext(ctx)
ev := Event{Time: time.Now(), RequestID: requestID, Component: component, Message: err.Error()}
t.logger.LogAttrs(ctx, slog.LevelError, "unhandled_error",
slog.String("request_id", requestID),
slog.String("component", component),
slog.String("error", err.Error()),
)
t.mu.Lock()
defer t.mu.Unlock()
t.events = append(t.events, ev)
if len(t.events) > t.maxEvents {
t.events = t.events[len(t.events)-t.maxEvents:]
}
t.checkAlertLocked()
}
// checkAlertLocked prueft, ob innerhalb alertWindow alertThreshold Fehler
// aufgetreten sind, und setzt/aktualisiert activeAlert entsprechend.
// Aufrufer haelt bereits t.mu.
func (t *Tracker) checkAlertLocked() {
if t.alertThreshold <= 0 {
return
}
cutoff := time.Now().Add(-t.alertWindow)
count := 0
for i := len(t.events) - 1; i >= 0; i-- {
if t.events[i].Time.Before(cutoff) {
break
}
count++
}
if count >= t.alertThreshold {
if t.activeAlert == nil || t.activeAlert.Acknowledged {
t.activeAlert = &Alert{TriggeredAt: time.Now(), Count: count}
t.logger.Error("alert_threshold_triggered", "count", count, "window", t.alertWindow.String())
} else {
t.activeAlert.Count = count
}
}
}
// RecentErrors liefert alle erfassten Fehler der letzten `since` Dauer
// (fuer Pruefung 1: sichtbar innerhalb einer Minute).
func (t *Tracker) RecentErrors(since time.Duration) []Event {
t.mu.Lock()
defer t.mu.Unlock()
cutoff := time.Now().Add(-since)
out := make([]Event, 0, len(t.events))
for _, e := range t.events {
if !e.Time.Before(cutoff) {
out = append(out, e)
}
}
return out
}
// ActiveAlert liefert den aktuell aktiven Alarm, falls vorhanden.
func (t *Tracker) ActiveAlert() (Alert, bool) {
t.mu.Lock()
defer t.mu.Unlock()
if t.activeAlert == nil {
return Alert{}, false
}
return *t.activeAlert, true
}
// Acknowledge quittiert den aktiven Alarm (Pruefung 3).
func (t *Tracker) Acknowledge() bool {
t.mu.Lock()
defer t.mu.Unlock()
if t.activeAlert == nil || t.activeAlert.Acknowledged {
return false
}
t.activeAlert.Acknowledged = true
t.logger.Info("alert_acknowledged", "count", t.activeAlert.Count)
return true
}
// Reporter ist die minimale Schnittstelle, die QA-02 (Pruefgate Fundament,
// nicht Teil dieser Kachel) spaeter nutzen koennte, um Fehler waehrend
// automatisierter Pruefungen zentral zu erfassen, ohne den vollen Tracker
// zu kennen.
type Reporter interface {
Capture(ctx context.Context, component string, err error)
}