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.
155 lines
4.6 KiB
Go
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)
|
|
}
|