journal/journslog/journslog.go

294 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Package journslog implements an [slog.Handler] which writes output to the
[systemd journal].
[systemd journal]: https://www.freedesktop.org/software/systemd/man/latest/systemd-journald.service.html
*/
package journslog
import (
"context"
"log/slog"
"runtime"
"slices"
"strconv"
"strings"
"time"
"src.lwithers.me.uk/go/journal"
)
// JournalHandler is an [slog.Handler] which writes output to the
// [systemd journal].
//
// Note that the handler discards the Time field from [slog.Record] by default,
// as there is no standardised way to override the timestamp recorded by the
// systemd-journal upon reception of a message. It is possible to emit a field
// using [WithAttrsFromTime].
//
// [systemd journal]: https://www.freedesktop.org/software/systemd/man/latest/systemd-journald.service.html
type JournalHandler struct {
// addSource causes CODE_FILE, CODE_LINE and CODE_FUNC attrs to be added
// to each entry.
addSource bool
// minLevel is the minimum level of messages we emit. Lower levels are
// not emitted. Note it can be overridden by levelFromContext.
minLevel slog.Leveler
// replaceAttr is an optional function (may be nil) which the user can
// use to map (resolved) [slog.Attr] values into other values, including
// empty to skip.
replaceAttr func(groups []string, a slog.Attr) slog.Attr
// levelMapper turns [slog.Level] values into [journal.Priority] values.
levelMapper LevelMapper
// keyMapper turns [slog.Attr] key strings into [journal.AttrKey] values.
keyMapper KeyMapper
// conn is the underlying journal connection to use.
conn *journal.Conn
// levelFromContext allows overriding the minimum log level for a logger
// based on the [context.Context] value. It may be nil.
levelFromContext func(context.Context) (slog.Level, bool)
// attrsFromContext may supply additional attributes to the logger from
// the [context.Context] value. It may be nil.
attrsFromContext func(context.Context) []slog.Attr
// attrsFromTime may return zero or more attributes to represent the
// Time from an [slog.Record]. It may be nil.
attrsFromTime func(time.Time) []slog.Attr
// groups are the ordered group names added by WithGroup. We need to
// maintain the full list, not just a pre-rendered prefix string,
// because of the API of replaceAttr.
groups []string
// group is the pre-rendered prefix string corresponding to groups.
group groupPrefix
// jattrs are the pre-rendered attributes added by WithAttrs.
jattrs []journal.Attr
}
// NewHandler returns a new [JournalHandler]. It returns an error if a
// connection to the journal socket could not be established.
func NewHandler(opts ...HandlerOption) (*JournalHandler, error) {
jh := &JournalHandler{
minLevel: slog.LevelInfo,
levelMapper: DefaultLevelMapper(),
keyMapper: DefaultKeyMapper(),
}
for _, opt := range opts {
opt(jh)
}
// For fields which require allocation of resources, initialise them
// after processing options in case the user already supplied a non-nil
// value. In particular this means we don't cause the global connection
// to the journal to be opened if the user supplied something more
// specific.
if jh.levelMapper == nil {
jh.levelMapper = DefaultLevelMapper()
}
if jh.keyMapper == nil {
jh.keyMapper = DefaultKeyMapper()
}
if jh.conn == nil {
var err error
if jh.conn, err = journal.GetGlobalConn(); err != nil {
return nil, err
}
}
return jh, nil
}
// WithAttrs returns an [slog.Handler] containing some fixed attributes, which
// will be emitted on all future calls to Handle. The attribute values are
// resolved within this function call.
func (jh *JournalHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return jh
}
jattrs := make([]journal.Attr, 0, len(attrs))
for _, attr := range attrs {
jattrs = jh.renderAttribute(jattrs, jh.groups, jh.group, attr)
}
if len(jattrs) == 0 {
return jh
}
nh := new(JournalHandler)
*nh = *jh
nh.jattrs = slices.Concat(jh.jattrs, jattrs)
return nh
}
// WithGroup returns an [slog.Handler] where are future attributes are assigned
// to the given group.
func (jh *JournalHandler) WithGroup(group string) slog.Handler {
nh := new(JournalHandler)
*nh = *jh
nh.groups = append(slices.Clone(jh.groups), group)
nh.group = jh.group.Append(group)
return nh
}
// Enabled returns true if the given log level is enabled, false otherwise.
func (jh *JournalHandler) Enabled(ctx context.Context, level slog.Level) bool {
var (
minLevel slog.Level
ok bool
)
// avoid call to jh.minLevel.Level() if we have a more specific value
// from the context
if jh.levelFromContext != nil {
minLevel, ok = jh.levelFromContext(ctx)
}
if !ok {
minLevel = jh.minLevel.Level()
}
return level >= minLevel
}
// Handle a log record, writing it to the journal similar to [journal.Entry].
func (jh *JournalHandler) Handle(ctx context.Context, rec slog.Record) error {
pri := jh.levelMapper.LevelMap(rec.Level)
var addAttrs []slog.Attr
if jh.attrsFromTime != nil && !rec.Time.IsZero() {
addAttrs = attrPoolGet()
defer attrPoolPut(addAttrs)
addAttrs = append(addAttrs, jh.attrsFromTime(rec.Time)...)
}
if jh.attrsFromContext != nil {
if addAttrs == nil {
addAttrs = attrPoolGet()
defer attrPoolPut(addAttrs)
}
addAttrs = append(addAttrs, jh.attrsFromContext(ctx)...)
}
// 2 for journal.Conn.Entry (MESSAGE and PRIORITY),
// 3 for SRC_* (optional but we always allocate space)
numAttrsEstimate := 2 + len(jh.conn.Common) + 3 + len(addAttrs) + len(jh.jattrs) + rec.NumAttrs()
jattrs := slices.Grow(jattrPoolGet(), numAttrsEstimate)
defer jattrPoolPut(jattrs)
// add SRC_* if requested
if jh.addSource && rec.PC != 0 {
jattrs = append(jattrs, jh.sourceAttrs(rec.PC)...)
}
// add time / context attributes, if any
for i := range addAttrs {
jattrs = jh.renderAttribute(jattrs, nil, "", addAttrs[i])
}
// add pre-rendered attributes from WithAttrs
jattrs = append(jattrs, jh.jattrs...)
// map [slog.Attr] values to [journal.Attr] values
rec.Attrs(func(a slog.Attr) bool {
jattrs = jh.renderAttribute(jattrs, jh.groups, jh.group, a)
return true
})
return jh.conn.EntryErr(pri, rec.Message, jattrs)
}
// groupPrefix enables efficient building of attribute keys prepended by group
// names, separated by '.'. Invariant: always n × (group-name '.'), n ≥ 0.
type groupPrefix string
func (gp groupPrefix) Prepend(key string) string {
if gp == "" { // avoid allocation in frequent path
return key
}
return string(gp) + key
}
func (gp groupPrefix) Append(group string) groupPrefix {
var b strings.Builder
b.WriteString(string(gp))
b.WriteString(group)
b.WriteRune('.')
return groupPrefix(b.String())
}
// renderAttribute turns an [slog.Attr] into some number of resolved,
// fully-qualified [journal.Attr] values. It calls replaceAttr if defined.
// New attributes are appended to the slice jattrs, and the new slice header
// is returned.
//
// If the attribute (after replacement) is a zero [slog.Attr] value, or it is
// of kind [slog.KindGroup] with zero child elements, no change is made to the
// jattrs slice.
//
// If the attribute (after replacement) is of kind [slog.Group], the function
// recurses, potentially adding multiple elements to the jattrs slice.
// list:[]journal.Attr{...}}.
//
// Otherwise, the function appends a single element to the jattrs slice.
func (jh *JournalHandler) renderAttribute(jattrs []journal.Attr, groups []string, gp groupPrefix, attr slog.Attr) []journal.Attr {
val := attr.Value.Resolve()
if jh.replaceAttr != nil && val.Kind() != slog.KindGroup {
attr = jh.replaceAttr(groups, attr)
val = attr.Value.Resolve()
}
if attr.Equal(slog.Attr{}) {
return jattrs
}
// handle slog.KindGroup by recursing
if val.Kind() == slog.KindGroup {
groups = append(groups, attr.Key)
gp = gp.Append(attr.Key)
for _, attr := range val.Group() {
jattrs = jh.renderAttribute(jattrs, groups, gp, attr)
}
return jattrs
}
key := gp.Prepend(attr.Key)
return append(jattrs, journal.Attr{
Key: jh.keyMapper.KeyMap(key),
Value: []byte(val.String()),
})
}
var (
srcAttrCodeFile = journal.MustAttrKey("CODE_FILE")
srcAttrCodeLine = journal.MustAttrKey("CODE_LINE")
srcAttrCodeFunc = journal.MustAttrKey("CODE_FUNC")
)
// sourceAttrs returns the attributes we emit for source location.
func (jh *JournalHandler) sourceAttrs(PC uintptr) []journal.Attr {
fs := runtime.CallersFrames([]uintptr{PC})
f, _ := fs.Next()
return []journal.Attr{
journal.Attr{
Key: srcAttrCodeFile,
Value: []byte(f.File),
},
journal.Attr{
Key: srcAttrCodeLine,
Value: []byte(strconv.Itoa(f.Line)),
},
journal.Attr{
Key: srcAttrCodeFunc,
Value: []byte(f.Function),
},
}
}