package main import ( "context" "fmt" "io" "log/slog" "os" "strings" "sync" "time" ) // daemonLogger returns a logger for one daemon part. JOURNAL_STREAM selects // syslog priorities without a time stamp. Other output carries the standard // time stamp and level. func daemonLogger(out io.Writer, prefix string) *slog.Logger { return slog.New(&DaemonLogHandler{ out: out, prefix: prefix, journal: os.Getenv("JOURNAL_STREAM") != "", mutex: &sync.Mutex{}, }) } // DaemonLogHandler writes short event lines with ordered attributes. Copies // share the writer lock. The err attribute follows all other attributes as a // cause. Journal lines carry a syslog priority instead of a time and level. type DaemonLogHandler struct { out io.Writer prefix string journal bool mutex *sync.Mutex attrs []slog.Attr group string } func (h *DaemonLogHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } func (h *DaemonLogHandler) Handle(_ context.Context, record slog.Record) error { var line strings.Builder if h.journal { priority := 7 switch { case record.Level >= slog.LevelError: priority = 3 case record.Level >= slog.LevelWarn: priority = 4 case record.Level >= slog.LevelInfo: priority = 6 } fmt.Fprintf(&line, "<%d>", priority) } else { line.WriteString(record.Time.Format("2006/01/02 15:04:05")) line.WriteByte(' ') line.WriteString(record.Level.String()) line.WriteByte(' ') } line.WriteString(h.prefix) line.WriteString(record.Message) var cause string for _, attr := range h.attrs { daemonLogAttr(&line, "", attr, &cause) } record.Attrs(func(attr slog.Attr) bool { daemonLogAttr(&line, h.group, attr, &cause) return true }) if cause != "" { line.WriteString(": ") line.WriteString(cause) } line.WriteByte('\n') h.mutex.Lock() defer h.mutex.Unlock() _, err := io.WriteString(h.out, line.String()) if err != nil { return fmt.Errorf("write daemon log: %w", err) } return nil } func (h *DaemonLogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { clone := *h clone.attrs = append([]slog.Attr(nil), h.attrs...) for _, attr := range attrs { if h.group != "" { attr = slog.Group(strings.TrimSuffix(h.group, "."), attr) } clone.attrs = append(clone.attrs, attr) } return &clone } func (h *DaemonLogHandler) WithGroup(name string) slog.Handler { clone := *h if name != "" { clone.group += name + "." } return &clone } // daemonLogAttr resolves and writes one attribute, or keeps its cause for the // end of the line. Groups flatten into dotted keys and empty attributes produce // no text. func daemonLogAttr(line *strings.Builder, group string, attr slog.Attr, cause *string) { attr.Value = attr.Value.Resolve() if attr.Equal(slog.Attr{}) { return } if attr.Value.Kind() == slog.KindGroup { if attr.Key != "" { group += attr.Key + "." } for _, child := range attr.Value.Group() { daemonLogAttr(line, group, child, cause) } return } if attr.Key == "err" { if attr.Value.Any() != nil { *cause = fmt.Sprint(attr.Value.Any()) } return } line.WriteByte(' ') line.WriteString(group + attr.Key) line.WriteByte('=') if attr.Value.Kind() == slog.KindDuration { line.WriteString(durationText(attr.Value.Duration())) } else { fmt.Fprint(line, attr.Value.Any()) } } // durationText returns d for a log line: rounded to the second, or to the // millisecond when it is shorter than a second. func durationText(d time.Duration) string { if d < time.Second { return d.Round(time.Millisecond).String() } return d.Round(time.Second).String() }