diff --git a/journslog/handler_option.go b/journslog/handler_option.go index f52ec05..38478c2 100644 --- a/journslog/handler_option.go +++ b/journslog/handler_option.go @@ -81,3 +81,11 @@ func WithAttrsFromTime(attrsFromTime func(time.Time) []slog.Attr) HandlerOption jh.attrsFromTime = attrsFromTime } } + +// WithAttrsFromLevel supplies a function which can optionally map the given +// log level onto zero or more attributes. +func WithAttrsFromLevel(attrsFromLevel func(slog.Level) []slog.Attr) HandlerOption { + return func(jh *JournalHandler) { + jh.attrsFromLevel = attrsFromLevel + } +} diff --git a/journslog/journslog.go b/journslog/journslog.go index 9185489..ca71d9c 100644 --- a/journslog/journslog.go +++ b/journslog/journslog.go @@ -62,6 +62,10 @@ type JournalHandler struct { // Time from an [slog.Record]. It may be nil. attrsFromTime func(time.Time) []slog.Attr + // attrsFromLevel may return zero or more attributes to represent the + // log level. It may be nil. + attrsFromLevel func(slog.Level) []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. @@ -174,6 +178,13 @@ func (jh *JournalHandler) Handle(ctx context.Context, rec slog.Record) error { } addAttrs = append(addAttrs, jh.attrsFromContext(ctx)...) } + if jh.attrsFromLevel != nil { + if addAttrs == nil { + addAttrs = attrPoolGet() + defer attrPoolPut(addAttrs) + } + addAttrs = append(addAttrs, jh.attrsFromLevel(rec.Level)...) + } // 2 for journal.Conn.Entry (MESSAGE and PRIORITY), // 3 for SRC_* (optional but we always allocate space) diff --git a/journslog/journslog_test.go b/journslog/journslog_test.go index 53129b8..33c342b 100644 --- a/journslog/journslog_test.go +++ b/journslog/journslog_test.go @@ -4,7 +4,11 @@ import ( "log/slog" "net/http" "path/filepath" + "strings" "testing" + "testing/slogtest" + "time" + "uuid" "src.lwithers.me.uk/go/journal" "src.lwithers.me.uk/go/journal/testsink" @@ -82,3 +86,97 @@ type slowValue string func (s slowValue) LogValue() slog.Value { return slog.StringValue(string(s)) } + +// TestSlog integrates with [testing/slogtest]. +func TestSlog(t *testing.T) { + tmpdir := t.TempDir() + + attrsFromTime := func(t time.Time) []slog.Attr { + return []slog.Attr{ + { + Key: "TEST_TIME", + Value: slog.StringValue(t.Format(time.RFC3339Nano)), + }, + } + } + + attrsFromLevel := func(l slog.Level) []slog.Attr { + return []slog.Attr{ + { + Key: "TEST_LEVEL", + Value: slog.StringValue(l.String()), + }, + } + } + + // XXX: this relies on [slogtest.Run] executing its test cases + // completely sequentially + var curSink *testsink.Sink + + newHandler := func(t *testing.T) slog.Handler { + sockpath := filepath.Join(tmpdir, uuid.New().String()) + sink, err := testsink.New(sockpath) + if err != nil { + t.Fatalf("failed to create test sink: %v", err) + } + + conn, err := journal.Connect(sockpath) + if err != nil { + sink.Stop() + t.Fatalf("failed to connect to test sink socket: %v", err) + } + + h, err := NewHandler(WithConn(conn), WithAttrsFromTime(attrsFromTime), WithAttrsFromLevel(attrsFromLevel), WithHandlerOptions(slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + if err != nil { + sink.Stop() + t.Fatalf("failed to create handler: %v", err) + } + + curSink = sink + return h + } + + result := func(t *testing.T) map[string]any { + defer curSink.Stop() + + m, err := curSink.Message(0) + if err != nil { + t.Fatalf("failed to fetch message from test sink: %v", err) + } + + msg, attrs, err := m.Decode() + if err != nil { + t.Errorf("failed to decode message from test sink: %v", err) + } + + out := make(map[string]any) + out[slog.MessageKey] = msg + for i := range attrs { + k, v := attrs[i].Key, attrs[i].Val + switch k { + case "TEST_TIME": + out[slog.TimeKey] = v + case "TEST_LEVEL": + out[slog.LevelKey] = v + default: + parts := strings.Split(k, "_") + Ng := len(parts) - 1 // number of groups + g := out + for i := range Ng { + next, _ := g[parts[i]].(map[string]any) + if next == nil { + next = make(map[string]any) + g[parts[i]] = next + } + g = next + } + g[strings.ToLower(parts[Ng])] = v + } + } + return out + } + + slogtest.Run(t, newHandler, result) +}