README | 3 +++ go.mod | 2 +- slog/handler.go | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++++ slog/handler_test.go | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++ diff --git a/README b/README index ba5acc5d63c4b08f105154675649e6543261b8bcaa7d4c48235c7af10d1e56d6..9d570cfcfc006cf2a2f7d21672c5e1d06a2c5fbfdd47811f36096dd568d30364 100644 --- a/README +++ b/README @@ -11,4 +11,7 @@ Limitations: * leading spaces in the first line of the value are ignored * trailing backslash at the end of lines is followed by space +Also there is go.cypherpunks.ru/recfile/slog.NewRecfileHandler log/slog +handler to write your logs in recfile format directly. + It is free software: see the file COPYING for copying conditions. diff --git a/go.mod b/go.mod index 0c423ef83542e3d5184604d03a8bde953c91e070712108dd007d36547ba8cd55..f635f535fa40ca69174d31b5cce57c2f98b19e6702ab680b8a69aec7e28400ac 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module go.cypherpunks.ru/recfile -go 1.12 +go 1.21 diff --git a/slog/handler.go b/slog/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..4962cb7a8f7888ef6131cff99549646cc9a2516801f5c2abe98c19e39a7236d5 --- /dev/null +++ b/slog/handler.go @@ -0,0 +1,128 @@ +package slog + +import ( + "bytes" + "context" + "io" + "log/slog" + "sync" + "time" + + "go.cypherpunks.ru/recfile" +) + +type RecfileHandler struct { + W io.Writer + Level slog.Level + LevelKey string + MsgKey string + TimeKey string + attrs []slog.Attr + group string + m *sync.Mutex +} + +func NewRecfileHandler( + w io.Writer, + level slog.Level, + levelKey, msgKey, timeKey string, +) *RecfileHandler { + return &RecfileHandler{ + W: w, + Level: level, + LevelKey: levelKey, + MsgKey: msgKey, + TimeKey: timeKey, + m: new(sync.Mutex), + } +} + +func (h *RecfileHandler) Enabled(ctx context.Context, level slog.Level) bool { + return level >= h.Level +} + +func (h *RecfileHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &RecfileHandler{ + W: h.W, + Level: h.Level, + LevelKey: h.LevelKey, + MsgKey: h.MsgKey, + TimeKey: h.TimeKey, + attrs: append(h.attrs, attrs...), + group: h.group, + m: h.m, + } +} + +func (h *RecfileHandler) WithGroup(name string) slog.Handler { + neu := RecfileHandler{ + W: h.W, + Level: h.Level, + LevelKey: h.LevelKey, + MsgKey: h.MsgKey, + TimeKey: h.TimeKey, + attrs: h.attrs, + group: h.group + name + "_", + m: h.m, + } + return &neu +} + +func writeValue(w *recfile.Writer, group string, attr slog.Attr) (err error) { + if attr.Value.Kind() == slog.KindAny { + multiline, ok := attr.Value.Any().([]string) + if ok { + if len(multiline) > 0 { + _, err = w.WriteFieldMultiline(group+attr.Key, multiline) + return + } + return + } + } + _, err = w.WriteFields(recfile.Field{ + Name: group + attr.Key, + Value: attr.Value.String(), + }) + return +} + +func (h *RecfileHandler) Handle(ctx context.Context, rec slog.Record) (err error) { + var b bytes.Buffer + w := recfile.NewWriter(&b) + _, err = w.RecordStart() + if err != nil { + panic(err) + } + var fields []recfile.Field + if h.LevelKey != "" { + fields = append(fields, recfile.Field{ + Name: h.LevelKey, + Value: rec.Level.String(), + }) + } + if h.TimeKey != "" { + fields = append(fields, recfile.Field{ + Name: h.TimeKey, + Value: rec.Time.UTC().Format(time.RFC3339Nano), + }) + } + fields = append(fields, recfile.Field{Name: h.MsgKey, Value: rec.Message}) + _, err = w.WriteFields(fields...) + if err != nil { + return + } + for _, attr := range h.attrs { + writeValue(w, h.group, attr) + } + rec.Attrs(func(attr slog.Attr) bool { return writeValue(w, h.group, attr) == nil }) + h.m.Lock() + n, err := h.W.Write(b.Bytes()) + h.m.Unlock() + if err != nil { + return + } + if n != b.Len() { + return io.EOF + } + return +} diff --git a/slog/handler_test.go b/slog/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a0c25838fe683b678b7dd4268744f735cba2c3808f98947a0e36431f948eb305 --- /dev/null +++ b/slog/handler_test.go @@ -0,0 +1,92 @@ +package slog + +import ( + "bytes" + "log/slog" + "testing" + "time" + + "go.cypherpunks.ru/recfile" +) + +func TestBasic(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(NewRecfileHandler( + &buf, + slog.LevelWarn, + "Urgency", + "Message", + "Time", + )) + if !logger.Enabled(nil, slog.LevelWarn) { + t.FailNow() + } + logger.Info("won't catch me") + logger.Warn("catch me") + + r := recfile.NewReader(&buf) + m, err := r.NextMap() + if err != nil { + t.Fatal(err) + } + if m["Message"] != "catch me" { + t.FailNow() + } + if m["Urgency"] != "WARN" { + t.FailNow() + } + if _, err = time.Parse(time.RFC3339Nano, m["Time"]); err != nil { + t.FailNow() + } +} + +func TestTrimmed(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(NewRecfileHandler(&buf, slog.LevelWarn, "", "Message", "")) + logger.Warn("catch me") + r := recfile.NewReader(&buf) + m, err := r.NextMap() + if err != nil { + t.Fatal(err) + } + if m["Message"] != "catch me" { + t.FailNow() + } + if m["Urgency"] != "" { + t.FailNow() + } + if m["Time"] != "" { + t.FailNow() + } +} + +func TestFeatured(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(NewRecfileHandler(&buf, slog.LevelInfo, "L", "M", "T")) + logger.WithGroup("grou").WithGroup("py").With("foo", "bar").With("bar", "baz").Info( + "catch me", "baz", []string{"multi", "line"}, + ) + r := recfile.NewReader(&buf) + m, err := r.NextMap() + if err != nil { + t.Fatal(err) + } + if m["M"] != "catch me" { + t.Fatal("M") + } + if m["L"] != "INFO" { + t.Fatal("L") + } + if _, err = time.Parse(time.RFC3339Nano, m["T"]); err != nil { + t.Fatal("T") + } + if m["grou_py_foo"] != "bar" { + t.Fatal("foo") + } + if m["grou_py_bar"] != "baz" { + t.Fatal("bar") + } + if m["grou_py_baz"] != "multi\nline" { + t.Fatal("baz") + } +}