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.
130 lines
3.4 KiB
Go
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)
|
|
}
|