From efac1a2dc488d3ab2e44866d633ebebd8b803454 Mon Sep 17 00:00:00 2001 From: "S.Solodyagin" Date: Sun, 29 Mar 2026 18:18:36 +0300 Subject: [PATCH] first commit --- LICENSE | 21 +++ README.md | 77 +++++++++++ example/main.go | 52 ++++++++ formatter.go | 216 ++++++++++++++++++++++++++++++ formatter_test.go | 330 ++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 7 + go.sum | 15 +++ 7 files changed, 718 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 example/main.go create mode 100644 formatter.go create mode 100644 formatter_test.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2f19cc6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Anton Fisher + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff76711 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Nested Formatter + +Human-readable log formatter, converts _logrus_ fields to a nested structure: + +![Screenshot](https://git.corp.kornet35.ru/gopkg/nested/raw/branch/docs/images/demo.png) + +## Configuration: + +```go +type Formatter struct { + // FieldsOrder - default: fields sorted alphabetically + FieldsOrder []string + + // TimestampFormat - default: time.StampMilli = "Jan _2 15:04:05.000" + TimestampFormat string + + // HideKeys - show [fieldValue] instead of [fieldKey:fieldValue] + HideKeys bool + + // NoColors - disable colors + NoColors bool + + // NoFieldsColors - apply colors only to the level, default is level + fields + NoFieldsColors bool + + // NoFieldsSpace - no space between fields + NoFieldsSpace bool + + // ShowFullLevel - show a full level [WARNING] instead of [WARN] + ShowFullLevel bool + + // NoUppercaseLevel - no upper case for level value + NoUppercaseLevel bool + + // TrimMessages - trim whitespaces on messages + TrimMessages bool + + // CallerFirst - print caller info first + CallerFirst bool + + // CustomCallerFormatter - set custom formatter for caller info + CustomCallerFormatter func(*runtime.Frame) string +} +``` + +## Usage + +```go +import ( + "git.corp.kornet35.ru/gopkg/logrus" + "git.corp.kornet35.ru/gopkg/nested" +) + +log := logrus.New() +log.SetFormatter(&nested.Formatter{ + HideKeys: true, + FieldsOrder: []string{"component", "category"}, +}) + +log.Info("just info message") +// Output: Jan _2 15:04:05.000 [INFO] just info message + +log.WithField("component", "rest").Warn("warn message") +// Output: Jan _2 15:04:05.000 [WARN] [rest] warn message +``` + +See more examples in the [tests](./tests/formatter_test.go) file. + +## Development + +```bash +# run tests: +make test + +# run demo: +make demo +``` diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..f92d07d --- /dev/null +++ b/example/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + + "git.corp.kornet35.ru/gopkg/logrus" + "git.corp.kornet35.ru/gopkg/nested" +) + +func main() { + fmt.Print("\n--- logrus-formatter-nested ---\n\n") + + printDemo(&nested.Formatter{ + HideKeys: true, + FieldsOrder: []string{"component", "category", "req"}, + }, "logrus-formatter-nested") + + fmt.Print("\n--- default logrus formatter ---\n\n") + printDemo(nil, "default logrus formatter") +} + +func printDemo(f logrus.Formatter, title string) { + l := logrus.New() + + l.SetLevel(logrus.DebugLevel) + + if f != nil { + l.SetFormatter(f) + } + + // enable/disable file/function name + l.SetReportCaller(false) + + l.Infof("this is %v demo", title) + + lWebServer := l.WithField("component", "web-server") + lWebServer.Info("starting...") + + lWebServerReq := lWebServer.WithFields(logrus.Fields{ + "req": "GET /api/stats", + "reqId": "#1", + }) + + lWebServerReq.Info("params: startYear=2048") + lWebServerReq.Error("response: 400 Bad Request") + + lDbConnector := l.WithField("category", "db-connector") + lDbConnector.Info("connecting to db on 10.10.10.13...") + lDbConnector.Warn("connection took 10s") + + l.Info("demo end.") +} diff --git a/formatter.go b/formatter.go new file mode 100644 index 0000000..9dda9e1 --- /dev/null +++ b/formatter.go @@ -0,0 +1,216 @@ +package nested + +import ( + "bytes" + "fmt" + "runtime" + "sort" + "strings" + "time" + + "git.corp.kornet35.ru/gopkg/logrus" +) + +// Formatter - logrus formatter, implements logrus.Formatter +type Formatter struct { + // FieldsOrder - default: fields sorted alphabetically + FieldsOrder []string + + // TimestampFormat - default: time.StampMilli = "Jan _2 15:04:05.000" + TimestampFormat string + + // HideKeys - show [fieldValue] instead of [fieldKey:fieldValue] + HideKeys bool + + // NoColors - disable colors + NoColors bool + + // NoFieldsColors - apply colors only to the level, default is level + fields + NoFieldsColors bool + + // NoFieldsSpace - no space between fields + NoFieldsSpace bool + + // ShowFullLevel - show a full level [WARNING] instead of [WARN] + ShowFullLevel bool + + // NoUppercaseLevel - no upper case for level value + NoUppercaseLevel bool + + // TrimMessages - trim whitespaces on messages + TrimMessages bool + + // CallerFirst - print caller info first + CallerFirst bool + + // CustomCallerFormatter - set custom formatter for caller info + CustomCallerFormatter func(*runtime.Frame) string +} + +// Format an log entry +func (f *Formatter) Format(entry *logrus.Entry) ([]byte, error) { + levelColor := getColorByLevel(entry.Level) + + timestampFormat := f.TimestampFormat + if timestampFormat == "" { + timestampFormat = time.StampMilli + } + + // output buffer + b := &bytes.Buffer{} + + // write time + b.WriteString(entry.Time.Format(timestampFormat)) + + // write level + var level string + if f.NoUppercaseLevel { + level = entry.Level.String() + } else { + level = strings.ToUpper(entry.Level.String()) + } + + if f.CallerFirst { + f.writeCaller(b, entry) + } + + if !f.NoColors { + fmt.Fprintf(b, "\x1b[%dm", levelColor) + } + + b.WriteString(" [") + if f.ShowFullLevel { + b.WriteString(level) + } else { + b.WriteString(level[:4]) + } + b.WriteString("]") + + if !f.NoFieldsSpace { + b.WriteString(" ") + } + + if !f.NoColors && f.NoFieldsColors { + b.WriteString("\x1b[0m") + } + + // write fields + if f.FieldsOrder == nil { + f.writeFields(b, entry) + } else { + f.writeOrderedFields(b, entry) + } + + if f.NoFieldsSpace { + b.WriteString(" ") + } + + if !f.NoColors && !f.NoFieldsColors { + b.WriteString("\x1b[0m") + } + + // write message + if f.TrimMessages { + b.WriteString(strings.TrimSpace(entry.Message)) + } else { + b.WriteString(entry.Message) + } + + if !f.CallerFirst { + f.writeCaller(b, entry) + } + + b.WriteByte('\n') + + return b.Bytes(), nil +} + +func (f *Formatter) writeCaller(b *bytes.Buffer, entry *logrus.Entry) { + if entry.HasCaller() { + if f.CustomCallerFormatter != nil { + fmt.Fprintf(b, f.CustomCallerFormatter(entry.Caller)) + } else { + fmt.Fprintf( + b, + " (%s:%d %s)", + entry.Caller.File, + entry.Caller.Line, + entry.Caller.Function, + ) + } + } +} + +func (f *Formatter) writeFields(b *bytes.Buffer, entry *logrus.Entry) { + if len(entry.Data) != 0 { + fields := make([]string, 0, len(entry.Data)) + for field := range entry.Data { + fields = append(fields, field) + } + + sort.Strings(fields) + + for _, field := range fields { + f.writeField(b, entry, field) + } + } +} + +func (f *Formatter) writeOrderedFields(b *bytes.Buffer, entry *logrus.Entry) { + length := len(entry.Data) + foundFieldsMap := map[string]bool{} + for _, field := range f.FieldsOrder { + if _, ok := entry.Data[field]; ok { + foundFieldsMap[field] = true + length-- + f.writeField(b, entry, field) + } + } + + if length > 0 { + notFoundFields := make([]string, 0, length) + for field := range entry.Data { + if !foundFieldsMap[field] { + notFoundFields = append(notFoundFields, field) + } + } + + sort.Strings(notFoundFields) + + for _, field := range notFoundFields { + f.writeField(b, entry, field) + } + } +} + +func (f *Formatter) writeField(b *bytes.Buffer, entry *logrus.Entry, field string) { + if f.HideKeys { + fmt.Fprintf(b, "[%v]", entry.Data[field]) + } else { + fmt.Fprintf(b, "[%s:%v]", field, entry.Data[field]) + } + + if !f.NoFieldsSpace { + b.WriteString(" ") + } +} + +const ( + colorRed = 31 + colorYellow = 33 + colorBlue = 36 + colorGray = 37 +) + +func getColorByLevel(level logrus.Level) int { + switch level { + case logrus.DebugLevel, logrus.TraceLevel: + return colorGray + case logrus.WarnLevel: + return colorYellow + case logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel: + return colorRed + default: + return colorBlue + } +} diff --git a/formatter_test.go b/formatter_test.go new file mode 100644 index 0000000..dad896d --- /dev/null +++ b/formatter_test.go @@ -0,0 +1,330 @@ +package nested + +import ( + "bytes" + "fmt" + "os" + "path" + "regexp" + "runtime" + "strings" + "testing" + + "git.corp.kornet35.ru/gopkg/logrus" +) + +func ExampleFormatter_Format_default() { + l := logrus.New() + l.SetOutput(os.Stdout) + l.SetLevel(logrus.DebugLevel) + l.SetFormatter(&Formatter{ + NoColors: true, + TimestampFormat: "-", + }) + + l.Debug("test1") + l.Info("test2") + l.Warn("test3") + l.Error("test4") + + // Output: + // - [DEBU] test1 + // - [INFO] test2 + // - [WARN] test3 + // - [ERRO] test4 +} + +func ExampleFormatter_Format_full_level() { + l := logrus.New() + l.SetOutput(os.Stdout) + l.SetLevel(logrus.DebugLevel) + l.SetFormatter(&Formatter{ + NoColors: true, + TimestampFormat: "-", + ShowFullLevel: true, + }) + + l.Debug("test1") + l.Info("test2") + l.Warn("test3") + l.Error(" test4") + + // Output: + // - [DEBUG] test1 + // - [INFO] test2 + // - [WARNING] test3 + // - [ERROR] test4 +} +func ExampleFormatter_Format_show_keys() { + l := logrus.New() + l.SetOutput(os.Stdout) + l.SetLevel(logrus.DebugLevel) + l.SetFormatter(&Formatter{ + NoColors: true, + TimestampFormat: "-", + HideKeys: false, + }) + + ll := l.WithField("category", "rest") + + l.Info("test1") + ll.Info("test2") + + // Output: + // - [INFO] test1 + // - [INFO] [category:rest] test2 +} + +func ExampleFormatter_Format_hide_keys() { + l := logrus.New() + l.SetOutput(os.Stdout) + l.SetLevel(logrus.DebugLevel) + l.SetFormatter(&Formatter{ + NoColors: true, + TimestampFormat: "-", + HideKeys: true, + }) + + ll := l.WithField("category", "rest") + + l.Info("test1") + ll.Info("test2") + + // Output: + // - [INFO] test1 + // - [INFO] [rest] test2 +} + +func ExampleFormatter_Format_sort_order() { + l := logrus.New() + l.SetOutput(os.Stdout) + l.SetLevel(logrus.DebugLevel) + l.SetFormatter(&Formatter{ + NoColors: true, + TimestampFormat: "-", + HideKeys: false, + }) + + ll := l.WithField("component", "main") + lll := ll.WithField("category", "rest") + + l.Info("test1") + ll.Info("test2") + lll.Info("test3") + + // Output: + // - [INFO] test1 + // - [INFO] [component:main] test2 + // - [INFO] [category:rest] [component:main] test3 +} + +func ExampleFormatter_Format_field_order() { + l := logrus.New() + l.SetOutput(os.Stdout) + l.SetLevel(logrus.DebugLevel) + l.SetFormatter(&Formatter{ + NoColors: true, + TimestampFormat: "-", + FieldsOrder: []string{"component", "category"}, + HideKeys: false, + }) + + ll := l.WithField("component", "main") + lll := ll.WithField("category", "rest") + + l.Info("test1") + ll.Info("test2") + lll.Info("test3") + + // Output: + // - [INFO] test1 + // - [INFO] [component:main] test2 + // - [INFO] [component:main] [category:rest] test3 +} + +func ExampleFormatter_Format_no_fields_space() { + l := logrus.New() + l.SetOutput(os.Stdout) + l.SetLevel(logrus.DebugLevel) + l.SetFormatter(&Formatter{ + NoColors: true, + TimestampFormat: "-", + FieldsOrder: []string{"component", "category"}, + HideKeys: false, + NoFieldsSpace: true, + }) + + ll := l.WithField("component", "main") + lll := ll.WithField("category", "rest") + + l.Info("test1") + ll.Info("test2") + lll.Info("test3") + + // Output: + // - [INFO] test1 + // - [INFO][component:main] test2 + // - [INFO][component:main][category:rest] test3 +} + +func ExampleFormatter_Format_no_uppercase_level() { + l := logrus.New() + l.SetOutput(os.Stdout) + l.SetLevel(logrus.DebugLevel) + l.SetFormatter(&Formatter{ + NoColors: true, + TimestampFormat: "-", + FieldsOrder: []string{"component", "category"}, + NoUppercaseLevel: true, + }) + + ll := l.WithField("component", "main") + lll := ll.WithField("category", "rest") + llll := ll.WithField("category", "other") + + l.Debug("test1") + ll.Info("test2") + lll.Warn("test3") + llll.Error("test4") + + // Output: + // - [debu] test1 + // - [info] [component:main] test2 + // - [warn] [component:main] [category:rest] test3 + // - [erro] [component:main] [category:other] test4 +} + +func ExampleFormatter_Format_trim_message() { + l := logrus.New() + l.SetOutput(os.Stdout) + l.SetLevel(logrus.DebugLevel) + l.SetFormatter(&Formatter{ + TrimMessages: true, + NoColors: true, + TimestampFormat: "-", + }) + + l.Debug(" test1 ") + l.Info("test2 ") + l.Warn(" test3") + l.Error(" test4 ") + + // Output: + // - [DEBU] test1 + // - [INFO] test2 + // - [WARN] test3 + // - [ERRO] test4 +} + +func TestFormatter_Format_with_report_caller(t *testing.T) { + output := bytes.NewBuffer([]byte{}) + + l := logrus.New() + l.SetOutput(output) + l.SetLevel(logrus.DebugLevel) + l.SetFormatter(&Formatter{ + NoColors: true, + TimestampFormat: "-", + }) + l.SetReportCaller(true) + + l.Debug("test1") + + line, err := output.ReadString('\n') + if err != nil { + t.Errorf("Cannot read log output: %v", err) + } + + expectedRegExp := "- \\[DEBU\\] test1 \\(.+\\.go:[0-9]+ .+\\)\n$" + match, err := regexp.MatchString( + expectedRegExp, + line, + ) + if err != nil { + t.Errorf("Cannot check regexp: %v", err) + } else if !match { + t.Errorf( + "logger.SetReportCaller(true) output doesn't match, expected: %s to find in: '%s'", + expectedRegExp, + line, + ) + } +} + +func TestFormatter_Format_with_report_caller_and_CallerFirst_true(t *testing.T) { + output := bytes.NewBuffer([]byte{}) + + l := logrus.New() + l.SetOutput(output) + l.SetLevel(logrus.DebugLevel) + l.SetFormatter(&Formatter{ + NoColors: true, + TimestampFormat: "-", + CallerFirst: true, + }) + l.SetReportCaller(true) + + l.Debug("test1") + + line, err := output.ReadString('\n') + if err != nil { + t.Errorf("Cannot read log output: %v", err) + } + + expectedRegExp := "- \\(.+\\.go:[0-9]+ .+\\) \\[DEBU\\] test1\n$" + match, err := regexp.MatchString( + expectedRegExp, + line, + ) + + if err != nil { + t.Errorf("Cannot check regexp: %v", err) + } else if !match { + t.Errorf( + "logger.SetReportCaller(true) output doesn't match, expected: %s to find in: '%s'", + expectedRegExp, + line, + ) + } +} + +func TestFormatter_Format_with_report_caller_and_CustomCallerFormatter(t *testing.T) { + output := bytes.NewBuffer([]byte{}) + + l := logrus.New() + l.SetOutput(output) + l.SetLevel(logrus.DebugLevel) + l.SetFormatter(&Formatter{ + NoColors: true, + TimestampFormat: "-", + CallerFirst: true, + CustomCallerFormatter: func(f *runtime.Frame) string { + s := strings.Split(f.Function, ".") + funcName := s[len(s)-1] + return fmt.Sprintf(" [%s:%d][%s()]", path.Base(f.File), f.Line, funcName) + }, + }) + l.SetReportCaller(true) + + l.Debug("test1") + + line, err := output.ReadString('\n') + if err != nil { + t.Errorf("Cannot read log output: %v", err) + } + + expectedRegExp := "- \\[.+\\.go:[0-9]+\\]\\[.+\\(\\)\\] \\[DEBU\\] test1\n$" + match, err := regexp.MatchString( + expectedRegExp, + line, + ) + if err != nil { + t.Errorf("Cannot check regexp: %v", err) + } else if !match { + t.Errorf( + "logger.SetReportCaller(true) output doesn't match, expected: %s to find in: '%s'", + expectedRegExp, + line, + ) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9731e34 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.corp.kornet35.ru/gopkg/nested + +go 1.25.5 + +require git.corp.kornet35.ru/gopkg/logrus v0.0.0-20260103190809-0c856ce1f510 + +require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0591bbd --- /dev/null +++ b/go.sum @@ -0,0 +1,15 @@ +git.corp.kornet35.ru/gopkg/logrus v0.0.0-20260103190809-0c856ce1f510 h1:Ill+HjUQDH442NPfZfD5Nt5mC+MjomXJjV3/EPi3brU= +git.corp.kornet35.ru/gopkg/logrus v0.0.0-20260103190809-0c856ce1f510/go.mod h1:RwD+Te62on5EpPYJD9BKz+Vp9kVfhA0qpHNkC9IFPgA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=