From 710704b6ce5ad2b62fc4a44f05b5f53ef924834e Mon Sep 17 00:00:00 2001 From: Marc Pervaz Boocha Date: Tue, 24 Feb 2026 00:27:21 +0530 Subject: Added more logging stuff --- logging/chain.go | 134 ------------------------------------ logging/log.go | 53 +++++++------- logging/sinks/chain.go | 152 +++++++++++++++++++++++++++++++++++++++++ logging/sinks/encoding.go | 140 +++++++++++++++++++++++++++++++++++++ logging/sinks/encoding_test.go | 43 ++++++++++++ logging/sinks/sink.go | 50 ++++++++++++++ logging/sinks/writer.go | 42 ++++++++++++ logging/sinks/writer_test.go | 36 ++++++++++ logging/test/handler.go | 43 ++++-------- logging/test/handler_test.go | 41 +++++------ 10 files changed, 529 insertions(+), 205 deletions(-) delete mode 100644 logging/chain.go create mode 100644 logging/sinks/chain.go create mode 100644 logging/sinks/encoding.go create mode 100644 logging/sinks/encoding_test.go create mode 100644 logging/sinks/sink.go create mode 100644 logging/sinks/writer.go create mode 100644 logging/sinks/writer_test.go diff --git a/logging/chain.go b/logging/chain.go deleted file mode 100644 index 87d7b2d..0000000 --- a/logging/chain.go +++ /dev/null @@ -1,134 +0,0 @@ -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 -} - -// 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 { - parent slog.Handler - group string - attrs []slog.Attr -} - -func ChainAttrs(h slog.Handler, attrs []slog.Attr) slog.Handler { - if len(attrs) == 0 { - return h - } - return &ChainingHandler{ - parent: h, - attrs: attrs, - } -} - -func ChainGroup(h slog.Handler, name string) slog.Handler { - if name == "" { - return h - } - return &ChainingHandler{ - parent: h, - group: name, - } -} - -var _ slog.Handler = (*ChainingHandler)(nil) - -func (h *ChainingHandler) 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) - - r.Attrs(func(attr slog.Attr) bool { - attrs = append(attrs, attr) - return true - }) - - 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 { - return ChainAttrs(h, attrs) - -} - -func (h *ChainingHandler) WithGroup(name string) slog.Handler { - return ChainGroup(h, name) -} 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/sinks/chain.go b/logging/sinks/chain.go new file mode 100644 index 0000000..c01ce5f --- /dev/null +++ b/logging/sinks/chain.go @@ -0,0 +1,152 @@ +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 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() } -- cgit v1.3.1