Files
common/logger/journald.go
Ask Bjørn Hansen 82e7f4398b fix(logger): render full logfmt line as journal MESSAGE
The native journal handler only put the bare msg string in MESSAGE,
so default `journalctl` output lost all slog attributes (env, name,
ip_version, etc.) that were visible with the old TextHandler path.

Wrap the slog-journal handler so each record's MESSAGE is rendered
through slog.TextHandler — producing the same `level=INFO msg="..."
key=val` format as before — while still emitting every attribute as
a structured journal field for `journalctl -o verbose` / field-based
filtering.

Also fix go.mod: slog-journal is a direct dependency.
2026-04-19 02:06:29 -07:00

130 lines
3.4 KiB
Go

package logger
import (
"bytes"
"context"
"log/slog"
"slices"
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 &journalMessageHandler{inner: h, level: opts.Level}
}
// journalMessageHandler wraps a slog-journal handler so each record's
// MESSAGE field contains the full logfmt line (level, msg, and all
// attributes) — matching the pre-journal stderr TextHandler output so
// plain `journalctl` remains readable. Attributes are still delivered as
// structured journal fields, so `journalctl -o verbose` or
// `--output-fields=` keep working for filtering.
type journalMessageHandler struct {
inner slog.Handler
level slog.Leveler
ops []handlerOp
}
type handlerOp struct {
attrs []slog.Attr // non-nil for WithAttrs
group string // non-empty for WithGroup
}
func (h *journalMessageHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
func (h *journalMessageHandler) Handle(ctx context.Context, r slog.Record) error {
var buf bytes.Buffer
var th slog.Handler = slog.NewTextHandler(&buf, &slog.HandlerOptions{
Level: h.level,
ReplaceAttr: logRemoveTime,
})
for _, op := range h.ops {
if op.group != "" {
th = th.WithGroup(op.group)
} else {
th = th.WithAttrs(op.attrs)
}
}
if err := th.Handle(ctx, r); err != nil {
return err
}
msg := bytes.TrimRight(buf.Bytes(), "\n")
nr := r.Clone()
nr.Message = string(msg)
return h.inner.Handle(ctx, nr)
}
func (h *journalMessageHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
h2 := *h
h2.ops = append(slices.Clip(h.ops), handlerOp{attrs: attrs})
h2.inner = h.inner.WithAttrs(attrs)
return &h2
}
func (h *journalMessageHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
h2 := *h
h2.ops = append(slices.Clip(h.ops), handlerOp{group: name})
h2.inner = h.inner.WithGroup(name)
return &h2
}
// 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)
}