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) }