// Package logging provides the single Logrus output for both service roles. // The slog handler keeps existing structured call sites and injected loggers, // without maintaining a second output or a second level configuration. package logging import ( "context" "crypto/sha256" "fmt" "io" "log/slog" "time" "github.com/sirupsen/logrus" ) func Configure(role string, debug bool, output io.Writer) { backend := logrus.New() backend.SetOutput(output) backend.SetFormatter(&logrus.JSONFormatter{TimestampFormat: time.RFC3339Nano, DisableHTMLEscape: true}) if debug { backend.SetLevel(logrus.DebugLevel) } else { backend.SetLevel(logrus.InfoLevel) } slog.SetDefault(slog.New(&handler{backend: backend, fields: logrus.Fields{"role": role}})) } type handler struct { backend *logrus.Logger fields logrus.Fields group string } func level(l slog.Level) logrus.Level { switch { case l >= slog.LevelError: return logrus.ErrorLevel case l >= slog.LevelWarn: return logrus.WarnLevel case l >= slog.LevelInfo: return logrus.InfoLevel default: return logrus.DebugLevel } } func (h *handler) Enabled(_ context.Context, l slog.Level) bool { return h.backend.IsLevelEnabled(level(l)) } func (h *handler) Handle(_ context.Context, r slog.Record) error { fields := h.copyFields() r.Attrs(func(a slog.Attr) bool { h.add(fields, a); return true }) h.backend.WithFields(fields).WithTime(r.Time).Log(level(r.Level), cleanString(r.Message)) return nil } func (h *handler) copyFields() logrus.Fields { fields := make(logrus.Fields, len(h.fields)+8) for k, v := range h.fields { fields[k] = v } return fields } func (h *handler) add(fields logrus.Fields, a slog.Attr) { a.Value = a.Value.Resolve() if a.Equal(slog.Attr{}) { return } if a.Value.Kind() == slog.KindGroup { group := h.group if a.Key != "" { group += a.Key + "." } nested := *h nested.group = group for _, child := range a.Value.Group() { nested.add(fields, child) } return } fields[h.group+a.Key] = safeValue(a.Key, a.Value.Any()) } func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler { clone := *h clone.fields = h.copyFields() for _, a := range attrs { clone.add(clone.fields, a) } return &clone } func (h *handler) WithGroup(name string) slog.Handler { if name == "" { return h } clone := *h clone.group += name + "." return &clone } // Body observes actual boundary bytes. Redaction happens only when Debug is on // and never mutates the original data. Unsafe/malformed bodies produce an // explicit diagnostic, not an unsanitized fallback or a successful parse claim. func Body(ctx context.Context, message string, body []byte, attrs ...any) { if !slog.Default().Enabled(ctx, slog.LevelDebug) { return } attrs = append(append([]any{}, attrs...), "body_bytes", len(body), "body_sha256", fmt.Sprintf("%x", sha256.Sum256(body))) logObservedBody(ctx, message, body, "json", RedactJSON, attrs...) } func TextBody(ctx context.Context, message string, body []byte, attrs ...any) { if !slog.Default().Enabled(ctx, slog.LevelDebug) { return } attrs = append(append([]any{}, attrs...), "body_bytes", len(body), "body_sha256", fmt.Sprintf("%x", sha256.Sum256(body))) logObservedBody(ctx, message, body, "utf-8", RedactText, attrs...) } func logObservedBody(ctx context.Context, message string, body []byte, encoding string, redact func([]byte) (string, error), attrs ...any) { safe, err := redact(body) if err != nil { attrs = append(attrs, "redaction_error", err.Error(), "body", "[OMITTED: unsafe or invalid JSON]") } else { attrs = append(attrs, "body", safe, "body_encoding", encoding) } slog.DebugContext(ctx, message, attrs...) }