diff options
| author | Marc Pervaz Boocha <mboocha@sudomsg.com> | 2026-02-24 00:27:21 +0530 |
|---|---|---|
| committer | Marc Pervaz Boocha <mboocha@sudomsg.com> | 2026-02-24 00:27:21 +0530 |
| commit | 710704b6ce5ad2b62fc4a44f05b5f53ef924834e (patch) | |
| tree | 85fe7f0e565cb401ae540377ef0d34fcf4ab6131 | |
| parent | go fix 1.26 (diff) | |
| download | kit-710704b6ce5ad2b62fc4a44f05b5f53ef924834e.tar kit-710704b6ce5ad2b62fc4a44f05b5f53ef924834e.tar.gz kit-710704b6ce5ad2b62fc4a44f05b5f53ef924834e.tar.bz2 kit-710704b6ce5ad2b62fc4a44f05b5f53ef924834e.tar.lz kit-710704b6ce5ad2b62fc4a44f05b5f53ef924834e.tar.xz kit-710704b6ce5ad2b62fc4a44f05b5f53ef924834e.tar.zst kit-710704b6ce5ad2b62fc4a44f05b5f53ef924834e.zip | |
Added more logging stuff
| -rw-r--r-- | logging/log.go | 53 | ||||
| -rw-r--r-- | logging/sinks/chain.go (renamed from logging/chain.go) | 76 | ||||
| -rw-r--r-- | logging/sinks/encoding.go | 140 | ||||
| -rw-r--r-- | logging/sinks/encoding_test.go | 43 | ||||
| -rw-r--r-- | logging/sinks/sink.go | 50 | ||||
| -rw-r--r-- | logging/sinks/writer.go | 42 | ||||
| -rw-r--r-- | logging/sinks/writer_test.go | 36 | ||||
| -rw-r--r-- | logging/test/handler.go | 43 | ||||
| -rw-r--r-- | logging/test/handler_test.go | 41 |
9 files changed, 424 insertions, 100 deletions
diff --git a/logging/log.go b/logging/log.go index 944b817..9f94bc0 100644 --- a/logging/log.go +++ b/logging/log.go @@ -8,12 +8,13 @@ package logging import ( "context" "fmt" - "io" "log/slog" "os" "runtime" "strings" "time" + + "go.sudomsg.com/kit/logging/sinks" ) type ctxLoggerKeyType struct{} @@ -131,6 +132,8 @@ const ( // // If configured, the File field must be a valid path for writing logs. SinkFile + + SinkSyslog ) func (s LogSink) String() string { @@ -141,6 +144,8 @@ func (s LogSink) String() string { return "stderr" case SinkFile: return "file" + case SinkSyslog: + return "syslog" default: return fmt.Sprintf("LogSink(%d)", s) } @@ -154,6 +159,8 @@ func (s *LogSink) Set(str string) error { *s = SinkStderr case "file": *s = SinkFile + case "syslog": + *s = SinkSyslog default: return fmt.Errorf("invalid LogSink %q", str) } @@ -175,11 +182,9 @@ func (s *LogSink) UnmarshalText(b []byte) error { type LogFormat int const ( - // SinkStdout directs logs to the standard output (os.Stdout). FormatText LogFormat = iota - - // SinkStderr directs logs to the standard error (os.Stderr). FormatJSON + FormatMessage ) func (s LogFormat) String() string { @@ -188,6 +193,8 @@ func (s LogFormat) String() string { return "json" case FormatText: return "text" + case FormatMessage: + return "message" default: return fmt.Sprintf("LogSink(%d)", s) } @@ -199,6 +206,8 @@ func (s *LogFormat) Set(str string) error { *s = FormatText case "json": *s = FormatJSON + case "message": + *s = FormatMessage default: return fmt.Errorf("invalid LogSink %q", str) } @@ -267,15 +276,26 @@ func Setup(cfg LogConfig) error { return nil } -// Same as Setup but returns the logger instead of setting up the global logger +// New is same as Setup but returns the logger instead of setting up the global logger func New(cfg LogConfig) (*slog.Logger, error) { - var w io.Writer + var encoder func(slog.Record, func(string, slog.Value) (slog.Value, bool)) (string, error) + switch cfg.Format { + case FormatText: + encoder = sinks.LogFmtEncoder + case FormatJSON: + encoder = sinks.JSONEncoder + case FormatMessage: + encoder = sinks.MessageEncoder + default: + return nil, fmt.Errorf("invalid LogFormat: %v", cfg.Format) + } + var sink sinks.Sink switch cfg.Sink { case SinkStdout: - w = os.Stdout + sink = sinks.NewWriterSink(os.Stdout, encoder, cfg.Level) case SinkStderr: - w = os.Stderr + sink = sinks.NewWriterSink(os.Stderr, encoder, cfg.Level) case SinkFile: if cfg.File == "" { return nil, fmt.Errorf("log sink set to 'file' but no file path provided") @@ -284,25 +304,12 @@ func New(cfg LogConfig) (*slog.Logger, error) { if err != nil { return nil, fmt.Errorf("failed to open log file: %w", err) } - w = f + sink = sinks.NewWriterSink(f, encoder, cfg.Level) default: return nil, fmt.Errorf("invalid LogSink %v", cfg.Sink) } - var handler slog.Handler - opts := &slog.HandlerOptions{ - Level: cfg.Level, - } - - switch cfg.Format { - case FormatText: - handler = slog.NewTextHandler(w, opts) - case FormatJSON: - handler = slog.NewJSONHandler(w, opts) - default: - return nil, fmt.Errorf("invalid LogFormat: %v", cfg.Format) - } - + handler := sinks.NewSinkHandler(sink) logger := slog.New(handler) return logger, nil } diff --git a/logging/chain.go b/logging/sinks/chain.go index 87d7b2d..c01ce5f 100644 --- a/logging/chain.go +++ b/logging/sinks/chain.go @@ -1,4 +1,4 @@ -package logging +package sinks import ( "context" @@ -66,69 +66,87 @@ func normalizeAttrsSlice(attrs iter.Seq[slog.Attr]) []slog.Attr { return out } -// ChainingHandler stores attributes/groups added via WithAttrs/WithGroup -// and ensures they are applied to the record before passing it to the next handler. -type ChainingHandler struct { +type chainingAttrHandler struct { parent slog.Handler - group string 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 &ChainingHandler{ + 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 &ChainingHandler{ + return &chainingGroupHandler{ parent: h, group: name, } } -var _ slog.Handler = (*ChainingHandler)(nil) +var _ slog.Handler = (*chainingGroupHandler)(nil) -func (h *ChainingHandler) Enabled(ctx context.Context, level slog.Level) bool { +func (h *chainingGroupHandler) Enabled(ctx context.Context, level slog.Level) bool { return h.parent.Enabled(ctx, level) } -func (h *ChainingHandler) Handle(ctx context.Context, r slog.Record) error { - attrs := slices.Clone(h.attrs) +func (h *chainingGroupHandler) Handle(ctx context.Context, r slog.Record) error { + newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) - r.Attrs(func(attr slog.Attr) bool { - attrs = append(attrs, attr) - return true + newRecord.AddAttrs(slog.Attr{ + Key: h.group, + Value: slog.GroupValue(GetNormalizedAttrs(r)...), }) - var outputAttrs []slog.Attr - if h.group != "" { - outputAttrs = append(outputAttrs, slog.Attr{ - Key: h.group, - Value: slog.GroupValue(attrs...), - }) - } else { - outputAttrs = attrs - } - - newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) - newRecord.AddAttrs(outputAttrs...) - return h.parent.Handle(ctx, newRecord) } -func (h *ChainingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { +func (h *chainingGroupHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return ChainAttrs(h, attrs) } -func (h *ChainingHandler) WithGroup(name string) slog.Handler { +func (h *chainingGroupHandler) WithGroup(name string) slog.Handler { return ChainGroup(h, name) } diff --git a/logging/sinks/encoding.go b/logging/sinks/encoding.go new file mode 100644 index 0000000..e1c558a --- /dev/null +++ b/logging/sinks/encoding.go @@ -0,0 +1,140 @@ +package sinks + +import ( + "encoding/json" + "fmt" + "iter" + "log/slog" + "runtime" + "strings" +) + +func RecordAll(r slog.Record) iter.Seq2[string, slog.Value] { + return func(yield func(string, slog.Value) bool) { + if !r.Time.IsZero() { + if !yield(slog.TimeKey, slog.TimeValue(r.Time)) { + return + } + } + + if !yield(slog.LevelKey, slog.AnyValue(r.Level)) { + return + } + if !yield(slog.MessageKey, slog.StringValue(r.Message)) { + return + } + + if r.PC != 0 { + if !yield(slog.SourceKey, slog.Uint64Value(uint64(r.PC))) { + return + } + } + + var walkAttrs func(attrs []slog.Attr, prefix string) bool + + walkAttrs = func(attrs []slog.Attr, prefix string) bool { + for _, attr := range attrs { + key := attr.Key + if prefix != "" { + key = prefix + "." + key + } + + if attr.Value.Kind() == slog.KindGroup { + if !walkAttrs(attr.Value.Group(), key) { + return false + } + } else { + if !yield(key, attr.Value) { + return false + } + } + } + return true + } + r.Attrs(func(attr slog.Attr) bool { + return walkAttrs([]slog.Attr{attr}, "") + }) + } +} + +func LogFmtEncoder(rec slog.Record, ReplaceAttr func(string, slog.Value) (slog.Value, bool)) (string, error) { + str := []string{} + for key, value := range RecordAll(rec) { + if ReplaceAttr != nil { + ok := true + value, ok = ReplaceAttr(key, value) + if !ok { + continue + } + } + if key == slog.SourceKey { + fs := runtime.CallersFrames([]uintptr{rec.PC}) + f, _ := fs.Next() + value = slog.StringValue(fmt.Sprintf("%s:%d", f.File, f.Line)) + } + + str = append(str, fmt.Sprintf("%s=%q", key, value)) + } + + return strings.Join(str, " "), nil +} + +func insertIntoMap(m map[string]any, dottedKey string, value slog.Value) { + parts := strings.Split(dottedKey, ".") + key := parts[len(parts)-1] + parts = parts[:len(parts)-1] + currentMap := m + + for _, part := range parts { + if _, ok := currentMap[part]; !ok { + currentMap[part] = make(map[string]any) + } + + if nested, ok := currentMap[part].(map[string]any); ok { + currentMap = nested + } else { + currentMap[part] = make(map[string]any) + currentMap = currentMap[part].(map[string]any) + } + } + currentMap[key] = value.Any() +} + +func JSONEncoder(rec slog.Record, ReplaceAttr func(string, slog.Value) (slog.Value, bool)) (string, error) { + m := make(map[string]any) + for key, value := range RecordAll(rec) { + if ReplaceAttr != nil { + ok := true + value, ok = ReplaceAttr(key, value) + if !ok { + continue + } + } + insertIntoMap(m, key, value) + } + + if _, ok := m[slog.SourceKey]; ok { + fs := runtime.CallersFrames([]uintptr{rec.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(rec slog.Record, ReplaceAttr func(string, slog.Value) (slog.Value, bool)) (string, error) { + if ReplaceAttr != nil { + value, ok := ReplaceAttr(slog.MessageKey, slog.StringValue(rec.Message)) + if !ok { + return "", nil + } + return value.String(), nil + } + + return rec.Message, nil +} diff --git a/logging/sinks/encoding_test.go b/logging/sinks/encoding_test.go new file mode 100644 index 0000000..2b2839f --- /dev/null +++ b/logging/sinks/encoding_test.go @@ -0,0 +1,43 @@ +package sinks_test + +import ( + "encoding/json" + "log/slog" + "testing" + "testing/slogtest" + + "go.sudomsg.com/kit/logging/sinks" + "go.sudomsg.com/kit/logging/test" +) + +func TestJSONEncoder(t *testing.T) { + var lastMock *test.MockHandler + + slogtest.Run(t, + func(t *testing.T) slog.Handler { + lastMock = test.NewMockLogHandler(t) + return 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) + s, err := sinks.JSONEncoder(rec, nil) + if err != nil { + t.Fatalf("%v", err) + } + if err := json.Unmarshal([]byte(s), &m); err != nil { + t.Fatalf("%v", err) + } + + return m + }, + ) +} diff --git a/logging/sinks/sink.go b/logging/sinks/sink.go new file mode 100644 index 0000000..acb437f --- /dev/null +++ b/logging/sinks/sink.go @@ -0,0 +1,50 @@ +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...) + h.Sink.Append(newRecord) + + return nil +} + +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/writer.go b/logging/sinks/writer.go new file mode 100644 index 0000000..a0b42cb --- /dev/null +++ b/logging/sinks/writer.go @@ -0,0 +1,42 @@ +package sinks + +import ( + "fmt" + "io" + "log/slog" + "sync" +) + +type WriterSink struct { + writer io.Writer + mu sync.Mutex + encoder func(slog.Record, func(string, slog.Value) (slog.Value, bool)) (string, error) + level slog.Level +} + +var _ Sink = &WriterSink{} + +func NewWriterSink(w io.Writer, encoder func(slog.Record, func(string, slog.Value) (slog.Value, bool)) (string, error), 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 { + out, err := s.encoder(r, nil) + if err != nil { + return 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 new file mode 100644 index 0000000..4089b06 --- /dev/null +++ b/logging/sinks/writer_test.go @@ -0,0 +1,36 @@ +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() + + 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/test/handler.go b/logging/test/handler.go index 65d5d92..e26916c 100644 --- a/logging/test/handler.go +++ b/logging/test/handler.go @@ -4,13 +4,12 @@ package test import ( - "context" "log/slog" "slices" "sync" "testing" - "go.sudomsg.com/kit/logging" + "go.sudomsg.com/kit/logging/sinks" ) type logRecorder struct { @@ -18,11 +17,18 @@ type logRecorder struct { records []slog.Record } -func (h *logRecorder) Append(r slog.Record) { +var _ sinks.Sink = &logRecorder{} + +func (h *logRecorder) Append(r slog.Record) error { h.mu.Lock() defer h.mu.Unlock() h.records = append(h.records, r.Clone()) + return nil +} + +func (h *logRecorder) Enabled(level slog.Level) bool { + return true // Capture all logs } func (h *logRecorder) Records() []slog.Record { @@ -38,6 +44,7 @@ func (h *logRecorder) Records() []slog.Record { // // All recorded logs can be retrieved with the Records method. type MockHandler struct { + *sinks.SinkHandler recorder *logRecorder } @@ -47,36 +54,14 @@ var _ slog.Handler = &MockHandler{} func NewMockLogHandler(tb testing.TB) *MockHandler { tb.Helper() + rec := &logRecorder{} + return &MockHandler{ - recorder: &logRecorder{}, + SinkHandler: sinks.NewSinkHandler(rec), + recorder: rec, } } -func (h *MockHandler) Enabled(ctx context.Context, level slog.Level) bool { - return true // Capture all logs -} - -func (h *MockHandler) Handle(ctx context.Context, r slog.Record) error { - newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) - - attrs := logging.GetNormalizedAttrs(r) - - newRecord.AddAttrs(attrs...) - h.recorder.Append(newRecord) - - return nil - -} - -func (h *MockHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return logging.ChainAttrs(h, attrs) - -} - -func (h *MockHandler) WithGroup(name string) slog.Handler { - return logging.ChainGroup(h, name) -} - // Records returns a copy of all slog.Records captured by this handler so far. // // It is safe to call from multiple goroutines. diff --git a/logging/test/handler_test.go b/logging/test/handler_test.go index 5e9430a..ff1ad01 100644 --- a/logging/test/handler_test.go +++ b/logging/test/handler_test.go @@ -4,9 +4,11 @@ import ( "fmt" "log/slog" "runtime" + "strings" "testing" "testing/slogtest" + "go.sudomsg.com/kit/logging/sinks" "go.sudomsg.com/kit/logging/test" ) @@ -30,37 +32,38 @@ func TestMockHandlerCompliance(t *testing.T) { // Convert slog.Record to map[string]any m := make(map[string]any) - if !rec.Time.IsZero() { - m[slog.TimeKey] = rec.Time + for key, value := range sinks.RecordAll(rec) { + insertIntoMap(m, key, value) } - if rec.PC != 0 { + + if _, ok := m[slog.SourceKey]; ok { fs := runtime.CallersFrames([]uintptr{rec.PC}) f, _ := fs.Next() m[slog.SourceKey] = fmt.Sprintf("%s:%d", f.File, f.Line) } - m[slog.LevelKey] = rec.Level - m[slog.MessageKey] = rec.Message - - rec.Attrs(func(attr slog.Attr) bool { - insertAttr(t, m, attr) - return true - }) return m }, ) } -func insertAttr(t testing.TB, m map[string]any, attr slog.Attr) { - t.Helper() +func insertIntoMap(m map[string]any, dottedKey string, value slog.Value) { + parts := strings.Split(dottedKey, ".") + key := parts[len(parts)-1] + parts = parts[:len(parts)-1] + currentMap := m + + for _, part := range parts { + if _, ok := currentMap[part]; !ok { + currentMap[part] = make(map[string]any) + } - if attr.Value.Kind() == slog.KindGroup { - groupMap := make(map[string]any) - for _, a := range attr.Value.Group() { - insertAttr(t, groupMap, a) + if nested, ok := currentMap[part].(map[string]any); ok { + currentMap = nested + } else { + currentMap[part] = make(map[string]any) + currentMap = currentMap[part].(map[string]any) } - m[attr.Key] = groupMap - } else { - m[attr.Key] = attr.Value.Any() } + currentMap[key] = value.Any() } |
