diff --git a/task/log/log.go b/task/log/log.go new file mode 100644 index 0000000..5372f60 --- /dev/null +++ b/task/log/log.go @@ -0,0 +1,152 @@ +package log + +import ( + "context" + "fmt" + "log/slog" + "runtime" + "sync" + "time" + + "git.lastassault.de/speatzle/morffix/types" + slogmulti "github.com/samber/slog-multi" +) + +func GetTaskLogger(t *types.Task) *slog.Logger { + return slog.New( + slogmulti.Fanout( + slog.Default().Handler(), + New(t, &Options{slog.LevelDebug}), + ), + ) +} + +// groupOrAttrs holds either a group name or a list of slog.Attrs. +type groupOrAttrs struct { + group string // group name if non-empty + attrs []slog.Attr // attrs if non-empty +} + +type TaskHandler struct { + opts Options + // TODO: state for WithGroup and WithAttrs + mu *sync.Mutex + t *types.Task + goas []groupOrAttrs +} + +type Options struct { + // Level reports the minimum level to log. + // Levels with lower levels are discarded. + // If nil, the Handler uses [slog.LevelInfo]. + Level slog.Leveler +} + +func New(t *types.Task, opts *Options) *TaskHandler { + h := &TaskHandler{t: t, mu: &sync.Mutex{}} + if opts != nil { + h.opts = *opts + } + if h.opts.Level == nil { + h.opts.Level = slog.LevelInfo + } + return h +} + +func (h *TaskHandler) Enabled(ctx context.Context, level slog.Level) bool { + return level >= h.opts.Level.Level() +} + +func (h *TaskHandler) Handle(ctx context.Context, r slog.Record) error { + buf := make([]byte, 0, 1024) + if !r.Time.IsZero() { + buf = h.appendAttr(buf, slog.Time(slog.TimeKey, r.Time)) + } + buf = h.appendAttr(buf, slog.Any(slog.LevelKey, r.Level)) + if r.PC != 0 { + fs := runtime.CallersFrames([]uintptr{r.PC}) + f, _ := fs.Next() + buf = h.appendAttr(buf, slog.String(slog.SourceKey, fmt.Sprintf("%s:%d", f.File, f.Line))) + } + buf = h.appendAttr(buf, slog.String(slog.MessageKey, r.Message)) + // Handle state from WithGroup and WithAttrs. + goas := h.goas + if r.NumAttrs() == 0 { + // If the record has no Attrs, remove groups at the end of the list; they are empty. + for len(goas) > 0 && goas[len(goas)-1].group != "" { + goas = goas[:len(goas)-1] + } + } + for _, goa := range goas { + if goa.group == "" { + for _, a := range goa.attrs { + buf = h.appendAttr(buf, a) + } + } + } + r.Attrs(func(a slog.Attr) bool { + buf = h.appendAttr(buf, a) + return true + }) + h.mu.Lock() + defer h.mu.Unlock() + h.t.Log = append(h.t.Log, string(buf)) + return nil +} + +func (h *TaskHandler) appendAttr(buf []byte, a slog.Attr) []byte { + // Resolve the Attr's value before doing anything else. + a.Value = a.Value.Resolve() + // Ignore empty Attrs. + if a.Equal(slog.Attr{}) { + return buf + } + + switch a.Value.Kind() { + case slog.KindString: + // Quote string values, to make them easy to parse. + buf = fmt.Appendf(buf, "%s: %q\n", a.Key, a.Value.String()) + case slog.KindTime: + // Write times in a standard way, without the monotonic time. + buf = fmt.Appendf(buf, "%s: %s\n", a.Key, a.Value.Time().Format(time.RFC3339Nano)) + case slog.KindGroup: + attrs := a.Value.Group() + // Ignore empty groups. + if len(attrs) == 0 { + return buf + } + // If the key is non-empty, write it out and indent the rest of the attrs. + // Otherwise, inline the attrs. + if a.Key != "" { + buf = fmt.Appendf(buf, "%s:\n", a.Key) + } + for _, ga := range attrs { + buf = h.appendAttr(buf, ga) + } + default: + buf = fmt.Appendf(buf, "%s: %s\n", a.Key, a.Value) + } + return buf +} + +func (h *TaskHandler) withGroupOrAttrs(goa groupOrAttrs) *TaskHandler { + h2 := *h + h2.goas = make([]groupOrAttrs, len(h.goas)+1) + copy(h2.goas, h.goas) + h2.goas[len(h2.goas)-1] = goa + return &h2 +} + +func (h *TaskHandler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + return h.withGroupOrAttrs(groupOrAttrs{group: name}) +} + +func (h *TaskHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + if len(attrs) == 0 { + return h + } + return h.withGroupOrAttrs(groupOrAttrs{attrs: attrs}) +}