feat(logger): include attributes in persisted log message

Store attributes as key=value pairs in the SQLite log message
so they are visible in the web UI. job_id is extracted into the
dedicated column and excluded from the message text.
This commit is contained in:
Rodrigo Verdiani 2026-07-06 22:58:28 -03:00
parent 2fd56d0f36
commit 715d680f97
2 changed files with 43 additions and 1 deletions

View File

@ -6,6 +6,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"os" "os"
"strings"
"sync" "sync"
"coda/internal/models" "coda/internal/models"
@ -74,17 +75,26 @@ func (h *Handler) Handle(ctx context.Context, r slog.Record) error {
entry := models.LogEntry{ entry := models.LogEntry{
Level: r.Level.String(), Level: r.Level.String(),
Message: r.Message,
Timestamp: r.Time, Timestamp: r.Time,
} }
var msg strings.Builder
msg.WriteString(r.Message)
r2.Attrs(func(a slog.Attr) bool { r2.Attrs(func(a slog.Attr) bool {
if a.Key == "job_id" && a.Value.Kind() == slog.KindString { if a.Key == "job_id" && a.Value.Kind() == slog.KindString {
entry.JobID = a.Value.String() entry.JobID = a.Value.String()
return true
} }
msg.WriteByte(' ')
msg.WriteString(a.Key)
msg.WriteByte('=')
msg.WriteString(fmt.Sprintf("%v", a.Value.Any()))
return true return true
}) })
entry.Message = msg.String()
select { select {
case h.ch <- entry: case h.ch <- entry:
default: default:

View File

@ -5,6 +5,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"coda/internal/models" "coda/internal/models"
@ -92,6 +93,37 @@ func TestHandlerRespectsLevel(t *testing.T) {
} }
} }
func TestHandlerIncludesAttrsInMessage(t *testing.T) {
st := openTestStore(t)
h := New(st.Logs, Options{Level: slog.LevelInfo, Stdout: io.Discard})
log := slog.New(h)
log.Warn("path outside music path", "path", "/mnt/media/Music/Artist", "music_path", "/music")
if err := h.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
logs, err := st.Logs.List(context.Background(), store.ListLogsParams{Limit: 10})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(logs) != 1 {
t.Fatalf("expected 1 log, got %d", len(logs))
}
msg := logs[0].Message
if !strings.Contains(msg, "path outside music path") {
t.Errorf("message missing base text: %q", msg)
}
if !strings.Contains(msg, "path=/mnt/media/Music/Artist") {
t.Errorf("message missing path attr: %q", msg)
}
if !strings.Contains(msg, "music_path=/music") {
t.Errorf("message missing music_path attr: %q", msg)
}
}
type failingWriter struct{} type failingWriter struct{}
func (failingWriter) Insert(ctx context.Context, e *models.LogEntry) error { func (failingWriter) Insert(ctx context.Context, e *models.LogEntry) error {