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:
76
logger/journald_linux_test.go
Normal file
76
logger/journald_linux_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
//go:build linux
|
||||
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseJournalStream(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantDev uint64
|
||||
wantIno uint64
|
||||
wantOK bool
|
||||
}{
|
||||
{"empty", "", 0, 0, false},
|
||||
{"no separator", "12345", 0, 0, false},
|
||||
{"leading colon", ":12345", 0, 0, false},
|
||||
{"trailing colon", "8:", 0, 0, false},
|
||||
{"non-numeric dev", "x:12345", 0, 0, false},
|
||||
{"non-numeric ino", "8:x", 0, 0, false},
|
||||
{"valid small", "8:12345", 8, 12345, true},
|
||||
{"valid large", "18446744073709551615:1", 18446744073709551615, 1, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dev, ino, ok := parseJournalStream(tt.input)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("ok=%v want %v (input=%q)", ok, tt.wantOK, tt.input)
|
||||
}
|
||||
if ok && (dev != tt.wantDev || ino != tt.wantIno) {
|
||||
t.Fatalf("dev=%d ino=%d want dev=%d ino=%d", dev, ino, tt.wantDev, tt.wantIno)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStderrIsJournal_Unset(t *testing.T) {
|
||||
t.Setenv("JOURNAL_STREAM", "")
|
||||
if stderrIsJournal() {
|
||||
t.Fatal("stderrIsJournal returned true with JOURNAL_STREAM unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStderrIsJournal_Bogus(t *testing.T) {
|
||||
t.Setenv("JOURNAL_STREAM", "not-a-valid-value")
|
||||
if stderrIsJournal() {
|
||||
t.Fatal("stderrIsJournal returned true with bogus JOURNAL_STREAM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStderrIsJournal_Mismatch(t *testing.T) {
|
||||
// Pick impossibly high dev:inode that won't match real stderr.
|
||||
t.Setenv("JOURNAL_STREAM", "999999999:999999999")
|
||||
if stderrIsJournal() {
|
||||
t.Fatal("stderrIsJournal returned true for mismatching dev:ino")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStderrIsJournal_Match(t *testing.T) {
|
||||
// Point JOURNAL_STREAM at the real stderr's dev:inode and confirm
|
||||
// detection works. This exercises the fstat+compare path without
|
||||
// needing an actual journal socket.
|
||||
var st syscall.Stat_t
|
||||
if err := syscall.Fstat(int(os.Stderr.Fd()), &st); err != nil {
|
||||
t.Fatalf("fstat stderr: %v", err)
|
||||
}
|
||||
t.Setenv("JOURNAL_STREAM", fmt.Sprintf("%d:%d", uint64(st.Dev), uint64(st.Ino)))
|
||||
if !stderrIsJournal() {
|
||||
t.Fatal("stderrIsJournal returned false when JOURNAL_STREAM matches stderr dev:ino")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user