From 1c75db8c1d1dc6a0a9097016dbdbbb4348f5c835 Mon Sep 17 00:00:00 2001 From: Marc Pervaz Boocha Date: Sat, 28 Feb 2026 00:16:22 +0530 Subject: Add Host Logger --- go.mod | 5 +- go.sum | 2 + logging/chain.go | 152 +++++++++++++++++++++++++ logging/config.go | 245 +++++++++++++++++++++++++++++++++++++++++ logging/encoding.go | 125 +++++++++++++++++++++ logging/encoding_test.go | 75 +++++++++++++ logging/host.go | 23 ++++ logging/host_linux.go | 211 +++++++++++++++++++++++++++++++++++ logging/host_windows.go | 18 +++ logging/log.go | 228 -------------------------------------- logging/sink.go | 48 ++++++++ logging/sinks/chain.go | 152 ------------------------- logging/sinks/encoding.go | 125 --------------------- logging/sinks/encoding_test.go | 75 ------------- logging/sinks/sink.go | 48 -------- logging/sinks/syslog.go | 155 -------------------------- logging/sinks/syslog_stub.go | 9 -- logging/sinks/syslog_unix.go | 20 ---- logging/sinks/writer.go | 44 -------- logging/sinks/writer_test.go | 39 ------- logging/syslog.go | 166 ++++++++++++++++++++++++++++ logging/syslog_stub.go | 9 ++ logging/syslog_unix.go | 20 ++++ logging/test/handler.go | 12 +- logging/writer.go | 45 ++++++++ logging/writer_test.go | 39 +++++++ 26 files changed, 1188 insertions(+), 902 deletions(-) create mode 100644 logging/chain.go create mode 100644 logging/config.go create mode 100644 logging/encoding.go create mode 100644 logging/encoding_test.go create mode 100644 logging/host.go create mode 100644 logging/host_linux.go create mode 100644 logging/host_windows.go create mode 100644 logging/sink.go delete mode 100644 logging/sinks/chain.go delete mode 100644 logging/sinks/encoding.go delete mode 100644 logging/sinks/encoding_test.go delete mode 100644 logging/sinks/sink.go delete mode 100644 logging/sinks/syslog.go delete mode 100644 logging/sinks/syslog_stub.go delete mode 100644 logging/sinks/syslog_unix.go delete mode 100644 logging/sinks/writer.go delete mode 100644 logging/sinks/writer_test.go create mode 100644 logging/syslog.go create mode 100644 logging/syslog_stub.go create mode 100644 logging/syslog_unix.go create mode 100644 logging/writer.go create mode 100644 logging/writer_test.go diff --git a/go.mod b/go.mod index 0cc7e2a..09e8bc6 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module go.sudomsg.com/kit go 1.26.0 -require golang.org/x/sync v0.19.0 +require ( + golang.org/x/sync v0.19.0 + golang.org/x/sys v0.41.0 +) diff --git a/go.sum b/go.sum index 159532a..7edb209 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/logging/chain.go b/logging/chain.go new file mode 100644 index 0000000..d6ccb8a --- /dev/null +++ b/logging/chain.go @@ -0,0 +1,152 @@ +package logging + +import ( + "context" + "iter" + "log/slog" + "slices" +) + +func attrAll(r slog.Record) iter.Seq[slog.Attr] { + return func(yield func(slog.Attr) bool) { + r.Attrs(func(attr slog.Attr) bool { + return yield(attr) + }) + } +} + +// GetNormalizedAttrs extracts, resolves, filters, and flattens all attributes +// from a slog.Record into a ready-to-process slice of slog.Attr. +// This helper is essential for any custom slog.Handler implementation +// that needs to process the full set of attributes. +func GetNormalizedAttrs(r slog.Record) []slog.Attr { + attrs := attrAll(r) + + return normalizeAttrsSlice(attrs) +} + +// normalizeAttrsSlice contains the core logic for resolving, filtering, and flattening. +// This function must be private as it is a utility for GetNormalizedAttrs. +func normalizeAttrsSlice(attrs iter.Seq[slog.Attr]) []slog.Attr { + var out []slog.Attr + for attr := range attrs { + if attr.Equal(slog.Attr{}) { + continue + } + + v := attr.Value.Resolve() + + switch v.Kind() { + case slog.KindGroup: + // Skip empty group + if len(v.Group()) == 0 { + continue + } + + inner := normalizeAttrsSlice(slices.Values(v.Group())) + + // Flatten anonymous group (Key == "") + if attr.Key == "" { + out = append(out, inner...) + } else { + // Named group, keep as a GroupValue + out = append(out, slog.Attr{ + Key: attr.Key, + Value: slog.GroupValue(inner...), + }) + } + default: + // Regular attribute + out = append(out, slog.Attr{ + Key: attr.Key, + Value: v, + }) + } + } + return out +} + +type chainingAttrHandler struct { + parent slog.Handler + attrs []slog.Attr +} + +var _ slog.Handler = (*chainingAttrHandler)(nil) + +// ChainAttrs stores attributes/groups added via WithAttrs/WithGroup +// and ensures they are applied to the record before passing it to the next handler. +func ChainAttrs(h slog.Handler, attrs []slog.Attr) slog.Handler { + if len(attrs) == 0 { + return h + } + return &chainingAttrHandler{ + parent: h, + attrs: attrs, + } +} + +func (h *chainingAttrHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.parent.Enabled(ctx, level) +} + +func (h *chainingAttrHandler) Handle(ctx context.Context, r slog.Record) error { + newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) + + attrs := slices.Collect(attrAll(r)) + newRecord.AddAttrs(h.attrs...) + newRecord.AddAttrs(attrs...) + + return h.parent.Handle(ctx, newRecord) +} + +func (h *chainingAttrHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return ChainAttrs(h, attrs) + +} + +func (h *chainingAttrHandler) WithGroup(name string) slog.Handler { + return ChainGroup(h, name) +} + +type chainingGroupHandler struct { + parent slog.Handler + group string +} + +// ChainGroup stores attributes/groups added via WithAttrs/WithGroup +// and ensures they are applied to the record before passing it to the next handler. +func ChainGroup(h slog.Handler, name string) slog.Handler { + if name == "" { + return h + } + return &chainingGroupHandler{ + parent: h, + group: name, + } +} + +var _ slog.Handler = (*chainingGroupHandler)(nil) + +func (h *chainingGroupHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.parent.Enabled(ctx, level) +} + +func (h *chainingGroupHandler) Handle(ctx context.Context, r slog.Record) error { + newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) + + newRecord.AddAttrs(slog.Attr{ + Key: h.group, + Value: slog.GroupValue(GetNormalizedAttrs(r)...), + }) + + return h.parent.Handle(ctx, newRecord) +} + +func (h *chainingGroupHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return ChainAttrs(h, attrs) + +} + +func (h *chainingGroupHandler) WithGroup(name string) slog.Handler { + return ChainGroup(h, name) +} diff --git a/logging/config.go b/logging/config.go new file mode 100644 index 0000000..fdf7a6b --- /dev/null +++ b/logging/config.go @@ -0,0 +1,245 @@ +// Package logging provides helpers for context-aware logging using Go's slog package. +// +// It allows attaching loggers to context.Context for structured logging, +// recovering and logging panics with stack traces, +// and provides a simple package-level logger setup function. +package logging + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + + "go.sudomsg.com/kit/net" +) + +type LogFormat int + +const ( + FormatText LogFormat = iota + FormatJSON + FormatMessage +) + +func (s LogFormat) String() string { + switch s { + case FormatJSON: + return "json" + case FormatText: + return "text" + case FormatMessage: + return "message" + default: + return fmt.Sprintf("LogFormat(%d)", s) + } +} + +func (s *LogFormat) Set(str string) error { + switch strings.ToLower(str) { + case "text": + *s = FormatText + case "json": + *s = FormatJSON + case "message": + *s = FormatMessage + default: + return fmt.Errorf("invalid LogFormat %q", str) + } + return nil +} + +func (s LogFormat) AppendText(b []byte) ([]byte, error) { + return append(b, s.String()...), nil +} + +func (s LogFormat) MarshalText() ([]byte, error) { + return s.AppendText(nil) +} + +func (s *LogFormat) UnmarshalText(b []byte) error { + return s.Set(string(b)) +} + +func newEncoder(s LogFormat) (Encoder, error) { + switch s { + case FormatText: + return LogFmtEncoder, nil + case FormatJSON: + return JSONEncoder, nil + case FormatMessage: + return MessageEncoder, nil + default: + return nil, fmt.Errorf("invalid LogFormat: %q", s) + } +} + +// LogSink defines where log output should be written. +// +// It is used to configure the log destination in Setup. +type LogSink int + +const ( + // SinkStderr directs logs to the standard error (os.Stderr). + SinkStderr LogSink = iota + + // SinkStdout directs logs to the standard output (os.Stdout). + SinkStdout + + // SinkFile directs logs to a file specified by the File field in LogConfig. + // + // If configured, the File field must be a valid path for writing logs. + SinkFile + + SinkSyslog //TODO + + SinkHost //TODO +) + +func (s LogSink) String() string { + switch s { + case SinkStdout: + return "stdout" + case SinkStderr: + return "stderr" + case SinkFile: + return "file" + case SinkSyslog: + return "syslog" + default: + return fmt.Sprintf("LogSink(%d)", s) + } +} + +func (s *LogSink) Set(str string) error { + switch strings.ToLower(str) { + case "stdout": + *s = SinkStdout + case "stderr": + *s = SinkStderr + case "file": + *s = SinkFile + case "syslog": + *s = SinkSyslog + default: + return fmt.Errorf("invalid LogSink %q", str) + } + return nil +} + +func (s LogSink) AppendText(b []byte) ([]byte, error) { + return append(b, s.String()...), nil +} + +func (s LogSink) MarshalText() ([]byte, error) { + return s.AppendText(nil) +} + +func (s *LogSink) UnmarshalText(b []byte) error { + return s.Set(string(b)) +} + +// LogConfig holds configuration for the logging setup. +// +// Level specifies the minimum level for logs (e.g., slog.LevelInfo). +// +// Sink specifies the output destination (e.g., SinkStdout, SinkStderr, SinkFile). +// +// Format specifies the log format, either "json" or "text". +// If empty or "json", JSON format is used. +// +// File specifies the output file path when SinkFile is selected. +// It is required if SinkFile is used. +type LogConfig struct { + Level slog.Level `toml:"level"` + Sink LogSink `toml:"sink"` + Format LogFormat `toml:"format"` + File string `toml:"file"` + Tag string `toml:"tag"` + Network net.NetType `toml:"network"` + Address string `toml:"address"` + Facility Facility `toml:"facility"` + ReplaceAttr func(groups []string, a slog.Attr) slog.Attr // Programmatic only +} + +func init() { + Setup(context.Background(), LogConfig{}) // Setup Sensible Defaults +} + +// Setup initializes the default slog.Logger based on the provided LogConfig. +// +// It configures the log output sink, log format, and log level. +// +// Returns an error if the configuration is invalid or if opening the log file fails. +// +// Example: +// +// cfg := LogConfig{ +// Level: slog.LevelInfo, +// Sink: SinkFile, +// Format: FormatJSON, +// File: "/var/log/myapp.log", +// } +// if err := Setup(cfg); err != nil { +// log.Fatalf("failed to configure logging: %v", err) +// } +// +// After a successful call, the global slog logger will log accordingly. +func Setup(ctx context.Context, cfg LogConfig) error { + logger, err := New(ctx, cfg) + if err != nil { + return err + } + slog.SetDefault(logger) + return nil +} + +func newSink(ctx context.Context, cfg LogConfig, encoder Encoder) (Sink, error) { + if (cfg.Sink == SinkStdout || cfg.Sink == SinkStderr) && IsDaemonManaged() { + return NewHostSink(ctx, cfg.Level, "", encoder, cfg.ReplaceAttr) + } + switch cfg.Sink { + case SinkStdout: + return NewWriterSink(os.Stdout, encoder, cfg.Level, cfg.ReplaceAttr), nil + case SinkStderr: + return NewWriterSink(os.Stderr, encoder, cfg.Level, cfg.ReplaceAttr), nil + case SinkFile: + if cfg.File == "" { + return nil, fmt.Errorf("log sink set to 'file' but no file path provided") + } + f, err := os.OpenFile(cfg.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + return NewWriterSink(f, encoder, cfg.Level, cfg.ReplaceAttr), nil + case SinkSyslog: + return DialSyslog(ctx, cfg.Network, cfg.Address, encoder, SyslogOptions{ + Facility: cfg.Facility, + Tag: cfg.Tag, + Level: cfg.Level, + ReplaceAttr: cfg.ReplaceAttr, + }) + case SinkHost: + return NewHostSink(ctx, cfg.Level, cfg.Tag, encoder, cfg.ReplaceAttr) + default: + return nil, fmt.Errorf("invalid LogSink %v", cfg.Sink) + } + +} + +// New is same as Setup but returns the logger instead of setting up the global logger +func New(ctx context.Context, cfg LogConfig) (*slog.Logger, error) { + encoder, err := newEncoder(cfg.Format) + if err != nil { + return nil, err + } + + sink, err := newSink(ctx, cfg, encoder) + if err != nil { + return nil, err + } + handler := NewSinkHandler(sink) + logger := slog.New(handler) + return logger, nil +} diff --git a/logging/encoding.go b/logging/encoding.go new file mode 100644 index 0000000..c4de0c8 --- /dev/null +++ b/logging/encoding.go @@ -0,0 +1,125 @@ +package logging + +import ( + "encoding/json" + "fmt" + "iter" + "log/slog" + "runtime" + "slices" + "strings" +) + +func RecordAll(r slog.Record, replaceAttr func(groups []string, a slog.Attr) slog.Attr) iter.Seq2[[]string, slog.Attr] { + return func(yield func([]string, slog.Attr) bool) { + var walk func([]string, slog.Attr) bool + walk = func(groups []string, a slog.Attr) bool { + if replaceAttr != nil { + a = replaceAttr(groups, a) + } + + if a.Key == "" { + return true + } + + a.Value = a.Value.Resolve() + + if a.Value.Kind() == slog.KindGroup { + newGroups := append(slices.Clone(groups), a.Key) + for _, child := range a.Value.Group() { + if !walk(newGroups, child) { + return false + } + } + return true + } + + return yield(groups, a) + } + + if !r.Time.IsZero() { + if !walk([]string{}, slog.Time(slog.TimeKey, r.Time)) { + return + } + } + + if !walk([]string{}, slog.Any(slog.LevelKey, r.Level)) { + return + } + if !walk([]string{}, slog.String(slog.MessageKey, r.Message)) { + return + } + + if r.PC != 0 { + if !walk([]string{}, slog.Uint64(slog.SourceKey, uint64(r.PC))) { + return + } + } + + r.Attrs(func(attr slog.Attr) bool { + return walk([]string{}, attr) + }) + } +} + +type Encoder func(iter.Seq2[[]string, slog.Attr]) (string, error) + +func LogFmtEncoder(attrs iter.Seq2[[]string, slog.Attr]) (string, error) { + str := []string{} + for groups, attr := range attrs { + if len(groups) == 0 && attr.Key == slog.SourceKey { + pc := uintptr(attr.Value.Uint64()) + fs := runtime.CallersFrames([]uintptr{pc}) + f, _ := fs.Next() + attr.Value = slog.StringValue(fmt.Sprintf("%s:%d", f.File, f.Line)) + } + + str = append(str, fmt.Sprintf("%s=%q", strings.Join(append(groups, attr.Key), "."), attr.Value)) + } + + return strings.Join(str, " "), nil +} + +func ToMap(seq iter.Seq2[[]string, slog.Attr]) map[string]any { + out := make(map[string]any) + for groups, attr := range seq { + current := out + for _, group := range groups { + if next, ok := current[group].(map[string]any); ok { + current = next + } else { + newMap := make(map[string]any) + current[group] = newMap + current = newMap + } + } + current[attr.Key] = attr.Value.Any() + } + return out +} + +func JSONEncoder(attrs iter.Seq2[[]string, slog.Attr]) (string, error) { + m := ToMap(attrs) + if v, ok := m[slog.SourceKey]; ok { + pc := v.(uint64) + fs := runtime.CallersFrames([]uintptr{uintptr(pc)}) + f, _ := fs.Next() + m[slog.SourceKey] = fmt.Sprintf("%s:%d", f.File, f.Line) + } + + b, err := json.Marshal(m) + if err != nil { + return "", err + } + + return string(b), nil +} + +func MessageEncoder(attrs iter.Seq2[[]string, slog.Attr]) (string, error) { + for groups, attr := range attrs { + if len(groups) == 0 && attr.Key == slog.MessageKey { + return attr.Value.String(), nil + } + } + return "", nil +} diff --git a/logging/encoding_test.go b/logging/encoding_test.go new file mode 100644 index 0000000..1e47e4a --- /dev/null +++ b/logging/encoding_test.go @@ -0,0 +1,75 @@ +package logging_test + +import ( + "encoding/json" + "log/slog" + "slices" + "sync" + "testing" + "testing/slogtest" + + "go.sudomsg.com/kit/logging" +) + +type logRecorder struct { + mu sync.Mutex + encoder logging.Encoder + records []string +} + +var _ logging.Sink = &logRecorder{} + +func (h *logRecorder) Append(r slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + + it := logging.RecordAll(r, nil) + + entry, err := h.encoder(it) + if err != nil { + return err + } + + h.records = append(h.records, entry) + return nil +} + +func (h *logRecorder) Enabled(level slog.Level) bool { + return true // Capture all logs +} + +func (h *logRecorder) Records() []string { + h.mu.Lock() + defer h.mu.Unlock() + return slices.Clone(h.records) +} + +func TestJSONEncoder(t *testing.T) { + var lastMock *logRecorder + + slogtest.Run(t, + func(t *testing.T) slog.Handler { + lastMock = &logRecorder{ + encoder: logging.JSONEncoder, + } + return logging.NewSinkHandler(lastMock) + }, + func(t *testing.T) map[string]any { + t.Helper() + + recs := lastMock.Records() + if len(recs) == 0 { + t.Fatalf("no records captured") + } + + rec := recs[len(recs)-1] + + m := make(map[string]any) + if err := json.Unmarshal([]byte(rec), &m); err != nil { + t.Fatalf("%v", err) + } + + return m + }, + ) +} diff --git a/logging/host.go b/logging/host.go new file mode 100644 index 0000000..b58a806 --- /dev/null +++ b/logging/host.go @@ -0,0 +1,23 @@ +//go:build !linux && !windows + +package logging + +import ( + "context" + "log/slog" + + netkit "go.sudomsg.com/kit/net" +) + +func NewHostSink(ctx context.Context, level slog.Level, tag string, encoder Encoder) (Sink, error) { + return DialSyslog(ctx, netkit.NetUnixDatagram, "", encoder, SyslogOptions{ + Tag: tag, + Facility: FacilityUser, + Level: level, + }) +} + + +func IsDaemonManaged() bool { + return false +} diff --git a/logging/host_linux.go b/logging/host_linux.go new file mode 100644 index 0000000..8617cd0 --- /dev/null +++ b/logging/host_linux.go @@ -0,0 +1,211 @@ +//go:build linux + +package logging + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "log/slog" + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + + netkit "go.sudomsg.com/kit/net" + "golang.org/x/sys/unix" +) + +type journalSink struct { + Level slog.Level + Tag string + Conn net.Conn + Encoder Encoder + mu sync.Mutex + ReplaceAttr func(groups []string, a slog.Attr) slog.Attr +} + +var _ Sink = &journalSink{} + +func newJournalSink(ctx context.Context, level slog.Level, tag string, encoder Encoder, replaceAttr func(groups []string, a slog.Attr) slog.Attr) (*journalSink, error) { + conn, err := netkit.Dial(ctx, netkit.NetUnixDatagram, "/run/systemd/journal/socket") + if err != nil { + return nil, fmt.Errorf("failed to connect to journald: %w", err) + } + + if encoder == nil { + encoder = MessageEncoder + } + + if tag == "" { + tag = filepath.Base(os.Args[0]) + } + + if tag == "" { + tag = "kit" + } + + return &journalSink{ + Level: level, + Tag: tag, + Conn: conn, + Encoder: encoder, + ReplaceAttr: replaceAttr, + }, nil +} + +func (s *journalSink) Enabled(level slog.Level) bool { + return s.Level <= level +} + +func (s *journalSink) Append(r slog.Record) error { + fields := map[string]string{ + "SYSLOG_IDENTIFIER": s.Tag, + } + + it := RecordAll(r, func(groups []string, a slog.Attr) slog.Attr { + var key string + switch a.Key { + case slog.LevelKey: + key = "PRIORITY" + val := a.Value.Any().(slog.Leveler).Level() + fields["PRIORTY"] = fmt.Sprintf("%d", mapLevelToSyslog(val)) + return slog.Attr{} + case slog.TimeKey: + fields["SYSLOG_TIMESTAMP"] = a.Value.String() + return slog.Attr{} + case slog.SourceKey: + pc := uintptr(a.Value.Uint64()) + fs := runtime.CallersFrames([]uintptr{pc}) + f, _ := fs.Next() + + fields["CODE_FUNC"] = f.Func.Name() + fields["CODE_FILE"] = f.File + fields["CODE_LINE"] = strconv.Itoa(f.Line) + return slog.Attr{} + default: + key = sanitizeKey(strings.Join(append(groups, a.Key), "_")) + } + + fields[key] = a.Value.String() + return a + }) + + msg, err := s.Encoder(it) + if err != nil { + return fmt.Errorf("encoding failed: %w", err) + } + + fields["MESSAGE"] = msg + return s.write(fields) +} + +func (s *journalSink) write(fields map[string]string) error { + var buf bytes.Buffer + + for k, v := range fields { + if strings.ContainsRune(v, '\n') { + fmt.Fprintln(&buf, k) + binary.Write(&buf, binary.LittleEndian, uint64(len(v))) + fmt.Fprintln(&buf, v) + } else { + fmt.Fprintf(&buf, "%v=%v\n", k, v) + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + if buf.Len() < 65536 { + _, err := s.Conn.Write(buf.Bytes()) + return err + } + + return s.writeMemfd(buf.Bytes()) +} + +func (s *journalSink) writeMemfd(data []byte) error { + fd, err := unix.MemfdCreate("journal_payload", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING) + if err != nil { + return fmt.Errorf("memfd_create failed: %w", err) + } + defer unix.Close(fd) + + _, err = unix.Write(fd, data) + if err != nil { + return err + } + + _, err = unix.FcntlInt(uintptr(fd), unix.F_ADD_SEALS, + unix.F_SEAL_SHRINK|unix.F_SEAL_GROW|unix.F_SEAL_WRITE|unix.F_SEAL_SEAL) + if err != nil { + return err + } + + unixConn, ok := s.Conn.(*net.UnixConn) + if !ok { + return fmt.Errorf("connection is not a unix socket") + } + + rights := unix.UnixRights(fd) + + _, _, err = unixConn.WriteMsgUnix(nil, rights, nil) + return err +} + +func sanitizeKey(k string) string { + k = strings.TrimLeft(strings.ToUpper(k), "_") + var res strings.Builder + for _, r := range k { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { + res.WriteRune(r) + } else { + res.WriteRune('_') + } + } + return res.String() +} + +func isJournalAvailable() bool { + const socketPath = "/run/systemd/journal/socket" + + fi, err := os.Stat(socketPath) + if err != nil { + return false + } + + return fi.Mode()&os.ModeSocket != 0 +} + +func NewHostSink(ctx context.Context, level slog.Level, tag string, encoder Encoder, replaceAttr func(groups []string, a slog.Attr) slog.Attr) (Sink, error) { + if isJournalAvailable() { + return newJournalSink(ctx, level, tag, encoder, replaceAttr) + } + + return DialSyslog(ctx, netkit.NetUnixDatagram, "", encoder, SyslogOptions{ + Tag: tag, + Facility: FacilityUser, + Level: level, + ReplaceAttr: replaceAttr, + }) +} + +func IsDaemonManaged() bool { + stream := os.Getenv("JOURNAL_STREAM") + if stream == "" { + return false + } + + var stat syscall.Stat_t + if err := syscall.Fstat(2, &stat); err != nil { + return false + } + + expected := fmt.Sprintf("%d:%d", stat.Dev, stat.Ino) + return stream == expected +} diff --git a/logging/host_windows.go b/logging/host_windows.go new file mode 100644 index 0000000..6beaf7c --- /dev/null +++ b/logging/host_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package logging + +import ( + "context" + "errors" + "log/slog" +) + + +func NewHostSink(ctx context.Context, level slog.Level, tag string, encoder Encoder) (Sink, error) { + return nil, errors.ErrUnsupported +} + +func IsDaemonManaged() bool { + return false +} diff --git a/logging/log.go b/logging/log.go index dc5b90e..a97d44a 100644 --- a/logging/log.go +++ b/logging/log.go @@ -7,15 +7,9 @@ package logging import ( "context" - "fmt" "log/slog" - "os" "runtime" - "strings" "time" - - "go.sudomsg.com/kit/logging/sinks" - "go.sudomsg.com/kit/net" ) type ctxLoggerKeyType struct{} @@ -116,225 +110,3 @@ func RecoverAndLog(ctx context.Context, msg string, err any) { r.Add("error", err, "stack", stack) handler.Handle(ctx, r) } - -type LogFormat int - -const ( - FormatText LogFormat = iota - FormatJSON - FormatMessage -) - -func (s LogFormat) String() string { - switch s { - case FormatJSON: - return "json" - case FormatText: - return "text" - case FormatMessage: - return "message" - default: - return fmt.Sprintf("LogFormat(%d)", s) - } -} - -func (s *LogFormat) Set(str string) error { - switch strings.ToLower(str) { - case "text": - *s = FormatText - case "json": - *s = FormatJSON - case "message": - *s = FormatMessage - default: - return fmt.Errorf("invalid LogFormat %q", str) - } - return nil -} - -func (s LogFormat) AppendText(b []byte) ([]byte, error) { - return append(b, s.String()...), nil -} - -func (s LogFormat) MarshalText() ([]byte, error) { - return s.AppendText(nil) -} - -func (s *LogFormat) UnmarshalText(b []byte) error { - return s.Set(string(b)) -} - -func newEncoder(s LogFormat) (sinks.Encoder, error) { - switch s { - case FormatText: - return sinks.LogFmtEncoder, nil - case FormatJSON: - return sinks.JSONEncoder, nil - case FormatMessage: - return sinks.MessageEncoder, nil - default: - return nil, fmt.Errorf("invalid LogFormat: %q", s) - } -} - -// LogSink defines where log output should be written. -// -// It is used to configure the log destination in Setup. -type LogSink int - -const ( - // SinkStdout directs logs to the standard output (os.Stdout). - SinkStdout LogSink = iota - - // SinkStderr directs logs to the standard error (os.Stderr). - SinkStderr - - // SinkFile directs logs to a file specified by the File field in LogConfig. - // - // If configured, the File field must be a valid path for writing logs. - SinkFile - - SinkSyslog //TODO - - SinkHost //TODO -) - -func (s LogSink) String() string { - switch s { - case SinkStdout: - return "stdout" - case SinkStderr: - return "stderr" - case SinkFile: - return "file" - case SinkSyslog: - return "syslog" - default: - return fmt.Sprintf("LogSink(%d)", s) - } -} - -func (s *LogSink) Set(str string) error { - switch strings.ToLower(str) { - case "stdout": - *s = SinkStdout - case "stderr": - *s = SinkStderr - case "file": - *s = SinkFile - case "syslog": - *s = SinkSyslog - default: - return fmt.Errorf("invalid LogSink %q", str) - } - return nil -} - -func (s LogSink) AppendText(b []byte) ([]byte, error) { - return append(b, s.String()...), nil -} - -func (s LogSink) MarshalText() ([]byte, error) { - return s.AppendText(nil) -} - -func (s *LogSink) UnmarshalText(b []byte) error { - return s.Set(string(b)) -} - -// LogConfig holds configuration for the logging setup. -// -// Level specifies the minimum level for logs (e.g., slog.LevelInfo). -// -// Sink specifies the output destination (e.g., SinkStdout, SinkStderr, SinkFile). -// -// Format specifies the log format, either "json" or "text". -// If empty or "json", JSON format is used. -// -// File specifies the output file path when SinkFile is selected. -// It is required if SinkFile is used. -type LogConfig struct { - Level slog.Level `toml:"level"` - Sink LogSink `toml:"sink"` - Format LogFormat `toml:"format"` - File string `toml:"file"` - Tag string `toml:"tag"` - Network net.NetType `toml:"network"` - Address string `toml:"address"` - Facility sinks.Facility `toml:"facility"` -} - -func init() { - Setup(context.Background(), LogConfig{}) // Setup Sensible Defaults -} - -// Setup initializes the default slog.Logger based on the provided LogConfig. -// -// It configures the log output sink, log format, and log level. -// -// Returns an error if the configuration is invalid or if opening the log file fails. -// -// Example: -// -// cfg := LogConfig{ -// Level: slog.LevelInfo, -// Sink: SinkFile, -// Format: FormatJSON, -// File: "/var/log/myapp.log", -// } -// if err := Setup(cfg); err != nil { -// log.Fatalf("failed to configure logging: %v", err) -// } -// -// After a successful call, the global slog logger will log accordingly. -func Setup(ctx context.Context, cfg LogConfig) error { - logger, err := New(ctx, cfg) - if err != nil { - return nil - } - slog.SetDefault(logger) - return nil -} - -func newSink(ctx context.Context, cfg LogConfig, encoder sinks.Encoder) (sinks.Sink, error) { - switch cfg.Sink { - case SinkStdout: - return sinks.NewWriterSink(os.Stdout, encoder, cfg.Level), nil - case SinkStderr: - return sinks.NewWriterSink(os.Stderr, encoder, cfg.Level), nil - case SinkFile: - if cfg.File == "" { - return nil, fmt.Errorf("log sink set to 'file' but no file path provided") - } - f, err := os.OpenFile(cfg.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return nil, fmt.Errorf("failed to open log file: %w", err) - } - return sinks.NewWriterSink(f, encoder, cfg.Level), nil - case SinkSyslog: - return sinks.DialSyslog(ctx, cfg.Network, cfg.Address, encoder, sinks.SyslogOptions{ - Facility: cfg.Facility, - Tag: cfg.Tag, - Level: cfg.Level, - }) - default: - return nil, fmt.Errorf("invalid LogSink %v", cfg.Sink) - } - -} - -// New is same as Setup but returns the logger instead of setting up the global logger -func New(ctx context.Context, cfg LogConfig) (*slog.Logger, error) { - encoder, err := newEncoder(cfg.Format) - if err != nil { - return nil, err - } - - sink, err := newSink(ctx, cfg, encoder) - if err != nil { - return nil, err - } - handler := sinks.NewSinkHandler(sink) - logger := slog.New(handler) - return logger, nil -} diff --git a/logging/sink.go b/logging/sink.go new file mode 100644 index 0000000..a1e6748 --- /dev/null +++ b/logging/sink.go @@ -0,0 +1,48 @@ +package logging + +import ( + "context" + "log/slog" +) + +type Sink interface { + Append(r slog.Record) error + Enabled(level slog.Level) bool +} + +type SinkHandler struct { + Sink Sink +} + +var _ slog.Handler = &SinkHandler{} + +func NewSinkHandler(sink Sink) *SinkHandler { + return &SinkHandler{ + Sink: sink, + } +} + +func (h *SinkHandler) Enabled(ctx context.Context, level slog.Level) bool { + if h.Sink == nil { + return false // disable if no sink + } + return h.Sink.Enabled(level) +} + +func (h *SinkHandler) Handle(ctx context.Context, r slog.Record) error { + newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) + + attrs := GetNormalizedAttrs(r) + + newRecord.AddAttrs(attrs...) + return h.Sink.Append(newRecord) +} + +func (h *SinkHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return ChainAttrs(h, attrs) + +} + +func (h *SinkHandler) WithGroup(name string) slog.Handler { + return ChainGroup(h, name) +} diff --git a/logging/sinks/chain.go b/logging/sinks/chain.go deleted file mode 100644 index c01ce5f..0000000 --- a/logging/sinks/chain.go +++ /dev/null @@ -1,152 +0,0 @@ -package sinks - -import ( - "context" - "iter" - "log/slog" - "slices" -) - -func attrAll(r slog.Record) iter.Seq[slog.Attr] { - return func(yield func(slog.Attr) bool) { - r.Attrs(func(attr slog.Attr) bool { - return yield(attr) - }) - } -} - -// GetNormalizedAttrs extracts, resolves, filters, and flattens all attributes -// from a slog.Record into a ready-to-process slice of slog.Attr. -// This helper is essential for any custom slog.Handler implementation -// that needs to process the full set of attributes. -func GetNormalizedAttrs(r slog.Record) []slog.Attr { - attrs := attrAll(r) - - return normalizeAttrsSlice(attrs) -} - -// normalizeAttrsSlice contains the core logic for resolving, filtering, and flattening. -// This function must be private as it is a utility for GetNormalizedAttrs. -func normalizeAttrsSlice(attrs iter.Seq[slog.Attr]) []slog.Attr { - var out []slog.Attr - for attr := range attrs { - if attr.Equal(slog.Attr{}) { - continue - } - - v := attr.Value.Resolve() - - switch v.Kind() { - case slog.KindGroup: - // Skip empty group - if len(v.Group()) == 0 { - continue - } - - inner := normalizeAttrsSlice(slices.Values(v.Group())) - - // Flatten anonymous group (Key == "") - if attr.Key == "" { - out = append(out, inner...) - } else { - // Named group, keep as a GroupValue - out = append(out, slog.Attr{ - Key: attr.Key, - Value: slog.GroupValue(inner...), - }) - } - default: - // Regular attribute - out = append(out, slog.Attr{ - Key: attr.Key, - Value: v, - }) - } - } - return out -} - -type chainingAttrHandler struct { - parent slog.Handler - attrs []slog.Attr -} - -var _ slog.Handler = (*chainingAttrHandler)(nil) - -// ChainAttrs stores attributes/groups added via WithAttrs/WithGroup -// and ensures they are applied to the record before passing it to the next handler. -func ChainAttrs(h slog.Handler, attrs []slog.Attr) slog.Handler { - if len(attrs) == 0 { - return h - } - return &chainingAttrHandler{ - parent: h, - attrs: attrs, - } -} - -func (h *chainingAttrHandler) Enabled(ctx context.Context, level slog.Level) bool { - return h.parent.Enabled(ctx, level) -} - -func (h *chainingAttrHandler) Handle(ctx context.Context, r slog.Record) error { - newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) - - attrs := slices.Collect(attrAll(r)) - newRecord.AddAttrs(h.attrs...) - newRecord.AddAttrs(attrs...) - - return h.parent.Handle(ctx, newRecord) -} - -func (h *chainingAttrHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return ChainAttrs(h, attrs) - -} - -func (h *chainingAttrHandler) WithGroup(name string) slog.Handler { - return ChainGroup(h, name) -} - -type chainingGroupHandler struct { - parent slog.Handler - group string -} - -// ChainGroup stores attributes/groups added via WithAttrs/WithGroup -// and ensures they are applied to the record before passing it to the next handler. -func ChainGroup(h slog.Handler, name string) slog.Handler { - if name == "" { - return h - } - return &chainingGroupHandler{ - parent: h, - group: name, - } -} - -var _ slog.Handler = (*chainingGroupHandler)(nil) - -func (h *chainingGroupHandler) Enabled(ctx context.Context, level slog.Level) bool { - return h.parent.Enabled(ctx, level) -} - -func (h *chainingGroupHandler) Handle(ctx context.Context, r slog.Record) error { - newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) - - newRecord.AddAttrs(slog.Attr{ - Key: h.group, - Value: slog.GroupValue(GetNormalizedAttrs(r)...), - }) - - return h.parent.Handle(ctx, newRecord) -} - -func (h *chainingGroupHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return ChainAttrs(h, attrs) - -} - -func (h *chainingGroupHandler) WithGroup(name string) slog.Handler { - return ChainGroup(h, name) -} diff --git a/logging/sinks/encoding.go b/logging/sinks/encoding.go deleted file mode 100644 index ae58a0e..0000000 --- a/logging/sinks/encoding.go +++ /dev/null @@ -1,125 +0,0 @@ -package sinks - -import ( - "encoding/json" - "fmt" - "iter" - "log/slog" - "runtime" - "slices" - "strings" -) - -func RecordAll(r slog.Record, replaceAttr func(groups []string, a slog.Attr) slog.Attr) iter.Seq2[[]string, slog.Attr] { - return func(yield func([]string, slog.Attr) bool) { - var walk func([]string, slog.Attr) bool - walk = func(groups []string, a slog.Attr) bool { - if replaceAttr != nil { - a = replaceAttr(groups, a) - } - - if a.Key == "" { - return true - } - - a.Value = a.Value.Resolve() - - if a.Value.Kind() == slog.KindGroup { - newGroups := append(slices.Clone(groups), a.Key) - for _, child := range a.Value.Group() { - if !walk(newGroups, child) { - return false - } - } - return true - } - - return yield(groups, a) - } - - if !r.Time.IsZero() { - if !walk([]string{}, slog.Time(slog.TimeKey, r.Time)) { - return - } - } - - if !walk([]string{}, slog.Any(slog.LevelKey, r.Level)) { - return - } - if !walk([]string{}, slog.String(slog.MessageKey, r.Message)) { - return - } - - if r.PC != 0 { - if !walk([]string{}, slog.Uint64(slog.SourceKey, uint64(r.PC))) { - return - } - } - - r.Attrs(func(attr slog.Attr) bool { - return walk([]string{}, attr) - }) - } -} - -type Encoder func(iter.Seq2[[]string, slog.Attr]) (string, error) - -func LogFmtEncoder(attrs iter.Seq2[[]string, slog.Attr]) (string, error) { - str := []string{} - for groups, attr := range attrs { - if len(groups) == 0 && attr.Key == slog.SourceKey { - pc := uintptr(attr.Value.Uint64()) - fs := runtime.CallersFrames([]uintptr{pc}) - f, _ := fs.Next() - attr.Value = slog.StringValue(fmt.Sprintf("%s:%d", f.File, f.Line)) - } - - str = append(str, fmt.Sprintf("%s=%q", strings.Join(append(groups, attr.Key), "."), attr.Value)) - } - - return strings.Join(str, " "), nil -} - -func ToMap(seq iter.Seq2[[]string, slog.Attr]) map[string]any { - out := make(map[string]any) - for groups, attr := range seq { - current := out - for _, group := range groups { - if next, ok := current[group].(map[string]any); ok { - current = next - } else { - newMap := make(map[string]any) - current[group] = newMap - current = newMap - } - } - current[attr.Key] = attr.Value.Any() - } - return out -} - -func JSONEncoder(attrs iter.Seq2[[]string, slog.Attr]) (string, error) { - m := ToMap(attrs) - if v, ok := m[slog.SourceKey]; ok { - pc := v.(uint64) - fs := runtime.CallersFrames([]uintptr{uintptr(pc)}) - f, _ := fs.Next() - m[slog.SourceKey] = fmt.Sprintf("%s:%d", f.File, f.Line) - } - - b, err := json.Marshal(m) - if err != nil { - return "", err - } - - return string(b), nil -} - -func MessageEncoder(attrs iter.Seq2[[]string, slog.Attr]) (string, error) { - for groups, attr := range attrs { - if len(groups) == 0 && attr.Key == slog.MessageKey { - return attr.Value.String(), nil - } - } - return "", nil -} diff --git a/logging/sinks/encoding_test.go b/logging/sinks/encoding_test.go deleted file mode 100644 index 09a668a..0000000 --- a/logging/sinks/encoding_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package sinks_test - -import ( - "encoding/json" - "log/slog" - "slices" - "sync" - "testing" - "testing/slogtest" - - "go.sudomsg.com/kit/logging/sinks" -) - -type logRecorder struct { - mu sync.Mutex - encoder sinks.Encoder - records []string -} - -var _ sinks.Sink = &logRecorder{} - -func (h *logRecorder) Append(r slog.Record) error { - h.mu.Lock() - defer h.mu.Unlock() - - it := sinks.RecordAll(r, nil) - - entry, err := h.encoder(it) - if err != nil { - return err - } - - h.records = append(h.records, entry) - return nil -} - -func (h *logRecorder) Enabled(level slog.Level) bool { - return true // Capture all logs -} - -func (h *logRecorder) Records() []string { - h.mu.Lock() - defer h.mu.Unlock() - return slices.Clone(h.records) -} - -func TestJSONEncoder(t *testing.T) { - var lastMock *logRecorder - - slogtest.Run(t, - func(t *testing.T) slog.Handler { - lastMock = &logRecorder{ - encoder: sinks.JSONEncoder, - } - return sinks.NewSinkHandler(lastMock) - }, - func(t *testing.T) map[string]any { - t.Helper() - - recs := lastMock.Records() - if len(recs) == 0 { - t.Fatalf("no records captured") - } - - rec := recs[len(recs)-1] - - m := make(map[string]any) - if err := json.Unmarshal([]byte(rec), &m); err != nil { - t.Fatalf("%v", err) - } - - return m - }, - ) -} diff --git a/logging/sinks/sink.go b/logging/sinks/sink.go deleted file mode 100644 index 058b128..0000000 --- a/logging/sinks/sink.go +++ /dev/null @@ -1,48 +0,0 @@ -package sinks - -import ( - "context" - "log/slog" -) - -type Sink interface { - Append(r slog.Record) error - Enabled(level slog.Level) bool -} - -type SinkHandler struct { - Sink Sink -} - -var _ slog.Handler = &SinkHandler{} - -func NewSinkHandler(sink Sink) *SinkHandler { - return &SinkHandler{ - Sink: sink, - } -} - -func (h *SinkHandler) Enabled(ctx context.Context, level slog.Level) bool { - if h.Sink == nil { - return false // disable if no sink - } - return h.Sink.Enabled(level) -} - -func (h *SinkHandler) Handle(ctx context.Context, r slog.Record) error { - newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) - - attrs := GetNormalizedAttrs(r) - - newRecord.AddAttrs(attrs...) - return h.Sink.Append(newRecord) -} - -func (h *SinkHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return ChainAttrs(h, attrs) - -} - -func (h *SinkHandler) WithGroup(name string) slog.Handler { - return ChainGroup(h, name) -} diff --git a/logging/sinks/syslog.go b/logging/sinks/syslog.go deleted file mode 100644 index 0c2e429..0000000 --- a/logging/sinks/syslog.go +++ /dev/null @@ -1,155 +0,0 @@ -package sinks - -import ( - "context" - "fmt" - "io" - "log/slog" - "os" - "path/filepath" - "time" - - "go.sudomsg.com/kit/net" -) - -type Facility int - -const ( - FacilityKern Facility = 0 // kernel messages - FacilityUser Facility = 1 // user-level messages - FacilityMail Facility = 2 // mail system - FacilityDaemon Facility = 3 // system daemons - FacilityAuth Facility = 4 // security/authorization messages - FacilitySyslog Facility = 5 // messages generated internally by syslogd - FacilityLPR Facility = 6 // line printer subsystem - FacilityNews Facility = 7 // network news subsystem - FacilityUUCP Facility = 8 // UUCP subsystem - FacilityCron Facility = 9 // clock daemon - FacilityAuthPriv Facility = 10 // security/authorization messages (private) - FacilityFTP Facility = 11 // FTP daemon - FacilityLocal0 Facility = 16 - FacilityLocal7 Facility = 23 -) - -type SyslogSink struct { - Writer io.Writer - Facility Facility - Tag string - Hostname string - Level slog.Level - Encoder Encoder - EmitHostname bool -} - -var _ Sink = &SyslogSink{} - -type SyslogOptions struct { - Facility Facility - Tag string - Hostname string - Level slog.Leveler -} - -// DialSyslog creates a Sink connected to a local or remote syslog daemon. -// raddr: "/dev/log", "localhost:514", etc. -func DialSyslog(ctx context.Context, network net.NetType, raddr string, enc Encoder, options SyslogOptions) (*SyslogSink, error) { - if raddr == "" { - network, raddr = resolveLocalEndpoint() - } - - conn, err := net.Dial(ctx, network, raddr) - if err != nil { - return nil, fmt.Errorf("syslog dial failed: %w", err) - } - - isLocal := (network == net.NetUnixDatagram || network == net.NetUnix) - - sink := NewSyslogSink(conn, enc, options) - sink.EmitHostname = !isLocal - return sink, nil -} - -func NewSyslogSink(w io.Writer, enc Encoder, options SyslogOptions) *SyslogSink { - if options.Hostname == "" { - options.Hostname, _ = os.Hostname() - } - if options.Hostname == "" { - options.Hostname = "localhost" - } - - if options.Tag == "" { - options.Tag = filepath.Base(os.Args[0]) - } - - if options.Tag == "" { - options.Tag = "kit" - } - - if len(options.Tag) > 32 { - options.Tag = options.Tag[:32] - } - - return &SyslogSink{ - Writer: w, - Encoder: enc, - Tag: options.Tag, - Facility: options.Facility, - Hostname: options.Hostname, - } -} - -func (s *SyslogSink) Enabled(level slog.Level) bool { - return s.Level <= level -} - -func (s *SyslogSink) Append(r slog.Record) error { - level := slog.Leveler(s.Level) - var ts time.Time - it := RecordAll(r, func(groups []string, a slog.Attr) slog.Attr { - if len(groups) == 0 { - if a.Key == slog.TimeKey { - ts = a.Value.Time() - return slog.Attr{} - } - - if a.Key == slog.LevelKey { - level = a.Value.Any().(slog.Leveler) - return slog.Attr{} - } - } - return a - }) - out, err := s.Encoder(it) - if err != nil { - return fmt.Errorf("failed to encode: %w", err) - } - - pri := int(s.Facility)*8 + mapLevelToSyslog(level.Level()) - - // Note: RFC 3164 uses a space for leading zeros in days (e.g., "Jan 2") - t := "" - if !ts.IsZero() { - t = r.Time.Format("Jan _2 15:04:05") - } - hostname := "" - if s.EmitHostname { - hostname = s.Hostname - } - packet := fmt.Sprintf("<%d>%s %s %s: %s", pri, t, hostname, s.Tag, out) - - _, err = fmt.Fprint(s.Writer, packet) - return err -} - -func mapLevelToSyslog(l slog.Level) int { - switch { - case l >= slog.LevelError: - return 3 - case l >= slog.LevelWarn: - return 4 - case l >= slog.LevelInfo: - return 6 - default: - return 7 - } -} diff --git a/logging/sinks/syslog_stub.go b/logging/sinks/syslog_stub.go deleted file mode 100644 index 74aac9f..0000000 --- a/logging/sinks/syslog_stub.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !unix - -package sinks - -import "go.sudomsg.com/kit/net" - -func resolveLocalEndpoint() (net.NetType, string) { - return net.NetUDP, "localhost:514" -} diff --git a/logging/sinks/syslog_unix.go b/logging/sinks/syslog_unix.go deleted file mode 100644 index f90a065..0000000 --- a/logging/sinks/syslog_unix.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build unix -package sinks - -import ( - "os" - - "go.sudomsg.com/kit/net" -) - -var defaultSyslogPaths = []string{"/dev/log", "/var/run/log", "/var/run/syslog"} - -func resolveLocalEndpoint() (net.NetType, string) { - for _, path := range defaultSyslogPaths { - if _, err := os.Stat(path); err == nil { - return net.NetUnixDatagram, path - } - } - // Fallback if no local socket is found - return net.NetUDP, "localhost:514" -} diff --git a/logging/sinks/writer.go b/logging/sinks/writer.go deleted file mode 100644 index ed18c97..0000000 --- a/logging/sinks/writer.go +++ /dev/null @@ -1,44 +0,0 @@ -package sinks - -import ( - "fmt" - "io" - "log/slog" - "sync" -) - -type WriterSink struct { - Writer io.Writer - mu sync.Mutex - Encoder Encoder - Level slog.Level - ReplaceAttr func(groups []string, a slog.Attr) slog.Attr -} - -var _ Sink = &WriterSink{} - -func NewWriterSink(w io.Writer, encoder Encoder, level slog.Leveler) *WriterSink { - return &WriterSink{ - Writer: w, - Encoder: encoder, - Level: level.Level(), - } -} - -func (s *WriterSink) Enabled(level slog.Level) bool { - return s.Level <= level -} - -func (s *WriterSink) Append(r slog.Record) error { - it := RecordAll(r, s.ReplaceAttr) - out, err := s.Encoder(it) - if err != nil { - return fmt.Errorf("failed to encode: %w", err) - } - - s.mu.Lock() - defer s.mu.Unlock() - - _, err = fmt.Fprintln(s.Writer, out) - return err -} diff --git a/logging/sinks/writer_test.go b/logging/sinks/writer_test.go deleted file mode 100644 index 0c8f79e..0000000 --- a/logging/sinks/writer_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package sinks_test - -import ( - "bytes" - "encoding/json" - "log/slog" - "testing" - "testing/slogtest" - - "go.sudomsg.com/kit/logging/sinks" -) - -func TestWriterHandler(t *testing.T) { - var buf bytes.Buffer - - slogtest.Run(t, - func(t *testing.T) slog.Handler { - buf.Reset() - sink := sinks.NewWriterSink(&buf, sinks.JSONEncoder, slog.LevelDebug) - return sinks.NewSinkHandler(sink) - }, - func(t *testing.T) map[string]any { - t.Helper() - - if buf.Len() == 0 { - t.Fatal("buffer is empty; no log was written") - } - b := buf.Bytes() - r := bytes.NewReader(b) - dec := json.NewDecoder(r) - m := make(map[string]any) - if err := dec.Decode(&m); err != nil { - t.Fatalf("%v", err) - } - - return m - }, - ) -} diff --git a/logging/syslog.go b/logging/syslog.go new file mode 100644 index 0000000..00e3738 --- /dev/null +++ b/logging/syslog.go @@ -0,0 +1,166 @@ +package logging + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "sync" + "time" + + "go.sudomsg.com/kit/net" +) + +type Facility int + +const ( + FacilityKern Facility = 0 // kernel messages + FacilityUser Facility = 1 // user-level messages + FacilityMail Facility = 2 // mail system + FacilityDaemon Facility = 3 // system daemons + FacilityAuth Facility = 4 // security/authorization messages + FacilitySyslog Facility = 5 // messages generated internally by syslogd + FacilityLPR Facility = 6 // line printer subsystem + FacilityNews Facility = 7 // network news subsystem + FacilityUUCP Facility = 8 // UUCP subsystem + FacilityCron Facility = 9 // clock daemon + FacilityAuthPriv Facility = 10 // security/authorization messages (private) + FacilityFTP Facility = 11 // FTP daemon + FacilityLocal0 Facility = 16 + FacilityLocal7 Facility = 23 +) + +type SyslogSink struct { + Writer io.Writer + Facility Facility + Tag string + Hostname string + Level slog.Level + Encoder Encoder + EmitHostname bool + ReplaceAttr func(groups []string, a slog.Attr) slog.Attr + mu sync.Mutex +} + +var _ Sink = &SyslogSink{} + +type SyslogOptions struct { + Facility Facility + Tag string + Hostname string + Level slog.Leveler + ReplaceAttr func(groups []string, a slog.Attr) slog.Attr +} + +// DialSyslog creates a Sink connected to a local or remote syslog daemon. +// raddr: "/dev/log", "localhost:514", etc. +func DialSyslog(ctx context.Context, network net.NetType, raddr string, enc Encoder, options SyslogOptions) (*SyslogSink, error) { + if raddr == "" { + network, raddr = resolveLocalEndpoint() + } + + conn, err := net.Dial(ctx, network, raddr) + if err != nil { + return nil, fmt.Errorf("syslog dial failed: %w", err) + } + + isLocal := (network == net.NetUnixDatagram || network == net.NetUnix) + + sink := NewSyslogSink(conn, enc, options) + sink.EmitHostname = !isLocal + return sink, nil +} + +func NewSyslogSink(w io.Writer, enc Encoder, options SyslogOptions) *SyslogSink { + if options.Hostname == "" { + options.Hostname, _ = os.Hostname() + } + if options.Hostname == "" { + options.Hostname = "localhost" + } + + if options.Tag == "" { + options.Tag = filepath.Base(os.Args[0]) + } + + if options.Tag == "" { + options.Tag = "kit" + } + + if len(options.Tag) > 32 { + options.Tag = options.Tag[:32] + } + + return &SyslogSink{ + Writer: w, + Encoder: enc, + Tag: options.Tag, + Facility: options.Facility, + Hostname: options.Hostname, + ReplaceAttr: options.ReplaceAttr, + } +} + +func (s *SyslogSink) Enabled(level slog.Level) bool { + return s.Level <= level +} + +func (s *SyslogSink) Append(r slog.Record) error { + level := slog.Leveler(s.Level) + var ts time.Time + it := RecordAll(r, func(groups []string, attr slog.Attr) slog.Attr { + if s.ReplaceAttr != nil { + attr = s.ReplaceAttr(groups, attr) + } + if len(groups) == 0 { + if attr.Key == slog.TimeKey { + ts = attr.Value.Time() + return slog.Attr{} + } + + if attr.Key == slog.LevelKey { + level = attr.Value.Any().(slog.Leveler) + return slog.Attr{} + } + } + return attr + }) + out, err := s.Encoder(it) + if err != nil { + return fmt.Errorf("failed to encode: %w", err) + } + + pri := int(s.Facility)*8 + mapLevelToSyslog(level.Level()) + + // Note: RFC 3164 uses a space for leading zeros in days (e.g., "Jan 2") + t := "" + if !ts.IsZero() { + t = r.Time.Format("Jan _2 15:04:05") + } + hostname := "" + if s.EmitHostname { + hostname = s.Hostname + } + packet := fmt.Sprintf("<%d>%s %s %s: %s", pri, t, hostname, s.Tag, out) + + s.mu.Lock() + defer s.mu.Unlock() + + _, err = fmt.Fprint(s.Writer, packet) + return err +} + +func mapLevelToSyslog(l slog.Level) int { + switch { + case l >= slog.LevelError: + return 3 + case l >= slog.LevelWarn: + return 4 + case l >= slog.LevelInfo: + return 6 + default: + return 7 + } +} diff --git a/logging/syslog_stub.go b/logging/syslog_stub.go new file mode 100644 index 0000000..f5b3e77 --- /dev/null +++ b/logging/syslog_stub.go @@ -0,0 +1,9 @@ +//go:build !unix + +package logging + +import "go.sudomsg.com/kit/net" + +func resolveLocalEndpoint() (net.NetType, string) { + return net.NetUDP, "localhost:514" +} diff --git a/logging/syslog_unix.go b/logging/syslog_unix.go new file mode 100644 index 0000000..e1ddb69 --- /dev/null +++ b/logging/syslog_unix.go @@ -0,0 +1,20 @@ +//go:build unix +package logging + +import ( + "os" + + "go.sudomsg.com/kit/net" +) + +var defaultSyslogPaths = []string{"/dev/log", "/var/run/log", "/var/run/syslog"} + +func resolveLocalEndpoint() (net.NetType, string) { + for _, path := range defaultSyslogPaths { + if _, err := os.Stat(path); err == nil { + return net.NetUnixDatagram, path + } + } + // Fallback if no local socket is found + return net.NetUDP, "localhost:514" +} diff --git a/logging/test/handler.go b/logging/test/handler.go index 210893b..a005987 100644 --- a/logging/test/handler.go +++ b/logging/test/handler.go @@ -9,7 +9,7 @@ import ( "sync" "testing" - "go.sudomsg.com/kit/logging/sinks" + "go.sudomsg.com/kit/logging" ) type logRecorder struct { @@ -17,15 +17,15 @@ type logRecorder struct { records []map[string]any } -var _ sinks.Sink = &logRecorder{} +var _ logging.Sink = &logRecorder{} func (h *logRecorder) Append(r slog.Record) error { h.mu.Lock() defer h.mu.Unlock() - it := sinks.RecordAll(r, nil) + it := logging.RecordAll(r, nil) - h.records = append(h.records, sinks.ToMap(it)) + h.records = append(h.records, logging.ToMap(it)) return nil } @@ -46,7 +46,7 @@ func (h *logRecorder) Records() []map[string]any { // // All recorded logs can be retrieved with the Records method. type MockHandler struct { - *sinks.SinkHandler + *logging.SinkHandler recorder *logRecorder } @@ -59,7 +59,7 @@ func NewMockLogHandler(tb testing.TB) *MockHandler { rec := &logRecorder{} return &MockHandler{ - SinkHandler: sinks.NewSinkHandler(rec), + SinkHandler: logging.NewSinkHandler(rec), recorder: rec, } } diff --git a/logging/writer.go b/logging/writer.go new file mode 100644 index 0000000..6f1ee34 --- /dev/null +++ b/logging/writer.go @@ -0,0 +1,45 @@ +package logging + +import ( + "fmt" + "io" + "log/slog" + "sync" +) + +type WriterSink struct { + Writer io.Writer + mu sync.Mutex + Encoder Encoder + Level slog.Level + ReplaceAttr func(groups []string, a slog.Attr) slog.Attr +} + +var _ Sink = &WriterSink{} + +func NewWriterSink(w io.Writer, encoder Encoder, level slog.Leveler, replaceAttr func(groups []string, a slog.Attr) slog.Attr) *WriterSink { + return &WriterSink{ + Writer: w, + Encoder: encoder, + Level: level.Level(), + ReplaceAttr: replaceAttr, + } +} + +func (s *WriterSink) Enabled(level slog.Level) bool { + return s.Level <= level +} + +func (s *WriterSink) Append(r slog.Record) error { + it := RecordAll(r, s.ReplaceAttr) + out, err := s.Encoder(it) + if err != nil { + return fmt.Errorf("failed to encode: %w", err) + } + + s.mu.Lock() + defer s.mu.Unlock() + + _, err = fmt.Fprintln(s.Writer, out) + return err +} diff --git a/logging/writer_test.go b/logging/writer_test.go new file mode 100644 index 0000000..3e2897d --- /dev/null +++ b/logging/writer_test.go @@ -0,0 +1,39 @@ +package logging_test + +import ( + "bytes" + "encoding/json" + "log/slog" + "testing" + "testing/slogtest" + + "go.sudomsg.com/kit/logging" +) + +func TestWriterHandler(t *testing.T) { + var buf bytes.Buffer + + slogtest.Run(t, + func(t *testing.T) slog.Handler { + buf.Reset() + sink := logging.NewWriterSink(&buf, logging.JSONEncoder, slog.LevelDebug, nil) + return logging.NewSinkHandler(sink) + }, + func(t *testing.T) map[string]any { + t.Helper() + + if buf.Len() == 0 { + t.Fatal("buffer is empty; no log was written") + } + b := buf.Bytes() + r := bytes.NewReader(b) + dec := json.NewDecoder(r) + m := make(map[string]any) + if err := dec.Decode(&m); err != nil { + t.Fatalf("%v", err) + } + + return m + }, + ) +} -- cgit v1.3