aboutsummaryrefslogtreecommitdiffstats
path: root/logging
diff options
context:
space:
mode:
authorMarc Pervaz Boocha <mboocha@sudomsg.com>2026-02-27 21:18:28 +0530
committerMarc Pervaz Boocha <mboocha@sudomsg.com>2026-02-27 21:18:28 +0530
commita451b3008e17167198da38ec627bb28d8af084de (patch)
treeb1e30744d58bf7f0a770d8da6fdb949d74fdd88b /logging
parentMisc Fixes (diff)
downloadkit-a451b3008e17167198da38ec627bb28d8af084de.tar
kit-a451b3008e17167198da38ec627bb28d8af084de.tar.gz
kit-a451b3008e17167198da38ec627bb28d8af084de.tar.bz2
kit-a451b3008e17167198da38ec627bb28d8af084de.tar.lz
kit-a451b3008e17167198da38ec627bb28d8af084de.tar.xz
kit-a451b3008e17167198da38ec627bb28d8af084de.tar.zst
kit-a451b3008e17167198da38ec627bb28d8af084de.zip
Add Syslog and refactored the Sink api
Diffstat (limited to 'logging')
-rw-r--r--logging/http/http_test.go16
-rw-r--r--logging/log.go34
-rw-r--r--logging/log_test.go59
-rw-r--r--logging/sinks/encoding.go147
-rw-r--r--logging/sinks/encoding_test.go50
-rw-r--r--logging/sinks/sink.go4
-rw-r--r--logging/sinks/syslog.go155
-rw-r--r--logging/sinks/syslog_stub.go9
-rw-r--r--logging/sinks/syslog_unix.go20
-rw-r--r--logging/sinks/writer.go24
-rw-r--r--logging/test/handler.go10
-rw-r--r--logging/test/handler_test.go36
12 files changed, 363 insertions, 201 deletions
diff --git a/logging/http/http_test.go b/logging/http/http_test.go
index 832b49e..e12b9b2 100644
--- a/logging/http/http_test.go
+++ b/logging/http/http_test.go
@@ -80,20 +80,14 @@ func TestNew(t *testing.T) {
t.Fatalf("expected 1 log record, got %d", len(records))
}
- record := records[0]
-
- attrs := map[string]slog.Value{}
- record.Attrs(func(a slog.Attr) bool {
- attrs[a.Key] = a.Value
- return true
- })
+ attrs := records[0]
method := attrs["method"]
- if got := method.String(); got != tt.method {
+ if got := method.(string); got != tt.method {
t.Fatalf("expected %v log record, got %v", tt.method, got)
}
status := attrs["status"]
- if got := int(status.Int64()); got != tt.status {
+ if got := int(status.(int64)); got != tt.status {
t.Fatalf("expected %v log record, got %v", tt.status, got)
}
@@ -103,11 +97,11 @@ func TestNew(t *testing.T) {
}
host := attrs["host"]
- if got := host.String(); got != url.Host {
+ if got := host.(string); got != url.Host {
t.Fatalf("expected %v log record, got %v", url.Host, got)
}
path := attrs["path"]
- if got := path.String(); got != url.RequestURI() {
+ if got := path.(string); got != url.RequestURI() {
t.Fatalf("expected %v log record, got %v", url.RequestURI(), got)
}
})
diff --git a/logging/log.go b/logging/log.go
index a9d4bf4..dc5b90e 100644
--- a/logging/log.go
+++ b/logging/log.go
@@ -254,18 +254,18 @@ func (s *LogSink) UnmarshalText(b []byte) error {
// 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 int `toml:"facility"`
+ 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(LogConfig{}) // Setup Sensible Defaults
+ Setup(context.Background(), LogConfig{}) // Setup Sensible Defaults
}
// Setup initializes the default slog.Logger based on the provided LogConfig.
@@ -287,8 +287,8 @@ func init() {
// }
//
// After a successful call, the global slog logger will log accordingly.
-func Setup(cfg LogConfig) error {
- logger, err := New(cfg)
+func Setup(ctx context.Context, cfg LogConfig) error {
+ logger, err := New(ctx, cfg)
if err != nil {
return nil
}
@@ -296,7 +296,7 @@ func Setup(cfg LogConfig) error {
return nil
}
-func newSink(cfg LogConfig, encoder sinks.Encoder) (sinks.Sink, error) {
+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
@@ -311,6 +311,12 @@ func newSink(cfg LogConfig, encoder sinks.Encoder) (sinks.Sink, error) {
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)
}
@@ -318,13 +324,13 @@ func newSink(cfg LogConfig, encoder sinks.Encoder) (sinks.Sink, error) {
}
// New is same as Setup but returns the logger instead of setting up the global logger
-func New(cfg LogConfig) (*slog.Logger, error) {
+func New(ctx context.Context, cfg LogConfig) (*slog.Logger, error) {
encoder, err := newEncoder(cfg.Format)
if err != nil {
return nil, err
}
- sink, err := newSink(cfg, encoder)
+ sink, err := newSink(ctx, cfg, encoder)
if err != nil {
return nil, err
}
diff --git a/logging/log_test.go b/logging/log_test.go
index 5efed19..2852939 100644
--- a/logging/log_test.go
+++ b/logging/log_test.go
@@ -50,19 +50,11 @@ func TestRecoverAndLog(t *testing.T) {
}
record := records[0]
- if record.Message != "panic recovered" {
- t.Errorf("expected message 'panic recAvered', got %q", record.Message)
+ if record[slog.MessageKey] != "panic recovered" {
+ t.Errorf("expected message 'panic recAvered', got %q", record[slog.MessageKey])
}
- foundStack := false
- record.Attrs(func(a slog.Attr) bool {
- if a.Key == "stack" {
- foundStack = true
- }
- return true
- })
-
- if !foundStack {
+ if _, ok := record["stack"]; !ok {
t.Errorf("expected 'stack' attribute in log")
}
}
@@ -87,21 +79,21 @@ func TestWith(t *testing.T) {
}
record := records[0]
- foundUser := false
- foundID := false
- record.Attrs(func(attr slog.Attr) bool {
- switch attr.Key {
- case "user":
- foundUser = attr.Value.String() == user
- case "id":
- foundID = attr.Value.String() == id
+ if v, ok := record["user"]; ok {
+ if v.(string) != user {
+ t.Errorf("expected %v, got %v in 'user' attribute in log record, ", user, v)
}
- return true
- })
+ } else {
+ t.Errorf("expected 'user' attributes in log record, %v", records)
+ }
- if !foundUser || !foundID {
- t.Errorf("expected 'user' and 'id' attributes in log record, %v", records)
+ if v, ok := record["id"]; ok {
+ if v.(string) != id {
+ t.Errorf("expected %v, got %v in 'id' attribute in log record, ", id, v)
+ }
+ } else {
+ t.Errorf("expected 'id' attributes in log record, %v", records)
}
// Test context carries logger with same attributes
@@ -130,16 +122,13 @@ func TestWithGroup(t *testing.T) {
}
record := records[0]
- var foundGroup bool
-
- record.Attrs(func(attr slog.Attr) bool {
- foundGroup = attr.Value.Kind() == slog.KindGroup
- return false
- })
-
- if !foundGroup {
- t.Errorf("expected 'user' and 'id' attributes in log record, %v", records)
+ if v, ok := record["foo"]; ok {
+ if _, ok := v.(map[string]any); !ok {
+ t.Errorf("expected 'foo' to be a group attr in log record, %v", records)
+ }
+ } else {
+ t.Errorf("expected 'foo' attributes in log record, %v", records)
}
}
@@ -220,9 +209,9 @@ func TestLogSink(t *testing.T) {
t.Parallel()
EnumTester(t, []logging.LogSink{
- logging.SinkStdout,
- logging.SinkStderr,
- logging.SinkFile,
+ logging.SinkStdout,
+ logging.SinkStderr,
+ logging.SinkFile,
})
}
diff --git a/logging/sinks/encoding.go b/logging/sinks/encoding.go
index 42848ec..ae58a0e 100644
--- a/logging/sinks/encoding.go
+++ b/logging/sinks/encoding.go
@@ -6,117 +6,103 @@ import (
"iter"
"log/slog"
"runtime"
+ "slices"
"strings"
)
-func RecordAll(r slog.Record) iter.Seq2[string, slog.Value] {
- return func(yield func(string, slog.Value) bool) {
+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 !yield(slog.TimeKey, slog.TimeValue(r.Time)) {
+ if !walk([]string{}, slog.Time(slog.TimeKey, r.Time)) {
return
}
}
- if !yield(slog.LevelKey, slog.AnyValue(r.Level)) {
+ if !walk([]string{}, slog.Any(slog.LevelKey, r.Level)) {
return
}
- if !yield(slog.MessageKey, slog.StringValue(r.Message)) {
+ if !walk([]string{}, slog.String(slog.MessageKey, r.Message)) {
return
}
if r.PC != 0 {
- if !yield(slog.SourceKey, slog.Uint64Value(uint64(r.PC))) {
+ if !walk([]string{}, slog.Uint64(slog.SourceKey, 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}, "")
+ return walk([]string{}, attr)
})
}
}
-type Encoder func(rec slog.Record, ReplaceAttr func(string, slog.Value) (slog.Value, bool)) (string, error)
+type Encoder func(iter.Seq2[[]string, slog.Attr]) (string, error)
-func LogFmtEncoder(rec slog.Record, ReplaceAttr func(string, slog.Value) (slog.Value, bool)) (string, error) {
+func LogFmtEncoder(attrs iter.Seq2[[]string, slog.Attr]) (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})
+ 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()
- value = slog.StringValue(fmt.Sprintf("%s:%d", f.File, f.Line))
+ attr.Value = slog.StringValue(fmt.Sprintf("%s:%d", f.File, f.Line))
}
- str = append(str, fmt.Sprintf("%s=%q", key, value))
+ str = append(str, fmt.Sprintf("%s=%q", strings.Join(append(groups, attr.Key), "."), attr.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
+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
}
}
- insertIntoMap(m, key, value)
+ current[attr.Key] = attr.Value.Any()
}
+ return out
+}
- if _, ok := m[slog.SourceKey]; ok {
- fs := runtime.CallersFrames([]uintptr{rec.PC})
+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)
}
@@ -129,14 +115,11 @@ func JSONEncoder(rec slog.Record, ReplaceAttr func(string, slog.Value) (slog.Val
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
+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 value.String(), nil
}
-
- return rec.Message, nil
+ return "", nil
}
diff --git a/logging/sinks/encoding_test.go b/logging/sinks/encoding_test.go
index 2b2839f..09a668a 100644
--- a/logging/sinks/encoding_test.go
+++ b/logging/sinks/encoding_test.go
@@ -3,20 +3,56 @@ package sinks_test
import (
"encoding/json"
"log/slog"
+ "slices"
+ "sync"
"testing"
"testing/slogtest"
"go.sudomsg.com/kit/logging/sinks"
- "go.sudomsg.com/kit/logging/test"
)
+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 *test.MockHandler
+ var lastMock *logRecorder
slogtest.Run(t,
func(t *testing.T) slog.Handler {
- lastMock = test.NewMockLogHandler(t)
- return lastMock
+ lastMock = &logRecorder{
+ encoder: sinks.JSONEncoder,
+ }
+ return sinks.NewSinkHandler(lastMock)
},
func(t *testing.T) map[string]any {
t.Helper()
@@ -29,11 +65,7 @@ func TestJSONEncoder(t *testing.T) {
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 {
+ if err := json.Unmarshal([]byte(rec), &m); err != nil {
t.Fatalf("%v", err)
}
diff --git a/logging/sinks/sink.go b/logging/sinks/sink.go
index acb437f..058b128 100644
--- a/logging/sinks/sink.go
+++ b/logging/sinks/sink.go
@@ -35,9 +35,7 @@ func (h *SinkHandler) Handle(ctx context.Context, r slog.Record) error {
attrs := GetNormalizedAttrs(r)
newRecord.AddAttrs(attrs...)
- h.Sink.Append(newRecord)
-
- return nil
+ return h.Sink.Append(newRecord)
}
func (h *SinkHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
diff --git a/logging/sinks/syslog.go b/logging/sinks/syslog.go
new file mode 100644
index 0000000..0c2e429
--- /dev/null
+++ b/logging/sinks/syslog.go
@@ -0,0 +1,155 @@
+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
new file mode 100644
index 0000000..74aac9f
--- /dev/null
+++ b/logging/sinks/syslog_stub.go
@@ -0,0 +1,9 @@
+//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
new file mode 100644
index 0000000..f90a065
--- /dev/null
+++ b/logging/sinks/syslog_unix.go
@@ -0,0 +1,20 @@
+//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
index 93f3423..ed18c97 100644
--- a/logging/sinks/writer.go
+++ b/logging/sinks/writer.go
@@ -8,35 +8,37 @@ import (
)
type WriterSink struct {
- writer io.Writer
- mu sync.Mutex
- encoder Encoder
- level slog.Level
+ 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(),
+ Writer: w,
+ Encoder: encoder,
+ Level: level.Level(),
}
}
func (s *WriterSink) Enabled(level slog.Level) bool {
- return s.level < level
+ return s.Level <= level
}
func (s *WriterSink) Append(r slog.Record) error {
- out, err := s.encoder(r, nil)
+ it := RecordAll(r, s.ReplaceAttr)
+ out, err := s.Encoder(it)
if err != nil {
- return err
+ return fmt.Errorf("failed to encode: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
- _, err = fmt.Fprintln(s.writer, out)
+ _, err = fmt.Fprintln(s.Writer, out)
return err
}
diff --git a/logging/test/handler.go b/logging/test/handler.go
index e26916c..210893b 100644
--- a/logging/test/handler.go
+++ b/logging/test/handler.go
@@ -14,7 +14,7 @@ import (
type logRecorder struct {
mu sync.Mutex
- records []slog.Record
+ records []map[string]any
}
var _ sinks.Sink = &logRecorder{}
@@ -23,7 +23,9 @@ func (h *logRecorder) Append(r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
- h.records = append(h.records, r.Clone())
+ it := sinks.RecordAll(r, nil)
+
+ h.records = append(h.records, sinks.ToMap(it))
return nil
}
@@ -31,7 +33,7 @@ func (h *logRecorder) Enabled(level slog.Level) bool {
return true // Capture all logs
}
-func (h *logRecorder) Records() []slog.Record {
+func (h *logRecorder) Records() []map[string]any {
h.mu.Lock()
defer h.mu.Unlock()
return slices.Clone(h.records)
@@ -65,6 +67,6 @@ func NewMockLogHandler(tb testing.TB) *MockHandler {
// Records returns a copy of all slog.Records captured by this handler so far.
//
// It is safe to call from multiple goroutines.
-func (h *MockHandler) Records() []slog.Record {
+func (h *MockHandler) Records() []map[string]any {
return h.recorder.Records()
}
diff --git a/logging/test/handler_test.go b/logging/test/handler_test.go
index ff1ad01..e78192e 100644
--- a/logging/test/handler_test.go
+++ b/logging/test/handler_test.go
@@ -4,11 +4,9 @@ import (
"fmt"
"log/slog"
"runtime"
- "strings"
"testing"
"testing/slogtest"
- "go.sudomsg.com/kit/logging/sinks"
"go.sudomsg.com/kit/logging/test"
)
@@ -28,16 +26,11 @@ func TestMockHandlerCompliance(t *testing.T) {
t.Fatalf("no records captured")
}
- rec := recs[len(recs)-1]
+ m := recs[len(recs)-1]
- // Convert slog.Record to map[string]any
- m := make(map[string]any)
- for key, value := range sinks.RecordAll(rec) {
- insertIntoMap(m, key, value)
- }
-
- if _, ok := m[slog.SourceKey]; ok {
- fs := runtime.CallersFrames([]uintptr{rec.PC})
+ if v, ok := m[slog.SourceKey]; ok {
+ pc := uintptr(v.(uint64))
+ fs := runtime.CallersFrames([]uintptr{pc})
f, _ := fs.Next()
m[slog.SourceKey] = fmt.Sprintf("%s:%d", f.File, f.Line)
}
@@ -46,24 +39,3 @@ func TestMockHandlerCompliance(t *testing.T) {
},
)
}
-
-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()
-}