feat(logger): deliver stderr to systemd journal with per-record priority

When stderr is connected to journald, switch from the plain text handler
to the native journal protocol via github.com/systemd/slog-journal. Each
record now carries PRIORITY, MESSAGE, and slog attributes as structured
fields, so journalctl -p and LogLevelMax= in unit files filter by
severity instead of dropping everything at once.

Detection follows systemd.exec(5): parse JOURNAL_STREAM as <dev>:<ino>
and fstat(2) stderr to confirm the match, guarding against processes
that inherit the env var but have stderr redirected elsewhere. The
fstat path is Linux-only; other platforms fall through to the existing
text handler.

Also honour DEBUG_INVOCATION by raising stderr to Debug level, matching
the behaviour of systemd.service(5) RestartMode=debug.
This commit is contained in:
2026-04-12 14:01:07 -07:00
parent 79ccf33774
commit d8b9cddfb8
8 changed files with 235 additions and 1 deletions

62
logger/journald.go Normal file
View File

@@ -0,0 +1,62 @@
package logger
import (
"log/slog"
slogjournal "github.com/systemd/slog-journal"
)
// newJournalHandler returns a slog.Handler that delivers records to the
// systemd journal using the native protocol. It returns nil when stderr
// is not connected to journald, in which case the caller falls back to
// the stderr text handler.
func newJournalHandler(opts *slog.HandlerOptions) slog.Handler {
if !stderrIsJournal() {
return nil
}
h, err := slogjournal.NewHandler(&slogjournal.Options{
Level: opts.Level,
ReplaceAttr: journalReplaceAttr,
ReplaceGroup: sanitizeJournalKey,
})
if err != nil {
return nil
}
return h
}
// journalReplaceAttr sanitizes slog attribute keys so they satisfy the
// journal's ^[A-Z_][A-Z0-9_]*$ constraint. Without this, otherwise
// valid attributes from third-party handlers (e.g. the trace_id/span_id
// injected by slogtraceid) are silently dropped by the journal.
func journalReplaceAttr(groups []string, a slog.Attr) slog.Attr {
a.Key = sanitizeJournalKey(a.Key)
return a
}
// sanitizeJournalKey maps an arbitrary string to a journal-legal key:
// upper-cases ASCII letters, replaces everything outside [A-Z0-9_] with
// '_', and prefixes '_' if the first byte is a digit.
func sanitizeJournalKey(k string) string {
if k == "" {
return k
}
b := make([]byte, 0, len(k)+1)
for i := 0; i < len(k); i++ {
c := k[i]
switch {
case c >= 'A' && c <= 'Z', c == '_':
b = append(b, c)
case c >= 'a' && c <= 'z':
b = append(b, c-'a'+'A')
case c >= '0' && c <= '9':
if i == 0 {
b = append(b, '_')
}
b = append(b, c)
default:
b = append(b, '_')
}
}
return string(b)
}