// Package logging stellt das strukturierte Logging für alle Syncova-Dienste bereit. // // Jeder Eintrag trägt laut PROMPT.md §49 mindestens Zeitstempel, Level, Service, // Komponente, Operation und Correlation ID. Secrets dürfen niemals im Log landen // (PROMPT.md §12), weshalb dieses Paket bekannte Secret-Felder aktiv redigiert. package logging import ( "context" "io" "log/slog" "strings" ) // Feldnamen des strukturierten Logs. Sie sind zentral definiert, damit alle // Dienste dieselben Schlüssel verwenden und Logs auswertbar bleiben. const ( // FieldService benennt den Dienst (z. B. syncova-api). FieldService = "service" // FieldComponent benennt die Komponente innerhalb des Dienstes (z. B. repository). FieldComponent = "component" // FieldOperation benennt die konkrete Operation (z. B. backup.commit). FieldOperation = "operation" // FieldCorrelationID verknüpft alle Logeinträge einer Ende-zu-Ende-Operation (PROMPT.md §50). FieldCorrelationID = "correlation_id" // FieldJobID benennt den zugehörigen Backup-Job. FieldJobID = "job_id" // FieldTaskID benennt die zugehörige Teilaufgabe. FieldTaskID = "task_id" // FieldErrorCode trägt den klassifizierten Fehlercode (PROMPT.md §51). FieldErrorCode = "error_code" ) // redactedPlaceholder ersetzt den Wert eines als geheim erkannten Feldes. const redactedPlaceholder = "[REDACTED]" // secretFieldNames listet Feldnamen, deren Werte niemals im Klartext geloggt // werden dürfen. Der Abgleich erfolgt als Teilstring-Suche auf dem kleingeschriebenen // Schlüssel, damit auch Varianten wie "db_password" oder "agentToken" erfasst werden. var secretFieldNames = []string{ "password", "passwort", "secret", "token", "credential", "authorization", "api_key", "apikey", "private_key", "privatekey", "encryption_key", "passphrase", "dsn", "connection_string", "recovery_code", } // contextKey ist ein privater Typ für Context-Schlüssel dieses Pakets. // Ein eigener Typ verhindert Kollisionen mit Schlüsseln anderer Pakete. type contextKey string // correlationIDContextKey speichert die Correlation ID im Request-Context. const correlationIDContextKey contextKey = "syncova.correlation_id" // Options steuert den Aufbau des Loggers. type Options struct { // ServiceName erscheint in jedem Logeintrag als Dienstkennung. ServiceName string // Level ist der minimale Log-Level (debug, info, warn, error). Level string // Format ist "json" oder "text". Format string } // New erzeugt einen Logger, der in die übergebene Senke schreibt. // // Der zurückgegebene Logger redigiert Secret-Felder automatisch und ergänzt // jeden Eintrag um den Dienstnamen. func New(outputWriter io.Writer, loggerOptions Options) *slog.Logger { handlerOptions := &slog.HandlerOptions{ Level: parseLevel(loggerOptions.Level), // ReplaceAttr greift für jedes Attribut und ist damit der einzige Ort, // an dem eine Redaction zuverlässig nicht vergessen werden kann. ReplaceAttr: redactSecretAttribute, } var logHandler slog.Handler if strings.EqualFold(loggerOptions.Format, "text") { logHandler = slog.NewTextHandler(outputWriter, handlerOptions) } else { logHandler = slog.NewJSONHandler(outputWriter, handlerOptions) } return slog.New(logHandler).With(slog.String(FieldService, loggerOptions.ServiceName)) } // parseLevel übersetzt den konfigurierten Level-Namen in einen slog.Level. func parseLevel(levelName string) slog.Level { switch strings.ToLower(strings.TrimSpace(levelName)) { case "debug": return slog.LevelDebug case "warn": return slog.LevelWarn case "error": return slog.LevelError default: // Unbekannte Werte werden von der Konfiguration bereits abgelehnt; // hier ist Info der sichere Rückfall. return slog.LevelInfo } } // redactSecretAttribute ersetzt die Werte geheim benannter Attribute. func redactSecretAttribute(_ []string, logAttribute slog.Attr) slog.Attr { if IsSecretFieldName(logAttribute.Key) { return slog.String(logAttribute.Key, redactedPlaceholder) } return logAttribute } // IsSecretFieldName meldet, ob ein Feldname auf einen geheimen Wert hindeutet. // Die Funktion ist exportiert, damit auch API- und Audit-Schichten dieselbe // Bewertung verwenden können. func IsSecretFieldName(fieldName string) bool { lowercaseFieldName := strings.ToLower(fieldName) for _, secretFieldName := range secretFieldNames { if strings.Contains(lowercaseFieldName, secretFieldName) { return true } } return false } // ContextWithCorrelationID hinterlegt die Correlation ID im Context. func ContextWithCorrelationID(parentContext context.Context, correlationID string) context.Context { return context.WithValue(parentContext, correlationIDContextKey, correlationID) } // CorrelationIDFromContext liest die Correlation ID aus dem Context. // Fehlt sie, ist der zweite Rückgabewert false. func CorrelationIDFromContext(currentContext context.Context) (string, bool) { correlationID, isPresent := currentContext.Value(correlationIDContextKey).(string) if !isPresent || correlationID == "" { return "", false } return correlationID, true } // WithContext ergänzt einen Logger um die Correlation ID des Contexts. // // So bleibt eine Operation über Dienstgrenzen hinweg nachvollziehbar, ohne dass // jede Aufrufstelle die ID manuell durchreichen muss. func WithContext(currentContext context.Context, baseLogger *slog.Logger) *slog.Logger { correlationID, isPresent := CorrelationIDFromContext(currentContext) if !isPresent { return baseLogger } return baseLogger.With(slog.String(FieldCorrelationID, correlationID)) } // WithComponent kennzeichnet alle folgenden Einträge mit einer Komponente. func WithComponent(baseLogger *slog.Logger, componentName string) *slog.Logger { return baseLogger.With(slog.String(FieldComponent, componentName)) } // WithOperation kennzeichnet alle folgenden Einträge mit einer Operation. func WithOperation(baseLogger *slog.Logger, operationName string) *slog.Logger { return baseLogger.With(slog.String(FieldOperation, operationName)) }