aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMarc Pervaz Boocha <mboocha@sudomsg.com>2026-02-24 00:27:21 +0530
committerMarc Pervaz Boocha <mboocha@sudomsg.com>2026-02-24 00:27:21 +0530
commit710704b6ce5ad2b62fc4a44f05b5f53ef924834e (patch)
tree85fe7f0e565cb401ae540377ef0d34fcf4ab6131
parentgo fix 1.26 (diff)
downloadkit-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.go53
-rw-r--r--logging/sinks/chain.go (renamed from logging/chain.go)76
-rw-r--r--logging/sinks/encoding.go140
-rw-r--r--logging/sinks/encoding_test.go43
-rw-r--r--logging/sinks/sink.go50
-rw-r--r--logging/sinks/writer.go42
-rw-r--r--logging/sinks/writer_test.go36
-rw-r--r--logging/test/handler.go43
-rw-r--r--logging/test/handler_test.go41
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()
}