feat(server): add syslog priority prefixes for systemd-journald (#5192)

* fix: add syslog priority prefixes for systemd-journald

When running under systemd, all log messages were assigned priority 3
(error) by journald because navidrome wrote plain text to stderr without
syslog priority prefixes.

Add a journalFormatter that wraps the existing logrus formatter and
prepends <N> syslog priority prefixes (RFC 5424) to each log line.
The formatter is automatically enabled when the JOURNAL_STREAM
environment variable is set (indicating the process is managed by
systemd).

Priority mapping:
- Fatal/Panic → <2>/<0> (crit/emerg)
- Error → <3> (err)
- Warn → <4> (warning)
- Info → <6> (info)
- Debug/Trace → <7> (debug)

Fixes #5142

* test: refactor journalFormatter tests to use Ginkgo and DescribeTable

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Tom Boucher
2026-03-15 14:14:05 -04:00
committed by GitHub
parent c42570446b
commit aa93911991
4 changed files with 95 additions and 0 deletions

View File

@@ -340,6 +340,10 @@ func Load(noConfigDump bool) {
os.Exit(1)
}
log.SetOutput(out)
} else if os.Getenv("JOURNAL_STREAM") != "" {
// When running under systemd, prepend syslog priority prefixes so
// journald assigns the correct severity to each log line.
log.EnableJournalFormat()
}
log.SetLevelString(Server.LogLevel)

41
log/journal.go Normal file
View File

@@ -0,0 +1,41 @@
package log
import (
"fmt"
"github.com/sirupsen/logrus"
)
// journalFormatter wraps a logrus.Formatter and prepends a syslog priority
// prefix (<N>) to each log line. When stderr is captured by systemd-journald,
// this prefix tells journald the correct severity for each message.
//
// See https://www.freedesktop.org/software/systemd/man/sd-daemon.html
type journalFormatter struct {
inner logrus.Formatter
}
// levelToPriority maps logrus levels to syslog priority values.
// The mapping follows RFC 5424 severity levels.
var levelToPriority = map[logrus.Level]int{
logrus.PanicLevel: 0, // emerg
logrus.FatalLevel: 2, // crit
logrus.ErrorLevel: 3, // err
logrus.WarnLevel: 4, // warning
logrus.InfoLevel: 6, // info
logrus.DebugLevel: 7, // debug
logrus.TraceLevel: 7, // debug
}
func (f *journalFormatter) Format(entry *logrus.Entry) ([]byte, error) {
formatted, err := f.inner.Format(entry)
if err != nil {
return formatted, err
}
priority, ok := levelToPriority[entry.Level]
if !ok {
priority = 6 // default to info for unknown levels
}
prefix := []byte(fmt.Sprintf("<%d>", priority))
return append(prefix, formatted...), nil
}

41
log/journal_test.go Normal file
View File

@@ -0,0 +1,41 @@
package log
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/sirupsen/logrus"
)
var _ = Describe("journalFormatter", func() {
var formatter *journalFormatter
BeforeEach(func() {
inner := &logrus.TextFormatter{
DisableTimestamp: true,
DisableColors: true,
}
formatter = &journalFormatter{inner: inner}
})
DescribeTable("prefixes log lines with syslog priority",
func(level logrus.Level, expectedPrefix string) {
entry := &logrus.Entry{
Logger: logrus.New(),
Level: level,
Message: "test message",
Data: logrus.Fields{},
}
out, err := formatter.Format(entry)
Expect(err).ToNot(HaveOccurred())
Expect(string(out)).To(HavePrefix(expectedPrefix))
},
Entry("error", logrus.ErrorLevel, "<3>"),
Entry("warning", logrus.WarnLevel, "<4>"),
Entry("info", logrus.InfoLevel, "<6>"),
Entry("debug", logrus.DebugLevel, "<7>"),
Entry("trace", logrus.TraceLevel, "<7>"),
Entry("fatal", logrus.FatalLevel, "<2>"),
Entry("panic", logrus.PanicLevel, "<0>"),
Entry("unknown level defaults to info", logrus.Level(99), "<6>"),
)
})

View File

@@ -145,6 +145,15 @@ func SetOutput(w io.Writer) {
defaultLogger.SetOutput(w)
}
// EnableJournalFormat wraps the current logger formatter with syslog
// priority prefixes for systemd-journald. Only call this when output
// goes to stderr and JOURNAL_STREAM is set.
func EnableJournalFormat() {
loggerMu.Lock()
defer loggerMu.Unlock()
defaultLogger.Formatter = &journalFormatter{inner: defaultLogger.Formatter}
}
// Redact applies redaction to a single string
func Redact(msg string) string {
r, _ := redacted.redact(msg)