294 lines
8.6 KiB
Go
294 lines
8.6 KiB
Go
/*
|
||
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),
|
||
},
|
||
}
|
||
}
|