Files
go-sip/internal/logging/logging.go
T

115 lines
3.1 KiB
Go

// 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)))
safe, err := RedactJSON(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", "json")
}
slog.DebugContext(ctx, message, attrs...)
}