├── .github ├── CODEOWNERS └── workflows │ └── go.yaml ├── .gitignore ├── _example ├── go.mod ├── main_test.go ├── go.sum └── main.go ├── go.mod ├── slogt ├── testhandler.go ├── testhandler_test.go ├── thandler_test.go └── thandler.go ├── slogm ├── capture_test.go ├── stacktrace.go ├── request_id.go ├── capture.go ├── request_id_test.go ├── trim.go ├── slogm.go ├── stacktrace_test.go ├── secret.go ├── trim_test.go └── secret_test.go ├── LICENSE ├── chain.go ├── fblog ├── internal │ └── misc │ │ ├── queue_test.go │ │ └── queue.go ├── option.go ├── logger.go ├── entry.go └── logger_test.go ├── accum.go ├── .golangci.yml ├── go.sum ├── logger ├── io.go ├── option.go ├── logger_test.go └── logger.go ├── accum_test.go ├── slogx_test.go ├── slogx.go ├── chain_test.go └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @semior001 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | .idea 15 | .vscode 16 | go.work* 17 | -------------------------------------------------------------------------------- /_example/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cappuccinotm/slogx/_example 2 | 3 | go 1.21 4 | 5 | replace github.com/cappuccinotm/slogx => ../ 6 | 7 | require ( 8 | github.com/cappuccinotm/slogx v0.0.0-00010101000000-000000000000 9 | github.com/google/uuid v1.3.0 10 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 11 | ) 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cappuccinotm/slogx 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/stretchr/testify v1.8.2 7 | github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce 8 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /_example/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/cappuccinotm/slogx/slogt" 5 | "log/slog" 6 | "testing" 7 | ) 8 | 9 | func TestSomething(t *testing.T) { 10 | h := slogt.Handler(t, slogt.SplitMultiline) 11 | logger := slog.New(h) 12 | logger.Debug("some single-line message", 13 | slog.String("key", "value"), 14 | slog.Group("group", 15 | slog.String("groupKey", "groupValue"), 16 | )) 17 | logger.Info("some\nmultiline\nmessage", slog.String("key", "value")) 18 | } 19 | -------------------------------------------------------------------------------- /_example/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 3 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 4 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 5 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 6 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= 7 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 8 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 9 | -------------------------------------------------------------------------------- /slogt/testhandler.go: -------------------------------------------------------------------------------- 1 | package slogt 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | ) 7 | 8 | // HandlerFunc is a function that implements Handler interface. 9 | // If consumer uses it with slogx.Accumulator, then it can completely capture log records 10 | // and check them in tests. 11 | type HandlerFunc func(ctx context.Context, rec slog.Record) error 12 | 13 | func (f HandlerFunc) Handle(ctx context.Context, rec slog.Record) error { return f(ctx, rec) } 14 | func (f HandlerFunc) WithAttrs([]slog.Attr) slog.Handler { return f } 15 | func (f HandlerFunc) WithGroup(string) slog.Handler { return f } 16 | func (f HandlerFunc) Enabled(context.Context, slog.Level) bool { return true } 17 | -------------------------------------------------------------------------------- /slogt/testhandler_test.go: -------------------------------------------------------------------------------- 1 | package slogt 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestHandlerFunc_Handle(t *testing.T) { 13 | called := 0 14 | f := HandlerFunc(func(ctx context.Context, rec slog.Record) error { 15 | called++ 16 | assert.Empty(t, rec) 17 | return nil 18 | }) 19 | assert.True(t, f.Enabled(context.Background(), slog.LevelInfo)) 20 | require.NoError(t, f.Handle(context.Background(), slog.Record{})) 21 | require.NoError(t, f.WithGroup("group").Handle(context.Background(), slog.Record{})) 22 | require.NoError(t, f.WithAttrs([]slog.Attr{}).Handle(context.Background(), slog.Record{})) 23 | assert.Equal(t, 3, called) 24 | } 25 | -------------------------------------------------------------------------------- /slogm/capture_test.go: -------------------------------------------------------------------------------- 1 | package slogm 2 | 3 | import ( 4 | "bytes" 5 | "log/slog" 6 | "testing" 7 | 8 | "github.com/cappuccinotm/slogx" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestCapture(t *testing.T) { 14 | ch := make(ChannelCapturer, 2) 15 | buf := bytes.NewBuffer(nil) 16 | h := slog.Handler(slog.NewTextHandler(buf, &slog.HandlerOptions{})) 17 | h = slogx.NewChain(h, Capture(ch)) 18 | lg := slog.New(h) 19 | lg.Info("test", 20 | slog.String("key", "value"), 21 | slog.Group("g1", 22 | slog.String("a", "1"), 23 | ), 24 | ) 25 | 26 | records := ch.Records() 27 | require.NoError(t, ch.Close()) 28 | 29 | assert.Equal(t, []slog.Attr{ 30 | slog.String("key", "value"), 31 | slog.Group("g1", 32 | slog.String("a", "1"), 33 | ), 34 | }, slogx.Attrs(records[0])) 35 | require.NotEmpty(t, buf.String()) 36 | } 37 | -------------------------------------------------------------------------------- /slogm/stacktrace.go: -------------------------------------------------------------------------------- 1 | package slogm 2 | 3 | import ( 4 | "context" 5 | "github.com/cappuccinotm/slogx" 6 | "regexp" 7 | "runtime" 8 | 9 | "log/slog" 10 | ) 11 | 12 | var reTrace = regexp.MustCompile(`.*/slog/logger\.go.*\n`) 13 | 14 | // StacktraceOnError returns a middleware that adds stacktrace to record if level is error. 15 | func StacktraceOnError() slogx.Middleware { 16 | return func(next slogx.HandleFunc) slogx.HandleFunc { 17 | return func(ctx context.Context, rec slog.Record) error { 18 | if rec.Level != slog.LevelError { 19 | return next(ctx, rec) 20 | } 21 | 22 | stackInfo := make([]byte, 1024*1024) 23 | if stackSize := runtime.Stack(stackInfo, false); stackSize > 0 { 24 | traceLines := reTrace.Split(string(stackInfo[:stackSize]), -1) 25 | if len(traceLines) == 0 { 26 | return next(ctx, rec) 27 | } 28 | rec.AddAttrs(slog.String("stacktrace", traceLines[len(traceLines)-1])) 29 | } 30 | 31 | return next(ctx, rec) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /slogm/request_id.go: -------------------------------------------------------------------------------- 1 | // Package slogm contains middlewares for chaining and 2 | // decoupling process of enriching or reducing logs. 3 | package slogm 4 | 5 | import ( 6 | "context" 7 | "github.com/cappuccinotm/slogx" 8 | 9 | "log/slog" 10 | ) 11 | 12 | type requestIDKey struct{} 13 | 14 | // ContextWithRequestID returns a new context with the given request ID. 15 | func ContextWithRequestID(parent context.Context, reqID string) context.Context { 16 | return context.WithValue(parent, requestIDKey{}, reqID) 17 | } 18 | 19 | // RequestIDFromContext returns request id from context. 20 | func RequestIDFromContext(ctx context.Context) (string, bool) { 21 | v, ok := ctx.Value(requestIDKey{}).(string) 22 | return v, ok 23 | } 24 | 25 | // RequestID returns a middleware that adds request id to record. 26 | func RequestID() slogx.Middleware { 27 | return func(next slogx.HandleFunc) slogx.HandleFunc { 28 | return func(ctx context.Context, rec slog.Record) error { 29 | if reqID, ok := RequestIDFromContext(ctx); ok { 30 | rec.AddAttrs(slog.String(slogx.RequestIDKey, reqID)) 31 | } 32 | return next(ctx, rec) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 CappuccinoTeam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /chain.go: -------------------------------------------------------------------------------- 1 | package slogx 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | ) 7 | 8 | // Chain is a chain of middleware. 9 | type Chain struct { 10 | mws []Middleware 11 | slog.Handler 12 | } 13 | 14 | // NewChain returns a new Chain with the given middleware. 15 | func NewChain(base slog.Handler, mws ...Middleware) *Chain { 16 | return &Chain{mws: mws, Handler: base} 17 | } 18 | 19 | // Handle runs the chain of middleware and the handler. 20 | func (c *Chain) Handle(ctx context.Context, rec slog.Record) error { 21 | h := c.Handler.Handle 22 | for i := len(c.mws) - 1; i >= 0; i-- { 23 | h = c.mws[i](h) 24 | } 25 | return h(ctx, rec) 26 | } 27 | 28 | // WithGroup returns a new Chain with the given group. 29 | // It applies middlewares on the top-level handler. 30 | func (c *Chain) WithGroup(group string) slog.Handler { 31 | return &Chain{ 32 | mws: c.mws, 33 | Handler: c.Handler.WithGroup(group), 34 | } 35 | } 36 | 37 | // WithAttrs returns a new Chain with the given attributes. 38 | func (c *Chain) WithAttrs(attrs []slog.Attr) slog.Handler { 39 | return &Chain{ 40 | mws: c.mws, 41 | Handler: c.Handler.WithAttrs(attrs), 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /slogm/capture.go: -------------------------------------------------------------------------------- 1 | package slogm 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/cappuccinotm/slogx" 8 | ) 9 | 10 | // Capturer is an interface for capturing log records. 11 | type Capturer interface { 12 | Push(slog.Record) 13 | Records() []slog.Record 14 | Close() error 15 | } 16 | 17 | // ChannelCapturer is a channel that captures log records. 18 | type ChannelCapturer chan slog.Record 19 | 20 | // Push adds a record to the channel. 21 | func (c ChannelCapturer) Push(rec slog.Record) { c <- rec } 22 | 23 | // Close closes the channel. 24 | func (c ChannelCapturer) Close() error { 25 | close(c) 26 | return nil 27 | } 28 | 29 | // Records returns all captured records for this moment. 30 | func (c ChannelCapturer) Records() []slog.Record { 31 | var records []slog.Record 32 | for len(c) > 0 { 33 | records = append(records, <-c) 34 | } 35 | return records 36 | } 37 | 38 | // Capture returns a middleware that captures log records. 39 | func Capture(capt Capturer) slogx.Middleware { 40 | return func(next slogx.HandleFunc) slogx.HandleFunc { 41 | return func(ctx context.Context, rec slog.Record) error { 42 | capt.Push(rec) 43 | return next(ctx, rec) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /slogm/request_id_test.go: -------------------------------------------------------------------------------- 1 | package slogm 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "log/slog" 10 | ) 11 | 12 | func TestRequestID(t *testing.T) { 13 | ctx := context.WithValue(context.Background(), requestIDKey{}, "test") 14 | mw := RequestID() 15 | found := false 16 | h := mw(func(ctx context.Context, rec slog.Record) error { 17 | rec.Attrs(func(attr slog.Attr) bool { 18 | if attr.Key == "request_id" && attr.Value.String() == "test" { 19 | found = true 20 | return false 21 | } 22 | return true 23 | }) 24 | return nil 25 | }) 26 | 27 | err := h(ctx, slog.Record{}) 28 | require.NoError(t, err) 29 | assert.True(t, found) 30 | } 31 | 32 | func TestContextWithRequestID(t *testing.T) { 33 | ctx := context.Background() 34 | ctx = ContextWithRequestID(ctx, "test") 35 | v, ok := ctx.Value(requestIDKey{}).(string) 36 | require.True(t, ok) 37 | assert.Equal(t, "test", v) 38 | } 39 | 40 | func TestRequestIDFromContext(t *testing.T) { 41 | ctx := context.Background() 42 | ctx = context.WithValue(ctx, requestIDKey{}, "test") 43 | v, ok := RequestIDFromContext(ctx) 44 | require.True(t, ok) 45 | assert.Equal(t, "test", v) 46 | } 47 | -------------------------------------------------------------------------------- /slogm/trim.go: -------------------------------------------------------------------------------- 1 | package slogm 2 | 3 | import ( 4 | "context" 5 | "github.com/cappuccinotm/slogx" 6 | "log/slog" 7 | ) 8 | 9 | // TrimAttrs returns a middleware that trims attributes to the provided limit. 10 | // Works only with attributes of type String/[]byte or Any. 11 | func TrimAttrs(limit int) slogx.Middleware { 12 | return func(next slogx.HandleFunc) slogx.HandleFunc { 13 | return func(ctx context.Context, rec slog.Record) error { 14 | var nattrs []slog.Attr 15 | hasOversizedAttrs := false 16 | rec.Attrs(func(attr slog.Attr) bool { 17 | nattr, trimmed := trim(limit, attr) 18 | nattrs = append(nattrs, nattr) 19 | hasOversizedAttrs = hasOversizedAttrs || trimmed 20 | return true 21 | }) 22 | 23 | if !hasOversizedAttrs { 24 | return next(ctx, rec) 25 | } 26 | 27 | nrec := slog.NewRecord(rec.Time, rec.Level, rec.Message, rec.PC) 28 | nrec.AddAttrs(nattrs...) 29 | 30 | return next(ctx, nrec) 31 | } 32 | } 33 | } 34 | 35 | func trim(limit int, attr slog.Attr) (res slog.Attr, trimmed bool) { 36 | attr.Value = attr.Value.Resolve() 37 | 38 | str, ok := stringValue(attr) 39 | if !ok { 40 | return attr, false 41 | } 42 | 43 | if len(str) > limit { 44 | str = str[:limit] + "..." 45 | } 46 | 47 | return slog.String(attr.Key, str), true 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Install go 19 | uses: actions/setup-go@v1 20 | with: 21 | go-version: 1.21 22 | 23 | - name: Run golangci-lint 24 | uses: golangci/golangci-lint-action@v2 25 | with: 26 | version: v1.54.1 27 | skip-go-installation: true 28 | 29 | - name: Run tests and extract coverage 30 | run: | 31 | go test -timeout=60s -covermode=count -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./... 32 | cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "mock_" > $GITHUB_WORKSPACE/profile.cov 33 | env: 34 | CGO_ENABLED: 0 35 | 36 | - name: Submit coverage to codecov 37 | run: | 38 | cat $GITHUB_WORKSPACE/profile.cov > $GITHUB_WORKSPACE/coverage.txt 39 | cd $GITHUB_WORKSPACE 40 | bash <(curl -s https://codecov.io/bash) 41 | env: 42 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 43 | GOFLAGS: "-mod=mod" 44 | CGO_ENABLED: 0 -------------------------------------------------------------------------------- /slogm/slogm.go: -------------------------------------------------------------------------------- 1 | package slogm 2 | 3 | import ( 4 | "encoding" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "reflect" 9 | ) 10 | 11 | func stringValue(attr slog.Attr) (str string, ok bool) { 12 | switch attr.Value.Kind() { 13 | case slog.KindString: 14 | str = attr.Value.String() 15 | case slog.KindAny: 16 | a := attr.Value.Any() 17 | 18 | if tm, ok := a.(encoding.TextMarshaler); ok { 19 | data, _ := tm.MarshalText() 20 | str = string(data) 21 | break 22 | } 23 | 24 | if jm, ok := a.(json.Marshaler); ok { 25 | data, _ := jm.MarshalJSON() 26 | str = string(data) 27 | break 28 | } 29 | 30 | if bs, ok := byteSlice(a); ok { 31 | str = string(bs) 32 | break 33 | } 34 | 35 | str = fmt.Sprintf("%+v", a) 36 | default: 37 | return "", false 38 | } 39 | 40 | return str, true 41 | } 42 | 43 | // byteSlice returns its argument as a []byte if the argument's 44 | // underlying type is []byte, along with a second return value of true. 45 | // Otherwise, it returns nil, false. 46 | func byteSlice(a any) ([]byte, bool) { 47 | if bs, ok := a.([]byte); ok { 48 | return bs, true 49 | } 50 | // Like Printf's %s, we allow both the slice type and the byte element type to be named. 51 | t := reflect.TypeOf(a) 52 | if t != nil && t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 { 53 | return reflect.ValueOf(a).Bytes(), true 54 | } 55 | return nil, false 56 | } 57 | -------------------------------------------------------------------------------- /fblog/internal/misc/queue_test.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestQueue(t *testing.T) { 10 | t.Run("simple rotation", func(t *testing.T) { 11 | q := NewQueue[int](9) 12 | q.idx = 3 13 | q.end = 8 14 | q.l = []int{1, 2, 3, 4, 5, 6, 7, 8, 9} 15 | assert.Equal(t, 4, q.PopFront()) 16 | assert.Equal(t, 0, q.idx) 17 | assert.Equal(t, []int{5, 6, 7, 8, 5, 6, 7, 8, 9}, q.l) 18 | assert.Equal(t, 4, q.end) 19 | }) 20 | 21 | t.Run("from the end to start", func(t *testing.T) { 22 | q := NewQueue[int](6) 23 | q.l = []int{0, 1, 2, 3, 4, 5} 24 | q.idx = 4 25 | q.end = 6 26 | g := q.PopFront() 27 | assert.Equal(t, 4, g) 28 | assert.Equal(t, []int{5, 1, 2, 3, 4, 5}, q.l) 29 | assert.Equal(t, 1, q.end) 30 | assert.Equal(t, 0, q.idx) 31 | }) 32 | 33 | t.Run("push-pop", func(t *testing.T) { 34 | q := NewQueue[int](6) 35 | q.PushBack(1) 36 | q.PushBack(2) 37 | q.PushBack(3) 38 | assert.Equal(t, 3, q.Len()) 39 | assert.Equal(t, 1, q.PopFront()) 40 | assert.Equal(t, 2, q.PopFront()) 41 | assert.Equal(t, 3, q.PopFront()) 42 | }) 43 | 44 | t.Run("push to rotated", func(t *testing.T) { 45 | q := NewQueue[int](2) 46 | q.PushBack(1) 47 | q.PushBack(2) 48 | assert.Equal(t, 2, q.Len()) 49 | assert.Equal(t, 1, q.PopFront()) 50 | q.PushBack(3) 51 | assert.Equal(t, 2, q.PopFront()) 52 | assert.Equal(t, 3, q.PopFront()) 53 | q.PushBack(4) 54 | assert.Equal(t, 4, q.PopFront()) 55 | }) 56 | 57 | t.Run("pop from empty queue", func(t *testing.T) { 58 | q := NewQueue[int](6) 59 | assert.Panics(t, func() { q.PopFront() }) 60 | }) 61 | 62 | t.Run("min cap always preset", func(t *testing.T) { 63 | q := NewQueue[int](-10) 64 | assert.Equal(t, minCap, cap(q.l)) 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /accum.go: -------------------------------------------------------------------------------- 1 | package slogx 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | ) 7 | 8 | type payload struct { 9 | group string 10 | attrs []slog.Attr 11 | parent *payload 12 | } 13 | 14 | type accumulator struct { 15 | slog.Handler 16 | last *payload 17 | } 18 | 19 | // Accumulator is a wrapper for slog.Handler that accumulates 20 | // attributes and groups and passes them to the underlying handler 21 | // only on Handle call, instead of logging them immediately. 22 | func Accumulator(h slog.Handler) slog.Handler { 23 | return &accumulator{Handler: h} 24 | } 25 | 26 | // Handle accumulates attributes and groups and then calls the wrapped handler. 27 | func (a *accumulator) Handle(ctx context.Context, rec slog.Record) error { 28 | if a.last != nil { 29 | rec.AddAttrs(a.assemble()...) 30 | } 31 | return a.Handler.Handle(ctx, rec) 32 | } 33 | 34 | // WithAttrs returns a new accumulator with the given attributes. 35 | func (a *accumulator) WithAttrs(attrs []slog.Attr) slog.Handler { 36 | acc := *a // shallow copy 37 | if acc.last == nil { 38 | acc.last = &payload{} 39 | } 40 | acc.last.attrs = append(acc.last.attrs, attrs...) 41 | return &acc 42 | } 43 | 44 | // WithGroup returns a new accumulator with the given group. 45 | func (a *accumulator) WithGroup(group string) slog.Handler { 46 | acc := *a // shallow copy 47 | acc.last = &payload{group: group, parent: acc.last} 48 | return &acc 49 | } 50 | 51 | func (a *accumulator) assemble() (attrs []slog.Attr) { 52 | for p := a.last; p != nil; p = p.parent { 53 | attrs = append(p.attrs, attrs...) 54 | if p.group != "" { 55 | attrs = []slog.Attr{slog.Group(p.group, listAny(attrs)...)} 56 | } 57 | } 58 | return attrs 59 | } 60 | 61 | func listAny(attrs []slog.Attr) []any { 62 | list := make([]any, len(attrs)) 63 | for i, a := range attrs { 64 | list[i] = a 65 | } 66 | return list 67 | } 68 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | govet: 3 | check-shadowing: true 4 | golint: 5 | min-confidence: 0 6 | gocyclo: 7 | min-complexity: 20 8 | maligned: 9 | suggest-new: true 10 | goconst: 11 | min-len: 2 12 | min-occurrences: 2 13 | misspell: 14 | locale: US 15 | lll: 16 | line-length: 140 17 | gocritic: 18 | enabled-tags: 19 | - performance 20 | - style 21 | - experimental 22 | disabled-checks: 23 | - wrapperFunc 24 | - hugeParam 25 | 26 | linters: 27 | enable: 28 | - megacheck 29 | - govet 30 | - unconvert 31 | - unused 32 | - gas 33 | - gocyclo 34 | - misspell 35 | - unparam 36 | - typecheck 37 | - ineffassign 38 | - stylecheck 39 | - gochecknoinits 40 | - exportloopref 41 | - gocritic 42 | - nakedret 43 | - gosimple 44 | - prealloc 45 | fast: false 46 | disable-all: true 47 | 48 | run: 49 | output: 50 | format: tab 51 | skip-dirs: 52 | - vendor 53 | 54 | issues: 55 | exclude-rules: 56 | - text: "should have a package comment, unless it's in another file for this package" 57 | linters: [gosec] 58 | - text: "G505: Blocklisted import crypto/sha1: weak cryptographic primitive" 59 | linters: [gosec] 60 | - text: "Use of weak cryptographic primitive" 61 | linters: [gosec] 62 | - path: _test\.go 63 | text: "Potential Slowloris Attack because ReadHeaderTimeout is not configured in the http.Server" 64 | linters: [gosec] 65 | - path: _test\.go 66 | text: "Deferring unsafe method \"Close\" on type \"io.ReadCloser\"" 67 | linters: [gosec] 68 | - path: fblog 69 | text: "Errors unhandled" 70 | linters: [gosec] 71 | - path: fblog 72 | text: "Use of unsafe calls should be audited" 73 | linters: [gosec] 74 | 75 | exclude-use-default: false -------------------------------------------------------------------------------- /fblog/internal/misc/queue.go: -------------------------------------------------------------------------------- 1 | // Package misc provides miscellaneous data types and functions 2 | // for better processing of log entries. 3 | package misc 4 | 5 | const minCap = 4 6 | 7 | // Queue is an implementation of the list data structure, 8 | // it is a FIFO (first in, first out) data structure over a slice. 9 | type Queue[T any] struct { 10 | l []T 11 | idx int 12 | end int 13 | } 14 | 15 | // NewQueue returns a new Queue. 16 | func NewQueue[T any](capacity int) *Queue[T] { 17 | // assume always that the amount of attrs is not less than minCap 18 | if capacity < minCap { 19 | capacity = minCap 20 | } 21 | 22 | return &Queue[T]{l: make([]T, 0, capacity)} 23 | } 24 | 25 | // Len returns the length of the queue. 26 | func (q *Queue[T]) Len() int { return q.end - q.idx } 27 | 28 | // PushBack adds an element to the end of the queue. 29 | func (q *Queue[T]) PushBack(e T) { 30 | if q.end < len(q.l) { 31 | q.l[q.end] = e 32 | } else { 33 | q.l = append(q.l, e) 34 | } 35 | q.end++ 36 | } 37 | 38 | // PopFront removes the first element from the queue and returns it. 39 | func (q *Queue[T]) PopFront() T { 40 | if q.idx >= q.end { 41 | panic("pop from empty queue") 42 | } 43 | e := q.l[q.idx] 44 | q.idx++ 45 | 46 | // if the index is too far from the beginning of the slice 47 | // (half of the slice or more), then we need to copy the 48 | // remaining elements to the beginning of the slice and reset 49 | // the index, to avoid memory leaks. 50 | if q.idx >= cap(q.l)/2 { 51 | q.shift() 52 | } 53 | 54 | return e 55 | } 56 | 57 | // shift moves the remaining elements to the beginning of the slice 58 | // and resets the index. 59 | func (q *Queue[T]) shift() { 60 | if q.end-q.idx == 0 { 61 | q.idx, q.end = 0, 0 62 | return 63 | } 64 | 65 | for i := 0; i < q.end-q.idx; i++ { 66 | q.l[i] = q.l[q.idx+i] 67 | } 68 | q.end -= q.idx 69 | q.idx = 0 70 | } 71 | -------------------------------------------------------------------------------- /slogt/thandler_test.go: -------------------------------------------------------------------------------- 1 | package slogt 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func Test_TestHandler(t *testing.T) { 14 | t.Run("singleline", func(t *testing.T) { 15 | tm := &testTMock{t: t} 16 | l := slog.New(Handler(tm)) 17 | l.Debug("test", slog.String("key", "value")) 18 | 19 | assert.Len(t, tm.rows, 1, "should be 1 row") 20 | assert.Contains(t, tm.rows[0], "t=") 21 | assert.Contains(t, tm.rows[0], fmt.Sprintf(" l=%s", slog.LevelDebug.String())) 22 | assert.Contains(t, tm.rows[0], " s=thandler_test.go:17") 23 | assert.Contains(t, tm.rows[0], fmt.Sprintf(" %s=test", slog.MessageKey)) 24 | assert.Contains(t, tm.rows[0], " key=value") 25 | 26 | // show how it prints log 27 | l = slog.New(Handler(t)) 28 | l.Debug("test", slog.String("key", "value")) 29 | }) 30 | 31 | t.Run("multiline", func(t *testing.T) { 32 | tm := &testTMock{t: t} 33 | l := slog.New(Handler(tm, SplitMultiline)) 34 | l.Debug("some\nmultiline\nmessage") 35 | assert.Len(t, tm.rows, 4, "should be 4 rows") 36 | assert.Equal(t, "some\nmultiline\nmessage", strings.Join(tm.rows[:3], "\n")) 37 | assert.Contains(t, tm.rows[3], "t=") 38 | assert.Contains(t, tm.rows[3], fmt.Sprintf(" l=%s", slog.LevelDebug.String())) 39 | assert.Contains(t, tm.rows[3], " s=thandler_test.go:34") 40 | assert.Contains(t, tm.rows[3], `msg="message with newlines has been printed to t.Log"`) 41 | }) 42 | } 43 | 44 | type testTMock struct { 45 | t *testing.T 46 | rows []string 47 | } 48 | 49 | func (t *testTMock) Log(args ...any) { 50 | t.t.Helper() 51 | 52 | require.Equal(t.t, 1, len(args), "must be only 1 argument") 53 | row, ok := args[0].(string) 54 | require.True(t.t, ok, "must be string argument") 55 | t.rows = append(t.rows, row) 56 | 57 | t.t.Log(row) 58 | } 59 | 60 | func (t *testTMock) Helper() {} 61 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 5 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 6 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 7 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 8 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 9 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 10 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 11 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 12 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 13 | github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= 14 | github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= 15 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= 16 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 17 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 19 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 20 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 21 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 22 | -------------------------------------------------------------------------------- /logger/io.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "io" 8 | "net" 9 | "net/http" 10 | ) 11 | 12 | // ErrNotHijacker is returned when the underlying ResponseWriter does not 13 | // implement http.Hijacker interface. 14 | var ErrNotHijacker = errors.New("ResponseWriter is not a Hijacker") 15 | 16 | type closerFn struct { 17 | io.Reader 18 | close func() error 19 | } 20 | 21 | func (c *closerFn) Close() error { return c.close() } 22 | 23 | func peek(src io.Reader, limit int64) (rd io.Reader, s string, full bool, err error) { 24 | if limit < 0 { 25 | limit = 0 26 | } 27 | 28 | buf := &bytes.Buffer{} 29 | if _, err = io.CopyN(buf, src, limit+1); err == io.EOF { 30 | str := buf.String() 31 | return buf, str, false, nil 32 | } 33 | if err != nil { 34 | return src, "", false, err 35 | } 36 | 37 | s = buf.String() 38 | s = s[:len(s)-1] 39 | 40 | return io.MultiReader(buf, src), s, true, nil 41 | } 42 | 43 | type responseWriter struct { 44 | http.ResponseWriter 45 | status int 46 | size int 47 | body string 48 | 49 | limit int 50 | } 51 | 52 | // WriteHeader implements http.ResponseWriter and saves status 53 | func (c *responseWriter) WriteHeader(status int) { 54 | c.status = status 55 | c.ResponseWriter.WriteHeader(status) 56 | } 57 | 58 | // Write implements http.ResponseWriter and tracks number of bytes written 59 | func (c *responseWriter) Write(b []byte) (int, error) { 60 | if c.status == 0 { 61 | c.status = 200 62 | } 63 | 64 | if c.limit > 0 { 65 | part := b 66 | if len(b) > c.limit { 67 | part = b[:c.limit] 68 | } 69 | c.body += string(part) 70 | c.limit -= len(part) 71 | } 72 | 73 | n, err := c.ResponseWriter.Write(b) 74 | c.size += n 75 | return n, err 76 | } 77 | 78 | // Flush implements http.Flusher 79 | func (c *responseWriter) Flush() { 80 | if f, ok := c.ResponseWriter.(http.Flusher); ok { 81 | f.Flush() 82 | } 83 | } 84 | 85 | // Hijack implements http.Hijacker 86 | func (c *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 87 | if hj, ok := c.ResponseWriter.(http.Hijacker); ok { 88 | return hj.Hijack() 89 | } 90 | return nil, nil, ErrNotHijacker 91 | } 92 | -------------------------------------------------------------------------------- /accum_test.go: -------------------------------------------------------------------------------- 1 | package slogx 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "testing" 7 | 8 | "github.com/cappuccinotm/slogx/slogt" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestAccumulator_Handle(t *testing.T) { 14 | t.Run("test", func(t *testing.T) { 15 | lg := slog.New(slogt.Handler(t)) 16 | lg.WithGroup("abacaba").Info("test") 17 | }) 18 | 19 | t.Run("accumulate only attributes", func(t *testing.T) { 20 | acc := Accumulator(slogt.HandlerFunc(func(ctx context.Context, rec slog.Record) error { 21 | var attrs []slog.Attr 22 | rec.Attrs(func(attr slog.Attr) bool { 23 | attrs = append(attrs, attr) 24 | return true 25 | }) 26 | assert.Equal(t, []slog.Attr{ 27 | slog.String("c", "3"), 28 | slog.String("d", "4"), 29 | slog.String("a", "1"), 30 | slog.String("b", "2"), 31 | }, attrs) 32 | return nil 33 | })) 34 | 35 | err := acc. 36 | WithAttrs([]slog.Attr{ 37 | slog.String("c", "3"), 38 | slog.String("d", "4"), 39 | }). 40 | WithAttrs([]slog.Attr{ 41 | slog.String("a", "1"), 42 | slog.String("b", "2"), 43 | }). 44 | Handle(context.Background(), slog.Record{}) 45 | assert.NoError(t, err) 46 | }) 47 | 48 | t.Run("accumulate groups and attributes", func(t *testing.T) { 49 | acc := Accumulator(slogt.HandlerFunc(func(ctx context.Context, rec slog.Record) error { 50 | var attrs []slog.Attr 51 | rec.Attrs(func(attr slog.Attr) bool { 52 | attrs = append(attrs, attr) 53 | return true 54 | }) 55 | if !assert.Equal(t, []slog.Attr{ 56 | slog.Group("g1", 57 | slog.String("a", "1"), 58 | slog.String("b", "2"), 59 | slog.Group("g2", 60 | slog.String("c", "3"), 61 | slog.String("d", "4"), 62 | ), 63 | ), 64 | }, attrs) { 65 | require.NoError(t, slogt.Handler(t).Handle(ctx, rec)) 66 | } 67 | return nil 68 | })) 69 | err := acc.WithGroup("g1"). 70 | WithAttrs([]slog.Attr{ 71 | slog.String("a", "1"), 72 | slog.String("b", "2"), 73 | }). 74 | WithGroup("g2"). 75 | WithAttrs([]slog.Attr{ 76 | slog.String("c", "3"), 77 | slog.String("d", "4"), 78 | }).Handle(context.Background(), slog.Record{}) 79 | assert.NoError(t, err) 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /slogx_test.go: -------------------------------------------------------------------------------- 1 | package slogx 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log/slog" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/cappuccinotm/slogx/slogt" 13 | ) 14 | 15 | func TestError(t *testing.T) { 16 | t.Run("actual error", func(t *testing.T) { 17 | err := errors.New("test") 18 | attr := Error(err) 19 | assert.Equal(t, attr.Key, ErrorKey) 20 | assert.Equal(t, attr.Value.String(), err.Error()) 21 | }) 22 | 23 | t.Run("nil error", func(t *testing.T) { 24 | t.Run("LogAttrNone", func(t *testing.T) { 25 | ErrAttrStrategy = LogAttrNone 26 | defer func() { 27 | ErrAttrStrategy = LogAttrAsIs 28 | }() 29 | 30 | attr := Error(nil) 31 | assert.Equal(t, slog.Attr{}, attr) 32 | }) 33 | 34 | t.Run("LogAttrAsIs", func(t *testing.T) { 35 | ErrAttrStrategy = LogAttrAsIs 36 | defer func() { 37 | ErrAttrStrategy = LogAttrAsIs 38 | }() 39 | 40 | attr := Error(nil) 41 | assert.Equal(t, attr.Key, ErrorKey) 42 | assert.Nil(t, attr.Value.Any()) 43 | }) 44 | }) 45 | } 46 | 47 | func TestAttrs(t *testing.T) { 48 | t.Run("empty", func(t *testing.T) { 49 | rec := slog.Record{} 50 | attrs := Attrs(rec) 51 | assert.Empty(t, attrs) 52 | }) 53 | 54 | t.Run("non-empty", func(t *testing.T) { 55 | rec := slog.Record{} 56 | rec.AddAttrs( 57 | slog.String("a", "1"), 58 | slog.String("b", "2"), 59 | ) 60 | attrs := Attrs(rec) 61 | assert.Equal(t, []slog.Attr{ 62 | slog.String("a", "1"), 63 | slog.String("b", "2"), 64 | }, attrs) 65 | }) 66 | } 67 | 68 | func TestApplyHandler(t *testing.T) { 69 | t.Run("error when handler failed", func(t *testing.T) { 70 | mw := ApplyHandler(slogt.HandlerFunc(func(ctx context.Context, rec slog.Record) error { 71 | return errors.New("handler failed") 72 | })) 73 | 74 | err := mw(func(ctx context.Context, record slog.Record) error { 75 | return nil 76 | })(context.Background(), slog.Record{}) 77 | require.Error(t, errors.New("handler failed"), err) 78 | }) 79 | 80 | t.Run("run next middleware", func(t *testing.T) { 81 | mw := ApplyHandler(slogt.HandlerFunc(func(ctx context.Context, rec slog.Record) error { 82 | return nil 83 | })) 84 | 85 | called := false 86 | 87 | err := mw(func(ctx context.Context, record slog.Record) error { 88 | called = true 89 | return nil 90 | })(context.Background(), slog.Record{}) 91 | require.NoError(t, err) 92 | assert.True(t, called, "next middleware must be called") 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /slogt/thandler.go: -------------------------------------------------------------------------------- 1 | // Package slogt provides functions for comfortable using of slog in tests. 2 | package slogt 3 | 4 | import ( 5 | "fmt" 6 | "log/slog" 7 | "strings" 8 | ) 9 | 10 | type testingOpts struct { 11 | splitMultiline bool 12 | } 13 | 14 | // TestingOpt is an option for Handler. 15 | type TestingOpt func(*testingOpts) 16 | 17 | // SplitMultiline enables splitting multiline messages into multiple log lines. 18 | func SplitMultiline(opts *testingOpts) { opts.splitMultiline = true } 19 | 20 | // Handler returns a slog.Handler, that directs all log messages to the 21 | // t.Logf function with the "[slog]" prefix. 22 | // It also shortens some common attributes, like "time" and "level" to "t" and "l" 23 | // and truncates the time to "15:04:05.000" format. 24 | func Handler(t testingT, topts ...TestingOpt) slog.Handler { 25 | t.Helper() 26 | 27 | options := testingOpts{} 28 | for _, opt := range topts { 29 | opt(&options) 30 | } 31 | 32 | handlerOpts := &slog.HandlerOptions{ 33 | AddSource: true, 34 | Level: slog.LevelDebug, 35 | ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { 36 | switch { 37 | case a.Key == slog.TimeKey: // shorten full time to "15:04:05.000" 38 | tt := a.Value.Time() 39 | return slog.String("t", tt.Format("15:04:05.000")) 40 | case a.Key == slog.LevelKey: // shorten "level":"debug" to "l":"debug" 41 | return slog.String("l", a.Value.String()) 42 | case a.Key == slog.SourceKey: // shorten "source":"full/path/to/file.go:123" to "s":"file.go:123" 43 | src := a.Value.Any().(*slog.Source) 44 | file := src.File[strings.LastIndex(src.File, "/")+1:] 45 | return slog.String("s", fmt.Sprintf("%s:%d", file, src.Line)) 46 | case a.Key == slog.MessageKey && options.splitMultiline && 47 | strings.Contains(a.Value.String(), "\n"): // print the multiline message to t.Log, instead of slog 48 | msg := a.Value.String() 49 | lines := strings.Split(msg, "\n") 50 | for _, line := range lines { 51 | t.Log(line) 52 | } 53 | 54 | return slog.String(slog.MessageKey, "message with newlines has been printed to t.Log") 55 | default: 56 | return a 57 | } 58 | }, 59 | } 60 | return slog.NewTextHandler(tWriter{t: t}, handlerOpts) 61 | } 62 | 63 | type testingT interface { 64 | Log(args ...interface{}) 65 | Helper() 66 | } 67 | 68 | type tWriter struct{ t testingT } 69 | 70 | // Write directs the provided bytes to the t.Logf function with the "[slog]" 71 | func (w tWriter) Write(p []byte) (n int, err error) { 72 | w.t.Helper() 73 | 74 | w.t.Log(string(p)) 75 | return len(p), nil 76 | } 77 | -------------------------------------------------------------------------------- /slogx.go: -------------------------------------------------------------------------------- 1 | // Package slogx contains extensions for standard library's slog package. 2 | package slogx 3 | 4 | import ( 5 | "context" 6 | "log/slog" 7 | ) 8 | 9 | // LogAttrStrategy specifies what to do with the attribute that 10 | // is about to be logged. 11 | type LogAttrStrategy uint8 12 | 13 | const ( 14 | // LogAttrNone means that the attribute should not be logged. 15 | LogAttrNone LogAttrStrategy = iota 16 | // LogAttrAsIs means that the attribute should be logged as is. 17 | LogAttrAsIs 18 | ) 19 | 20 | // Common used keys. 21 | var ( 22 | ErrorKey = "error" 23 | RequestIDKey = "request_id" 24 | ) 25 | 26 | // HandleFunc is a function that handles a record. 27 | type HandleFunc func(context.Context, slog.Record) error 28 | 29 | // Middleware is a middleware for logging handler. 30 | type Middleware func(HandleFunc) HandleFunc 31 | 32 | // ErrAttrStrategy specifies how to log errors. 33 | // "AsIs" logs nils, when the error is nil, if you want to not 34 | // log nils, use "None". 35 | // Example: 36 | // 37 | // 2024/01/13 15:20:26 ERROR LogAttrAsIs, error | error="some error" 38 | // 2024/01/13 15:20:26 ERROR LogAttrAsIs, nil | error= 39 | // 2024/01/13 15:20:26 ERROR LogAttrNone, error | error="some error" 40 | // 2024/01/13 15:20:26 ERROR LogAttrNone, nil | 41 | var ErrAttrStrategy = LogAttrAsIs 42 | 43 | // Error returns an attribute with error key. 44 | func Error(err error) slog.Attr { 45 | if err == nil && ErrAttrStrategy == LogAttrNone { 46 | return slog.Attr{} 47 | } 48 | return slog.Any(ErrorKey, err) 49 | } 50 | 51 | // Attrs returns attributes from the given record. 52 | func Attrs(rec slog.Record) []slog.Attr { 53 | var attrs []slog.Attr 54 | rec.Attrs(func(attr slog.Attr) bool { 55 | attrs = append(attrs, attr) 56 | return true 57 | }) 58 | return attrs 59 | } 60 | 61 | // ApplyHandler wraps slog.Handler as Middleware. 62 | func ApplyHandler(handler slog.Handler) Middleware { 63 | return func(next HandleFunc) HandleFunc { 64 | return func(ctx context.Context, rec slog.Record) error { 65 | err := handler.Handle(ctx, rec) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | return next(ctx, rec) 71 | } 72 | } 73 | } 74 | 75 | // NopHandler returns a slog.Handler, that does nothing. 76 | func NopHandler() slog.Handler { return nopHandler{} } 77 | 78 | type nopHandler struct{} 79 | 80 | func (nopHandler) Enabled(context.Context, slog.Level) bool { return false } 81 | func (nopHandler) Handle(context.Context, slog.Record) error { return nil } 82 | func (n nopHandler) WithAttrs([]slog.Attr) slog.Handler { return n } 83 | func (n nopHandler) WithGroup(string) slog.Handler { return n } 84 | -------------------------------------------------------------------------------- /slogm/stacktrace_test.go: -------------------------------------------------------------------------------- 1 | package slogm 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "github.com/cappuccinotm/slogx" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "log/slog" 13 | ) 14 | 15 | func TestStacktraceOnError(t *testing.T) { 16 | t.Run("in chain", func(t *testing.T) { 17 | buf := &bytes.Buffer{} 18 | h := slogx.NewChain(slog.NewJSONHandler(buf, nil), StacktraceOnError()) 19 | 20 | slog.New(h).Error("something bad happened", 21 | slog.String("detail", "oh my! some error occurred"), 22 | ) 23 | var entry struct { 24 | Level string `json:"level"` 25 | Message string `json:"msg"` 26 | Detail string `json:"detail"` 27 | Stack string `json:"stacktrace"` 28 | } 29 | 30 | require.NoError(t, json.NewDecoder(buf).Decode(&entry)) 31 | assert.Equal(t, slog.LevelError.String(), entry.Level) 32 | assert.Equal(t, "something bad happened", entry.Message) 33 | assert.Equal(t, "oh my! some error occurred", entry.Detail) 34 | 35 | t.Log("stacktrace:\n", entry.Stack) 36 | assert.Contains(t, entry.Stack, "github.com/cappuccinotm/slogx/slogm.TestStacktraceOnError") 37 | assert.NotContains(t, entry.Stack, "slogx/chain.go") 38 | }) 39 | 40 | t.Run("error level", func(t *testing.T) { 41 | mw := StacktraceOnError() 42 | 43 | found := false 44 | fn := mw(func(ctx context.Context, rec slog.Record) error { 45 | assert.Equal(t, "oh my! some error occurred", rec.Message) 46 | assert.Equal(t, slog.LevelError, rec.Level) 47 | rec.Attrs(func(attr slog.Attr) bool { 48 | if attr.Key != "stacktrace" { 49 | return true 50 | } 51 | found = true 52 | 53 | v := attr.Value.String() 54 | assert.Contains(t, v, "github.com/cappuccinotm/slogx/slogm.TestStacktraceOnError") 55 | t.Log("stacktrace:\n", v) 56 | return false 57 | }) 58 | return nil 59 | }) 60 | 61 | err := fn(context.Background(), slog.Record{ 62 | Level: slog.LevelError, 63 | Message: "oh my! some error occurred", 64 | }) 65 | require.NoError(t, err) 66 | 67 | assert.True(t, found) 68 | }) 69 | 70 | t.Run("info level", func(t *testing.T) { 71 | mw := StacktraceOnError() 72 | 73 | fn := mw(func(ctx context.Context, rec slog.Record) error { 74 | assert.Equal(t, "everything is normal", rec.Message) 75 | assert.Equal(t, slog.LevelInfo, rec.Level) 76 | rec.Attrs(func(attr slog.Attr) bool { 77 | require.NotEqual(t, "stacktrace", attr.Key) 78 | return true 79 | }) 80 | return nil 81 | }) 82 | 83 | err := fn(context.Background(), slog.Record{ 84 | Level: slog.LevelInfo, 85 | Message: "everything is normal", 86 | }) 87 | require.NoError(t, err) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /fblog/option.go: -------------------------------------------------------------------------------- 1 | package fblog 2 | 3 | import ( 4 | "io" 5 | "log/slog" 6 | ) 7 | 8 | // Option is an option for a Handler. 9 | type Option func(*Handler) 10 | 11 | // WithLevel returns an Option that sets the level of the handler. 12 | func WithLevel(lvl slog.Level) Option { return func(h *Handler) { h.lvl = lvl } } 13 | 14 | // SourceFormat is the source format of the handler. 15 | type SourceFormat uint8 16 | 17 | const ( 18 | // SourceFormatNone is the source format without any source. 19 | SourceFormatNone = 0 20 | 21 | // SourceFormatPos is the short source format, 22 | // e.g. "file:line". 23 | SourceFormatPos = 1 24 | 25 | // SourceFormatFunc is the short source format, 26 | // e.g. "func". 27 | SourceFormatFunc = 2 28 | 29 | // SourceFormatLong is the long source format, 30 | // e.g. "full/file/path:line:func". 31 | SourceFormatLong = 3 32 | ) 33 | 34 | // WithSource returns an Option that sets the source of the handler. 35 | func WithSource(srcFormat SourceFormat) Option { return func(h *Handler) { h.srcFormat = srcFormat } } 36 | 37 | // WithReplaceAttrs returns an Option that sets the function that will replace the attributes. 38 | func WithReplaceAttrs(r func([]string, slog.Attr) slog.Attr) Option { 39 | return func(h *Handler) { h.rep = r } 40 | } 41 | 42 | // WithLogTimeFormat returns an Option that sets the log's individual time format of the handler. 43 | func WithLogTimeFormat(f string) Option { return func(h *Handler) { h.logTimeFmt = f } } 44 | 45 | // WithTimeFormat returns an Option that sets the time format of the handler. 46 | func WithTimeFormat(f string) Option { return func(h *Handler) { h.timeFmt = f } } 47 | 48 | // Out sets the output writer of the handler. 49 | func Out(w io.Writer) Option { return func(h *Handler) { h.out = w } } 50 | 51 | // Err sets the error writer of the handler. 52 | func Err(w io.Writer) Option { return func(h *Handler) { h.err = w } } 53 | 54 | // Predefined key size options. 55 | const ( 56 | // HeaderKeySize looks for the header size of the log entry and 57 | // trims all the attribute keys to fit the header size. 58 | HeaderKeySize = 0 59 | // UnlimitedKeySize seeks for a maximum key size of the log entry 60 | // and formats the timestamp and level accordingly. 61 | UnlimitedKeySize = -1 62 | ) 63 | 64 | // WithMaxKeySize returns an Option that sets the maximum key size of the handler. 65 | // By default, the maximum key size is the "timestamp + level" size, if the key 66 | // is bigger than that it will be trimmed from the left and "..." will be added 67 | // at the beginning. 68 | // Minimum length of the key is 7 (length of the level with braces) and any 69 | // value that falls out of special cases will be set to UnlimitedKeySize. 70 | func WithMaxKeySize(s int) Option { 71 | return func(h *Handler) { 72 | if s < -1 || (s > 0 && s < 7) { 73 | h.maxKeySize = UnlimitedKeySize 74 | } 75 | 76 | h.maxKeySize = s 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /_example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/cappuccinotm/slogx/slogm" 10 | 11 | "github.com/cappuccinotm/slogx" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | func main() { 16 | h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ 17 | AddSource: true, 18 | Level: slog.LevelInfo, 19 | }) 20 | 21 | logger := slog.New(slogx.Accumulator(slogx.NewChain(h, 22 | slogm.RequestID(), 23 | slogm.StacktraceOnError(), 24 | slogm.MaskSecrets("***"), 25 | ))) 26 | 27 | ctx := slogm.ContextWithRequestID(context.Background(), uuid.New().String()) 28 | ctx = slogm.AddSecrets(ctx, "secret") 29 | logger.InfoContext(ctx, 30 | "some message", 31 | slog.String("key", "value"), 32 | ) 33 | 34 | // produces: 35 | // { 36 | // "time": "2023-08-17T02:04:19.281961+06:00", 37 | // "level": "INFO", 38 | // "source": { 39 | // "function": "main.main", 40 | // "file": "/.../github.com/cappuccinotm/slogx/_example/main.go", 41 | // "line": 25 42 | // }, 43 | // "msg": "some message", 44 | // "key": "value", 45 | // "request_id": "bcda1960-fa4d-46b3-9c1b-fec72c7c07a3" 46 | // } 47 | 48 | logger.ErrorContext(ctx, "oh no, an error occurred", 49 | slog.String("details", "some important secret error details"), 50 | slogx.Error(errors.New("some error")), 51 | ) 52 | 53 | // produces: 54 | // { 55 | // "time": "2023-08-17T03:35:21.251385+06:00", 56 | // "level": "ERROR", 57 | // "source": { 58 | // "function": "main.main", 59 | // "file": "/Users/semior/go/src/github.com/cappuccinotm/slogx/_example/main.go", 60 | // "line": 47 61 | // }, 62 | // "msg": "oh no, an error occurred", 63 | // "details": "some important *** error details", 64 | // "error": "some error", 65 | // "request_id": "8ba29407-5d58-4dca-99e9-54528b1ae3f0", 66 | // "stacktrace": "main.main()\n\t/Users/semior/go/src/github.com/cappuccinotm/slogx/_example/main.go:47 +0x4a4\n" 67 | // } 68 | 69 | logger.WithGroup("group1"). 70 | With(slog.String("omg", "the previous example was wrong")). 71 | WithGroup("group2"). 72 | With(slog.String("omg", "this is the right example")). 73 | With(slog.String("key", "value")). 74 | InfoContext(ctx, "some message", 75 | slog.String("key", "value")) 76 | 77 | // produces: 78 | // { 79 | // "time": "2024-02-18T05:02:13.030604+06:00", 80 | // "level": "INFO", 81 | // "source": { 82 | // "function": "main.main", 83 | // "file": "/Users/semior/go/src/github.com/cappuccinotm/slogx/_example/main.go", 84 | // "line": 74 85 | // }, 86 | // "msg": "some message", 87 | // "key": "value", 88 | // "group1": { 89 | // "omg": "the previous example was wrong", 90 | // "group2": { 91 | // "omg": "this is the right example", 92 | // "key": "value" 93 | // } 94 | // }, 95 | // "request_id": "1a34889f-a5b4-464e-9a86-0a30b50376cc" 96 | // } 97 | } 98 | -------------------------------------------------------------------------------- /chain_test.go: -------------------------------------------------------------------------------- 1 | package slogx 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "log/slog" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | type testCtxKey struct{} 15 | 16 | func TestChain_Handle(t *testing.T) { 17 | buf := &bytes.Buffer{} 18 | h := NewChain(slog.NewJSONHandler(buf, nil), 19 | func(next HandleFunc) HandleFunc { 20 | return func(ctx context.Context, record slog.Record) error { 21 | record.AddAttrs(slog.String("a", "1")) 22 | assert.Equal(t, "val", ctx.Value(testCtxKey{})) 23 | assert.Equal(t, "test", record.Message) 24 | return next(ctx, record) 25 | } 26 | }, 27 | func(next HandleFunc) HandleFunc { 28 | return func(ctx context.Context, record slog.Record) error { 29 | record.AddAttrs(slog.String("b", "2")) 30 | containsA := false 31 | record.Attrs(func(attr slog.Attr) bool { 32 | if attr.Key == "a" { 33 | containsA = true 34 | return false 35 | } 36 | return true 37 | }) 38 | assert.True(t, containsA) 39 | return next(ctx, record) 40 | } 41 | }, 42 | ) 43 | 44 | ctx := context.WithValue(context.Background(), testCtxKey{}, "val") 45 | 46 | logger := slog.New(h) 47 | logger.InfoContext(ctx, "test") 48 | 49 | t.Log(buf.String()) 50 | 51 | var entry struct { 52 | Level string `json:"level"` 53 | Msg string `json:"msg"` 54 | A string `json:"a"` 55 | B string `json:"b"` 56 | } 57 | 58 | require.NoError(t, json.NewDecoder(buf).Decode(&entry)) 59 | assert.Equal(t, slog.LevelInfo.String(), entry.Level) 60 | assert.Equal(t, "test", entry.Msg) 61 | assert.Equal(t, "1", entry.A) 62 | assert.Equal(t, "2", entry.B) 63 | } 64 | 65 | func TestChain_WithGroup(t *testing.T) { 66 | buf := &bytes.Buffer{} 67 | h := NewChain(slog.NewJSONHandler(buf, nil), 68 | func(next HandleFunc) HandleFunc { 69 | return func(ctx context.Context, rec slog.Record) error { 70 | rec.Add(slog.String("x-request-id", "x-request-id")) 71 | return next(ctx, rec) 72 | } 73 | }, 74 | ).WithGroup("test-group") 75 | 76 | logger := slog.New(h) 77 | logger.Info("test", slog.String("a", "1")) 78 | 79 | t.Log(buf.String()) 80 | 81 | var entry struct { 82 | TestGroup struct { 83 | A string `json:"a"` 84 | } `json:"test-group"` 85 | XRequestID string `json:"x-request-id"` // without accumulating - chain doesn't capture groups and attributes 86 | } 87 | 88 | require.NoError(t, json.NewDecoder(buf).Decode(&entry)) 89 | assert.Equal(t, "1", entry.TestGroup.A) 90 | assert.Empty(t, entry.XRequestID, "") 91 | } 92 | 93 | func TestChain_WithAttrs(t *testing.T) { 94 | buf := &bytes.Buffer{} 95 | h := NewChain(slog.NewJSONHandler(buf, nil)). 96 | WithAttrs([]slog.Attr{ 97 | slog.String("a", "1"), 98 | slog.String("b", "2"), 99 | }) 100 | 101 | logger := slog.New(h) 102 | logger.Info("test") 103 | 104 | t.Log(buf.String()) 105 | 106 | var entry struct { 107 | A string `json:"a"` 108 | B string `json:"b"` 109 | } 110 | 111 | require.NoError(t, json.NewDecoder(buf).Decode(&entry)) 112 | assert.Equal(t, "1", entry.A) 113 | assert.Equal(t, "2", entry.B) 114 | } 115 | -------------------------------------------------------------------------------- /slogm/secret.go: -------------------------------------------------------------------------------- 1 | package slogm 2 | 3 | import ( 4 | "context" 5 | "github.com/cappuccinotm/slogx" 6 | "log/slog" 7 | "strings" 8 | "sync" 9 | ) 10 | 11 | type secretsKey struct{} 12 | 13 | type secretsContainer struct { 14 | values []string 15 | mu sync.RWMutex 16 | } 17 | 18 | func (c *secretsContainer) Add(secrets ...string) { 19 | c.mu.Lock() 20 | defer c.mu.Unlock() 21 | 22 | c.values = append(c.values, secrets...) 23 | } 24 | 25 | func (c *secretsContainer) Get() []string { 26 | c.mu.RLock() 27 | defer c.mu.RUnlock() 28 | 29 | return c.values 30 | } 31 | 32 | // AddSecrets adds secrets to the context secrets container. 33 | func AddSecrets(ctx context.Context, secret ...string) context.Context { 34 | v, ok := ctx.Value(secretsKey{}).(*secretsContainer) 35 | if !ok { 36 | v = &secretsContainer{} 37 | ctx = context.WithValue(ctx, secretsKey{}, v) 38 | } 39 | 40 | v.Add(secret...) 41 | return ctx 42 | } 43 | 44 | // SecretsFromContext returns secrets from context. 45 | func SecretsFromContext(ctx context.Context) ([]string, bool) { 46 | v, ok := ctx.Value(secretsKey{}).(*secretsContainer) 47 | if !ok { 48 | return nil, false 49 | } 50 | 51 | return v.Get(), true 52 | } 53 | 54 | // MaskSecrets is a middleware that masks secrets (retrieved from context) in logs. 55 | // 56 | // Works only with attributes of type String/[]byte or Any. 57 | // If attribute is of type Any, there will be attempt to match it to: 58 | // - encoding.TextMarshaler 59 | // - json.Marshaler 60 | // - []byte 61 | // if it didn't match to any of these, it will be formatted with %+v and then masked. 62 | // 63 | // Child calls can add secrets to the container only if it's already present in the context, 64 | // so before any call, user should initialize it with the first "AddSecrets" call, e.g.: 65 | // 66 | // ctx = AddSecrets(ctx) 67 | func MaskSecrets(replacement string) slogx.Middleware { 68 | return func(next slogx.HandleFunc) slogx.HandleFunc { 69 | return func(ctx context.Context, rec slog.Record) error { 70 | secrets, ok := SecretsFromContext(ctx) 71 | if !ok { 72 | return next(ctx, rec) 73 | } 74 | 75 | var nattrs []slog.Attr 76 | hasMaskedAttrs := false 77 | rec.Attrs(func(attr slog.Attr) bool { 78 | nattr, trimmed := maskAttr(secrets, replacement, attr) 79 | nattrs = append(nattrs, nattr) 80 | hasMaskedAttrs = hasMaskedAttrs || trimmed 81 | return true 82 | }) 83 | 84 | msg, hasMaskedMessage := mask(secrets, replacement, rec.Message) 85 | 86 | if !hasMaskedAttrs && !hasMaskedMessage { 87 | return next(ctx, rec) 88 | } 89 | 90 | nrec := slog.NewRecord(rec.Time, rec.Level, msg, rec.PC) 91 | nrec.AddAttrs(nattrs...) 92 | 93 | return next(ctx, nrec) 94 | } 95 | } 96 | } 97 | 98 | func maskAttr(secrets []string, replacement string, attr slog.Attr) (res slog.Attr, masked bool) { 99 | attr.Value = attr.Value.Resolve() 100 | 101 | str, ok := stringValue(attr) 102 | if !ok { 103 | return attr, false 104 | } 105 | 106 | str, masked = mask(secrets, replacement, str) 107 | return slog.String(attr.Key, str), masked 108 | } 109 | 110 | func mask(secrets []string, replacement, str string) (res string, masked bool) { 111 | for _, secret := range secrets { 112 | masked = masked || strings.Contains(str, secret) 113 | str = strings.ReplaceAll(str, secret, replacement) 114 | } 115 | return str, masked 116 | } 117 | -------------------------------------------------------------------------------- /logger/option.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/cappuccinotm/slogx" 10 | ) 11 | 12 | // Option is a function that configures a Logger. 13 | type Option func(*Logger) 14 | 15 | // WithUser sets a custom user function. 16 | func WithUser(fn func(*http.Request) (string, error)) Option { 17 | return func(l *Logger) { l.userFn = fn } 18 | } 19 | 20 | // WithBody sets the maximum request & response body length to be logged. 21 | // Zero and negative values mean to not log the body at all. 22 | func WithBody(maxBodySize int) func(l *Logger) { 23 | return func(l *Logger) { l.maxBodySize = maxBodySize } 24 | } 25 | 26 | // WithLogger is a shortcut that sets Log2Slog as the log function 27 | // to log to slog. 28 | func WithLogger(logger *slog.Logger) Option { 29 | return WithLogFn(func(ctx context.Context, parts *LogParts) { 30 | Log2Slog(ctx, parts, logger) 31 | }) 32 | } 33 | 34 | // WithLogFn sets a custom log function. 35 | func WithLogFn(fn func(context.Context, *LogParts)) Option { 36 | return func(l *Logger) { l.logFn = fn } 37 | } 38 | 39 | // WithSanitizeHeaders sets a custom function to sanitize headers. 40 | func WithSanitizeHeaders(fn func(http.Header) map[string]string) Option { 41 | return func(l *Logger) { l.sanitizeHeadersFn = fn } 42 | } 43 | 44 | // WithSanitizeQuery sets a custom function to sanitize query parameters. 45 | func WithSanitizeQuery(fn func(string) string) Option { 46 | return func(l *Logger) { l.sanitizeQueryFn = fn } 47 | } 48 | 49 | // WithMaskIP sets a custom function to mask IP addresses. 50 | func WithMaskIP(fn func(string) string) Option { 51 | return func(l *Logger) { l.maskIPFn = fn } 52 | } 53 | 54 | // Log2Slog is the default log function that logs to slog. 55 | func Log2Slog(ctx context.Context, parts *LogParts, logger *slog.Logger) { 56 | msg := "http server request" 57 | if parts.Client { 58 | msg = "http client request" 59 | } 60 | 61 | reqAttrs := []any{ 62 | slog.String("method", parts.Request.Method), 63 | slog.String("url", parts.Request.URL), 64 | slog.Any("headers", parts.Request.Headers), 65 | } 66 | reqAttrs = appendNotEmpty(reqAttrs, "remote_ip", parts.Request.RemoteIP) 67 | reqAttrs = appendNotEmpty(reqAttrs, "host", parts.Request.Host) 68 | reqAttrs = appendNotEmpty(reqAttrs, "user", parts.Request.User) 69 | reqAttrs = appendNotEmpty(reqAttrs, "body", parts.Request.Body) 70 | 71 | respAttrs := []any{ 72 | slog.Int("status", parts.Response.Status), 73 | slog.Int64("size", parts.Response.Size), 74 | slog.Any("headers", parts.Response.Headers), 75 | } 76 | respAttrs = appendNotEmpty(respAttrs, "body", parts.Response.Body) 77 | if parts.Response.Error != nil { 78 | respAttrs = append(respAttrs, slogx.Error(parts.Response.Error)) 79 | } 80 | 81 | logger.InfoContext(ctx, msg, 82 | slog.Time("start_at", parts.StartAt), 83 | slog.Duration("duration", parts.Duration), 84 | slog.Group("request", reqAttrs...), 85 | slog.Group("response", respAttrs...), 86 | ) 87 | } 88 | 89 | func appendNotEmpty(attrs []any, k, v string) []any { 90 | if v != "" { 91 | return append(attrs, slog.String(k, v)) 92 | } 93 | return attrs 94 | } 95 | 96 | func defaultSanitizeHeaders(headers http.Header) map[string]string { 97 | sanitized := map[string]string{} 98 | for k := range headers { 99 | if k == "Authorization" { 100 | sanitized[k] = "[REDACTED]" 101 | continue 102 | } 103 | sanitized[k] = headers.Get(k) 104 | } 105 | return sanitized 106 | } 107 | 108 | var keysToHide = []string{"password", "passwd", "secret", "credentials", "token"} 109 | 110 | func defaultSanitizeQuery(query string) string { 111 | u, _ := url.ParseQuery(query) 112 | 113 | for _, key := range keysToHide { 114 | if _, ok := u[key]; ok { 115 | u.Set(key, "[REDACTED]") 116 | } 117 | } 118 | 119 | return u.Encode() 120 | } 121 | -------------------------------------------------------------------------------- /fblog/logger.go: -------------------------------------------------------------------------------- 1 | // Package fblog provides slog handler and its options to print logs in 2 | // the fblog-like style. 3 | package fblog 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "os" 11 | "runtime" 12 | "strings" 13 | "sync" 14 | "time" 15 | ) 16 | 17 | // Handler is a handler that prints logs in the fblog-like style, i.e. 18 | // a new line for each attribute. 19 | type Handler struct { 20 | out, err io.Writer 21 | 22 | lvl slog.Level 23 | srcFormat SourceFormat 24 | rep func([]string, slog.Attr) slog.Attr 25 | maxKeySize int 26 | logTimeFmt string 27 | timeFmt string 28 | 29 | // only for child handlers 30 | groups []string 31 | attrs []slog.Attr 32 | 33 | // internal use 34 | lock *sync.Mutex 35 | } 36 | 37 | // NewHandler returns a new Handler. 38 | // The format of the logs entries will be: 39 | // 40 | // : 41 | // : 42 | // .: 43 | // ... 44 | func NewHandler(opts ...Option) *Handler { 45 | h := &Handler{ 46 | out: os.Stdout, err: os.Stderr, 47 | lock: &sync.Mutex{}, 48 | lvl: slog.LevelInfo, 49 | srcFormat: SourceFormatNone, 50 | rep: func(_ []string, a slog.Attr) slog.Attr { return a }, 51 | logTimeFmt: "2006-01-02 15:04:05", 52 | timeFmt: time.RFC3339, 53 | maxKeySize: HeaderKeySize, 54 | } 55 | 56 | for _, opt := range opts { 57 | opt(h) 58 | } 59 | 60 | return h 61 | } 62 | 63 | // Enabled returns true if the level is enabled. 64 | func (h *Handler) Enabled(_ context.Context, level slog.Level) bool { return h.lvl <= level } 65 | 66 | // Handle writes the record to the writer. 67 | func (h *Handler) Handle(_ context.Context, rec slog.Record) error { 68 | e := newEntry(h.timeFmt, h.rep, rec.NumAttrs()+len(h.attrs)+1) // prealloc for source in case 69 | e.WriteHeader(h.logTimeFmt, h.maxKeySize, rec) 70 | rec.AddAttrs(h.attrs...) 71 | 72 | if rec.PC != 0 && h.srcFormat != SourceFormatNone { 73 | frames := runtime.CallersFrames([]uintptr{rec.PC}) 74 | f, _ := frames.Next() 75 | 76 | switch h.srcFormat { 77 | case SourceFormatPos: 78 | f.File = f.File[strings.LastIndex(f.File, "/")+1:] // only the file name 79 | e.WriteAttr([]string{}, slog.String(slog.SourceKey, fmt.Sprintf("%s:%d", f.File, f.Line))) 80 | case SourceFormatFunc: 81 | e.WriteAttr([]string{}, slog.String(slog.SourceKey, f.Function)) 82 | case SourceFormatLong: 83 | f.Function = f.Function[strings.LastIndex(f.Function, "/")+1:] // shorten func name to last pkg 84 | e.WriteAttr([]string{}, slog.String(slog.SourceKey, 85 | fmt.Sprintf("%s:%d:%s", f.File, f.Line, f.Function), 86 | )) 87 | } 88 | } 89 | 90 | var err error 91 | rec.Attrs(func(attr slog.Attr) bool { 92 | e.WriteAttr(h.groups, attr) 93 | return true 94 | }) 95 | if err != nil { 96 | return fmt.Errorf("write attributes: %w", err) 97 | } 98 | 99 | h.lock.Lock() 100 | defer h.lock.Unlock() 101 | 102 | if rec.Level >= slog.LevelWarn { 103 | if _, err = e.WriteTo(h.err); err != nil { 104 | return fmt.Errorf("write entry to the writer: %w", err) 105 | } 106 | return nil 107 | } 108 | 109 | if _, err = e.WriteTo(h.out); err != nil { 110 | return fmt.Errorf("write entry to the writer: %w", err) 111 | } 112 | 113 | return nil 114 | } 115 | 116 | // WithAttrs returns a new Handler with the given attributes. 117 | func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { 118 | hh := *h // shallow copy 119 | hh.attrs = attrs 120 | return &hh 121 | } 122 | 123 | // WithGroup returns a new Handler with the given group. 124 | func (h *Handler) WithGroup(name string) slog.Handler { 125 | hh := *h // shallow copy 126 | hh.groups = make([]string, len(h.groups), len(h.groups)+1) 127 | copy(hh.groups, h.groups) 128 | hh.groups = append(hh.groups, name) 129 | return &hh 130 | } 131 | -------------------------------------------------------------------------------- /fblog/entry.go: -------------------------------------------------------------------------------- 1 | package fblog 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "strings" 9 | 10 | "github.com/cappuccinotm/slogx/fblog/internal/misc" 11 | ) 12 | 13 | type entry struct { 14 | timeFmt string 15 | rep func([]string, slog.Attr) slog.Attr 16 | 17 | // internal use 18 | headerLen int 19 | buf *bytes.Buffer 20 | q *misc.Queue[grouped] 21 | } 22 | 23 | const lvlSize = 7 // length of the level with braces, e.g. "[DEBUG]" 24 | 25 | func newEntry(timeFmt string, rep func([]string, slog.Attr) slog.Attr, numAttrs int) *entry { 26 | return &entry{ 27 | buf: bytes.NewBuffer(nil), 28 | q: misc.NewQueue[grouped](numAttrs), 29 | timeFmt: timeFmt, 30 | rep: rep, 31 | } 32 | } 33 | 34 | func (e *entry) WriteHeader(logTimeFmt string, maxKeySize int, rec slog.Record) { 35 | if logTimeFmt != "" { 36 | tmFmts := rec.Time.Format(logTimeFmt) 37 | maxKeySize -= lvlSize 38 | switch { 39 | case maxKeySize == UnlimitedKeySize-lvlSize: 40 | e.buf.WriteString(tmFmts) // TODO handle the case with expanding space prefixes for a long 41 | case maxKeySize == HeaderKeySize-lvlSize: 42 | e.buf.WriteString(tmFmts) 43 | case len(tmFmts) > maxKeySize: 44 | // trim the timestamp from the left, add "..." at the beginning 45 | e.buf.WriteString("...") 46 | e.buf.WriteString(tmFmts[len(tmFmts)-maxKeySize+3:]) 47 | case maxKeySize > len(tmFmts): 48 | e.spaces(maxKeySize-len(tmFmts), false) 49 | e.buf.WriteString(tmFmts) 50 | default: 51 | e.buf.WriteString(tmFmts) 52 | } 53 | e.buf.WriteString(" ") 54 | } 55 | 56 | switch rec.Level { 57 | case slog.LevelDebug: 58 | e.buf.WriteString("[DEBUG]") 59 | case slog.LevelInfo: 60 | e.buf.WriteString(" [INFO]") 61 | case slog.LevelWarn: 62 | e.buf.WriteString(" [WARN]") 63 | case slog.LevelError: 64 | e.buf.WriteString("[ERROR]") 65 | default: 66 | e.buf.WriteString("[UNKNW]") 67 | } 68 | 69 | e.headerLen = e.buf.Len() 70 | e.buf.WriteString(": ") 71 | e.buf.WriteString(rec.Message) 72 | e.buf.WriteString("\n") 73 | } 74 | 75 | type grouped struct { 76 | group []string 77 | attr slog.Attr 78 | } 79 | 80 | func (e *entry) WriteAttr(group []string, a slog.Attr) { 81 | e.q.PushBack(grouped{group: group, attr: a}) 82 | 83 | for e.q.Len() > 0 { 84 | g := e.q.PopFront() 85 | 86 | groups, attr := g.group, g.attr 87 | attr = e.rep(groups, attr) 88 | attr.Value = attr.Value.Resolve() // resolve the value before writing 89 | 90 | if attr.Value.Kind() == slog.KindGroup { 91 | for _, a := range attr.Value.Group() { 92 | e.q.PushBack(grouped{group: append(groups, attr.Key), attr: a}) 93 | } 94 | continue 95 | } 96 | 97 | e.WriteKey(groups, attr) 98 | e.buf.WriteString(": ") 99 | e.WriteTextValue(attr) 100 | e.buf.WriteString("\n") 101 | } 102 | } 103 | 104 | func (e *entry) WriteTextValue(attr slog.Attr) { 105 | switch attr.Value.Kind() { 106 | case slog.KindString: 107 | _, _ = fmt.Fprintf(e.buf, "%q", attr.Value.String()) // escape the string 108 | case slog.KindTime: 109 | e.buf.WriteString(attr.Value.Time().Format(e.timeFmt)) 110 | case slog.KindDuration: 111 | e.buf.WriteString(attr.Value.Duration().String()) 112 | case slog.KindGroup: 113 | panic("impossible case, group should be resolved to this point, please, file an issue") 114 | default: 115 | _, _ = fmt.Fprintf(e.buf, "%+v", attr.Value.Any()) 116 | } 117 | } 118 | 119 | func (e *entry) WriteKey(groups []string, attr slog.Attr) { 120 | key := &strings.Builder{} 121 | for _, g := range groups { 122 | key.WriteString(g) 123 | key.WriteString(".") 124 | } 125 | key.WriteString(attr.Key) 126 | s := key.String() 127 | 128 | e.buf.Grow(e.headerLen) // preallocate the space for the key 129 | if e.headerLen-key.Len() < 0 { 130 | // trim the key from the left, add "..." at the beginning 131 | e.buf.WriteString("...") 132 | s = s[key.Len()-e.headerLen+3:] 133 | } 134 | 135 | if e.headerLen-key.Len() > 0 { 136 | e.spaces(e.headerLen-key.Len(), true) 137 | } 138 | 139 | e.buf.WriteString(s) 140 | } 141 | 142 | func (e *entry) WriteTo(wr io.Writer) (int64, error) { 143 | // TODO: append offset in case of UnlimitedKeySize 144 | n, err := wr.Write(e.buf.Bytes()) 145 | return int64(n), err 146 | } 147 | 148 | func (e *entry) spaces(n int, alreadyGrown bool) { 149 | if !alreadyGrown { 150 | e.buf.Grow(n) 151 | } 152 | for i := 0; i < n; i++ { 153 | e.buf.WriteByte(' ') 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /logger/logger_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | "log/slog" 16 | ) 17 | 18 | func TestLogger(t *testing.T) { 19 | t.Run("round tripper", func(t *testing.T) { 20 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | defer r.Body.Close() 22 | b, err := io.ReadAll(r.Body) 23 | require.NoError(t, err) 24 | 25 | assert.Equal(t, "hello", string(b), "request body was changed") 26 | 27 | // manually set date to make test deterministic 28 | w.Header().Add("Date", "Fri, 01 Jan 2021 00:00:01 GMT") 29 | w.Header().Add("X-Test-Server", "test") 30 | w.WriteHeader(http.StatusTeapot) 31 | _, _ = w.Write([]byte("hi")) 32 | })) 33 | defer ts.Close() 34 | 35 | buf := &bytes.Buffer{} 36 | l := New( 37 | WithLogger(slog.New(slog.NewJSONHandler(buf, nil))), 38 | WithBody(1024), 39 | WithUser(func(*http.Request) (string, error) { return "username", nil }), 40 | ) 41 | 42 | nowCalled := 0 43 | st := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) 44 | l.now = func() time.Time { 45 | nowCalled++ 46 | switch nowCalled { 47 | case 1: 48 | return st 49 | case 2: 50 | return st.Add(time.Second) 51 | default: 52 | assert.Fail(t, "unexpected call to now(), called %d times", nowCalled) 53 | return time.Time{} 54 | } 55 | } 56 | 57 | cl := ts.Client() 58 | cl.Transport = l.HTTPClientRoundTripper(cl.Transport) 59 | 60 | req, err := http.NewRequest(http.MethodGet, ts.URL+"/foo/bar", strings.NewReader("hello")) 61 | require.NoError(t, err) 62 | 63 | req.Header.Set("X-Test-Client", "test") 64 | 65 | resp, err := cl.Do(req) 66 | require.NoError(t, err) 67 | defer resp.Body.Close() 68 | 69 | b, err := io.ReadAll(resp.Body) 70 | require.NoError(t, err) 71 | 72 | assert.Equal(t, "hi", string(b), "response body was changed") 73 | 74 | t.Logf("log: %s", buf.String()) 75 | 76 | var entry jsonLogEntry 77 | require.NoError(t, json.NewDecoder(buf).Decode(&entry)) 78 | assert.Equal(t, jsonLogEntry{ 79 | Msg: "http client request", 80 | Level: "INFO", 81 | LogParts: LogParts{ 82 | Client: false, // false as this field doesn't fall into logs 83 | Duration: time.Second, 84 | StartAt: st, 85 | Request: &RequestInfo{ 86 | Method: http.MethodGet, 87 | URL: ts.URL + "/foo/bar", 88 | Host: "127.0.0.1", 89 | User: "username", 90 | Headers: map[string]string{"X-Test-Client": "test"}, 91 | Body: "hello", 92 | }, 93 | Response: &ResponseInfo{ 94 | Status: http.StatusTeapot, 95 | Size: 2, 96 | Headers: map[string]string{ 97 | "Date": "Fri, 01 Jan 2021 00:00:01 GMT", 98 | "X-Test-Server": "test", 99 | "Content-Length": "2", 100 | "Content-Type": "text/plain; charset=utf-8", 101 | }, 102 | Body: "hi", 103 | }, 104 | }, 105 | }, entry) 106 | }) 107 | 108 | t.Run("server middleware", func(t *testing.T) { 109 | buf := &bytes.Buffer{} 110 | l := New( 111 | WithLogger(slog.New(slog.NewJSONHandler(buf, nil))), 112 | WithBody(1024), 113 | WithUser(func(*http.Request) (string, error) { return "username", nil }), 114 | ) 115 | 116 | ts := httptest.NewServer(l.HTTPServerMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 117 | defer r.Body.Close() 118 | b, err := io.ReadAll(r.Body) 119 | require.NoError(t, err) 120 | 121 | assert.Equal(t, "hello", string(b), "request body was changed") 122 | 123 | // manually set date to make test deterministic 124 | w.Header().Add("Date", "Fri, 01 Jan 2021 00:00:01 GMT") 125 | w.Header().Add("X-Test-Server", "test") 126 | w.WriteHeader(http.StatusTeapot) 127 | _, _ = w.Write([]byte("hi")) 128 | }))) 129 | defer ts.Close() 130 | 131 | nowCalled := 0 132 | st := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) 133 | l.now = func() time.Time { 134 | nowCalled++ 135 | switch nowCalled { 136 | case 1: 137 | return st 138 | case 2: 139 | return st.Add(time.Second) 140 | default: 141 | assert.Fail(t, "unexpected call to now(), called %d times", nowCalled) 142 | return time.Time{} 143 | } 144 | } 145 | 146 | req, err := http.NewRequest(http.MethodGet, ts.URL+"/foo/bar", strings.NewReader("hello")) 147 | require.NoError(t, err) 148 | 149 | req.Header.Set("X-Test-Client", "test") 150 | 151 | resp, err := ts.Client().Do(req) 152 | require.NoError(t, err) 153 | defer resp.Body.Close() 154 | 155 | b, err := io.ReadAll(resp.Body) 156 | require.NoError(t, err) 157 | 158 | assert.Equal(t, "hi", string(b), "response body was changed") 159 | 160 | t.Logf("log: %s", buf.String()) 161 | 162 | var entry jsonLogEntry 163 | require.NoError(t, json.NewDecoder(buf).Decode(&entry)) 164 | assert.Equal(t, jsonLogEntry{ 165 | Msg: "http server request", 166 | Level: "INFO", 167 | LogParts: LogParts{ 168 | Client: false, // false as this field doesn't fall into logs 169 | Duration: time.Second, 170 | StartAt: st, 171 | Request: &RequestInfo{ 172 | Method: http.MethodGet, 173 | URL: "/foo/bar", 174 | RemoteIP: "127.0.0.1", 175 | Host: "127.0.0.1", 176 | User: "username", 177 | Headers: map[string]string{ 178 | "Accept-Encoding": "gzip", 179 | "Content-Length": "5", 180 | "User-Agent": "Go-http-client/1.1", 181 | "X-Test-Client": "test", 182 | }, 183 | Body: "hello", 184 | }, 185 | Response: &ResponseInfo{ 186 | Status: http.StatusTeapot, 187 | Size: 2, 188 | Headers: map[string]string{ 189 | "Date": "Fri, 01 Jan 2021 00:00:01 GMT", 190 | "X-Test-Server": "test", 191 | }, 192 | Body: "hi", 193 | }, 194 | }, 195 | }, entry) 196 | }) 197 | } 198 | 199 | type jsonLogEntry struct { 200 | Msg string `json:"msg"` 201 | Level string `json:"level"` 202 | LogParts 203 | } 204 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | // Package logger contains a service that provides methods to log HTTP requests 2 | // for both server and client sides. 3 | package logger 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "regexp" 12 | "strings" 13 | "time" 14 | 15 | "github.com/tomasen/realip" 16 | "log/slog" 17 | ) 18 | 19 | // Logger provides methods to log HTTP requests for both server and client sides. 20 | type Logger struct { 21 | logFn func(context.Context, *LogParts) 22 | userFn func(*http.Request) (string, error) 23 | maskIPFn func(string) string 24 | sanitizeHeadersFn func(http.Header) map[string]string 25 | sanitizeQueryFn func(string) string 26 | 27 | maxBodySize int 28 | 29 | // mock functions for testing 30 | now func() time.Time 31 | } 32 | 33 | // New returns a new Logger. 34 | func New(opts ...Option) *Logger { 35 | l := &Logger{ 36 | logFn: func(ctx context.Context, parts *LogParts) { Log2Slog(ctx, parts, slog.Default()) }, 37 | userFn: func(*http.Request) (string, error) { return "", nil }, 38 | maskIPFn: func(ip string) string { return ip }, 39 | sanitizeHeadersFn: defaultSanitizeHeaders, 40 | sanitizeQueryFn: defaultSanitizeQuery, 41 | maxBodySize: 0, 42 | 43 | now: time.Now, 44 | } 45 | for _, opt := range opts { 46 | opt(l) 47 | } 48 | return l 49 | } 50 | 51 | // HTTPClientRoundTripper returns a RoundTripper that logs HTTP requests. 52 | func (l *Logger) HTTPClientRoundTripper(next http.RoundTripper) http.RoundTripper { 53 | return roundTripperFunc(func(req *http.Request) (resp *http.Response, err error) { 54 | reqInfo := l.obtainRequestInfo(req) 55 | start := l.now() 56 | 57 | defer func() { 58 | end := l.now() 59 | 60 | p := &LogParts{ 61 | StartAt: start, 62 | Duration: end.Sub(start), 63 | Request: reqInfo, 64 | Response: &ResponseInfo{}, 65 | Client: true, 66 | } 67 | 68 | p.Response.Error = err 69 | if resp != nil { 70 | resp.Body, p.Response.Body = l.readBody(resp.Body, nil) 71 | p.Response.Status = resp.StatusCode 72 | p.Response.Size = resp.ContentLength 73 | p.Response.Headers = l.sanitizeHeadersFn(resp.Header) 74 | } 75 | 76 | l.logFn(req.Context(), p) 77 | }() 78 | 79 | return next.RoundTrip(req) 80 | }) 81 | } 82 | 83 | // HTTPServerMiddleware returns a middleware that logs HTTP requests. 84 | func (l *Logger) HTTPServerMiddleware(next http.Handler) http.Handler { 85 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 86 | wr := &responseWriter{ResponseWriter: w, limit: l.maxBodySize} 87 | 88 | reqInfo := l.obtainRequestInfo(r) 89 | start := l.now() 90 | 91 | defer func() { 92 | end := l.now() 93 | 94 | p := &LogParts{ 95 | StartAt: start, 96 | Duration: end.Sub(start), 97 | Request: reqInfo, 98 | Response: &ResponseInfo{}, 99 | } 100 | 101 | p.Response.Status = wr.status 102 | p.Response.Size = int64(wr.size) 103 | p.Response.Headers = l.sanitizeHeadersFn(wr.Header()) 104 | p.Response.Body = wr.body 105 | 106 | l.logFn(r.Context(), p) 107 | }() 108 | 109 | next.ServeHTTP(wr, r) 110 | }) 111 | } 112 | 113 | func (l *Logger) obtainRequestInfo(req *http.Request) *RequestInfo { 114 | var reqBody string 115 | req.Body, reqBody = l.readBody(req.Body, req.GetBody) 116 | 117 | u := *req.URL 118 | u.RawQuery = l.sanitizeQueryFn(u.RawQuery) 119 | rawurl := u.String() 120 | if unescURL, err := url.QueryUnescape(rawurl); err == nil { 121 | rawurl = unescURL 122 | } 123 | 124 | ip := l.maskIPFn(realip.FromRequest(req)) 125 | 126 | server := req.URL.Hostname() 127 | if server == "" { 128 | server = strings.Split(req.Host, ":")[0] 129 | } 130 | 131 | user, err := l.userFn(req) 132 | if err != nil { 133 | user = fmt.Sprintf("can't get user: %v", err) 134 | } 135 | 136 | return &RequestInfo{ 137 | Method: req.Method, 138 | URL: rawurl, 139 | RemoteIP: ip, 140 | Host: server, 141 | User: user, 142 | Headers: l.sanitizeHeadersFn(req.Header), 143 | Body: reqBody, 144 | } 145 | } 146 | 147 | var reMultWhtsp = regexp.MustCompile(`[\s\p{Zs}]{2,}`) 148 | 149 | func (l *Logger) readBody(src io.ReadCloser, getBodyFn func() (io.ReadCloser, error)) (r io.ReadCloser, bodyPart string) { 150 | if l.maxBodySize <= 0 { 151 | return src, "" 152 | } 153 | 154 | rd, body, hasMore, err := peek(src, int64(l.maxBodySize)) 155 | if err != nil { 156 | return src, "" 157 | } 158 | 159 | if len(body) > 0 { 160 | body = strings.Replace(body, "\n", " ", -1) 161 | body = reMultWhtsp.ReplaceAllString(body, " ") 162 | } 163 | 164 | if hasMore { 165 | body += "..." 166 | } 167 | 168 | if getBodyFn != nil { 169 | if rd, err := getBodyFn(); err == nil { 170 | return rd, body 171 | } 172 | } 173 | 174 | return &closerFn{Reader: rd, close: src.Close}, body 175 | } 176 | 177 | // LogParts contains the information to be logged. 178 | type LogParts struct { 179 | // Client is true if the logger is used as round tripper. 180 | Client bool `json:"-"` 181 | 182 | Duration time.Duration `json:"duration"` 183 | StartAt time.Time `json:"start_at"` 184 | Request *RequestInfo `json:"request"` 185 | Response *ResponseInfo `json:"response"` 186 | } 187 | 188 | // RequestInfo contains the request information to be logged. 189 | type RequestInfo struct { 190 | Method string `json:"method"` 191 | URL string `json:"url"` 192 | RemoteIP string `json:"remote_ip"` 193 | Host string `json:"host"` 194 | User string `json:"user"` 195 | 196 | Headers map[string]string `json:"headers"` 197 | Body string `json:"body"` 198 | } 199 | 200 | // ResponseInfo contains the response information to be logged. 201 | type ResponseInfo struct { 202 | Status int `json:"status"` 203 | Size int64 `json:"size"` 204 | Error error `json:"error"` 205 | 206 | Headers map[string]string `json:"headers"` 207 | Body string `json:"body"` 208 | } 209 | 210 | type roundTripperFunc func(*http.Request) (*http.Response, error) 211 | 212 | func (rt roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { 213 | return rt(r) 214 | } 215 | -------------------------------------------------------------------------------- /slogm/trim_test.go: -------------------------------------------------------------------------------- 1 | package slogm 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "log/slog" 9 | "net/netip" 10 | "runtime" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestTrimAttrs(t *testing.T) { 16 | t.Run("no attrs", func(t *testing.T) { 17 | mw := TrimAttrs(10) 18 | h := mw(func(ctx context.Context, rec slog.Record) error { 19 | assert.Equal(t, 0, rec.NumAttrs()) 20 | return nil 21 | }) 22 | 23 | err := h(context.Background(), slog.Record{}) 24 | require.NoError(t, err) 25 | }) 26 | 27 | t.Run("not limitable attr", func(t *testing.T) { 28 | mw := TrimAttrs(10) 29 | h := mw(func(ctx context.Context, rec slog.Record) error { 30 | assert.Equal(t, 1, rec.NumAttrs()) 31 | rec.Attrs(func(attr slog.Attr) bool { 32 | assert.Equal(t, "key", attr.Key) 33 | assert.Equal(t, 12345678912.3456789, attr.Value.Float64()) 34 | return true 35 | }) 36 | return nil 37 | }) 38 | 39 | var pcs [1]uintptr 40 | runtime.Callers(2, pcs[:]) 41 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 42 | rec.Add("key", 12345678912.3456789) 43 | err := h(context.Background(), rec) 44 | require.NoError(t, err) 45 | }) 46 | 47 | t.Run("not oversized attr", func(t *testing.T) { 48 | mw := TrimAttrs(10) 49 | h := mw(func(ctx context.Context, rec slog.Record) error { 50 | assert.Equal(t, 1, rec.NumAttrs()) 51 | rec.Attrs(func(attr slog.Attr) bool { 52 | assert.Equal(t, "key", attr.Key) 53 | assert.Equal(t, "value", attr.Value.String()) 54 | return true 55 | }) 56 | return nil 57 | }) 58 | 59 | var pcs [1]uintptr 60 | runtime.Callers(2, pcs[:]) 61 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 62 | rec.Add("key", "value") 63 | 64 | err := h(context.Background(), rec) 65 | require.NoError(t, err) 66 | }) 67 | 68 | t.Run("oversized string attr", func(t *testing.T) { 69 | mw := TrimAttrs(5) 70 | h := mw(func(ctx context.Context, rec slog.Record) error { 71 | assert.Equal(t, 1, rec.NumAttrs()) 72 | rec.Attrs(func(attr slog.Attr) bool { 73 | assert.Equal(t, "key", attr.Key) 74 | assert.Equal(t, "value...", attr.Value.String()) 75 | return true 76 | }) 77 | return nil 78 | }) 79 | 80 | var pcs [1]uintptr 81 | runtime.Callers(2, pcs[:]) 82 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 83 | rec.Add("key", "value_very_long") 84 | 85 | err := h(context.Background(), rec) 86 | require.NoError(t, err) 87 | }) 88 | 89 | t.Run("oversized text marshaler attr", func(t *testing.T) { 90 | mw := TrimAttrs(11) 91 | h := mw(func(ctx context.Context, rec slog.Record) error { 92 | assert.Equal(t, 1, rec.NumAttrs()) 93 | rec.Attrs(func(attr slog.Attr) bool { 94 | assert.Equal(t, "key", attr.Key) 95 | assert.Equal(t, "127.127.127...", attr.Value.String()) 96 | return true 97 | }) 98 | return nil 99 | }) 100 | 101 | var pcs [1]uintptr 102 | runtime.Callers(2, pcs[:]) 103 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 104 | addr, err := netip.ParseAddr("127.127.127.127") 105 | require.NoError(t, err) 106 | rec.Add("key", addr) 107 | 108 | err = h(context.Background(), rec) 109 | require.NoError(t, err) 110 | }) 111 | 112 | t.Run("oversized json marshaler attr", func(t *testing.T) { 113 | mw := TrimAttrs(11) 114 | h := mw(func(ctx context.Context, rec slog.Record) error { 115 | assert.Equal(t, 1, rec.NumAttrs()) 116 | rec.Attrs(func(attr slog.Attr) bool { 117 | assert.Equal(t, "key", attr.Key) 118 | assert.Equal(t, "some very l...", attr.Value.String()) 119 | return true 120 | }) 121 | return nil 122 | }) 123 | 124 | var pcs [1]uintptr 125 | runtime.Callers(2, pcs[:]) 126 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 127 | rec.Add("key", json.RawMessage("some very long string")) 128 | 129 | err := h(context.Background(), rec) 130 | require.NoError(t, err) 131 | }) 132 | 133 | t.Run("oversized byte slice attr", func(t *testing.T) { 134 | mw := TrimAttrs(11) 135 | h := mw(func(ctx context.Context, rec slog.Record) error { 136 | assert.Equal(t, 1, rec.NumAttrs()) 137 | rec.Attrs(func(attr slog.Attr) bool { 138 | assert.Equal(t, "key", attr.Key) 139 | assert.Equal(t, "some very l...", attr.Value.String()) 140 | return true 141 | }) 142 | return nil 143 | }) 144 | 145 | var pcs [1]uintptr 146 | runtime.Callers(2, pcs[:]) 147 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 148 | rec.Add("key", []byte("some very long string")) 149 | 150 | err := h(context.Background(), rec) 151 | require.NoError(t, err) 152 | }) 153 | 154 | t.Run("oversized byte slice (custom named) attr", func(t *testing.T) { 155 | mw := TrimAttrs(11) 156 | h := mw(func(ctx context.Context, rec slog.Record) error { 157 | assert.Equal(t, 1, rec.NumAttrs()) 158 | rec.Attrs(func(attr slog.Attr) bool { 159 | assert.Equal(t, "key", attr.Key) 160 | assert.Equal(t, "some very l...", attr.Value.String()) 161 | return true 162 | }) 163 | return nil 164 | }) 165 | 166 | var pcs [1]uintptr 167 | runtime.Callers(2, pcs[:]) 168 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 169 | type trickyByteSlice []uint8 170 | rec.Add("key", trickyByteSlice("some very long string")) 171 | 172 | err := h(context.Background(), rec) 173 | require.NoError(t, err) 174 | }) 175 | 176 | t.Run("oversized unserializable attr", func(t *testing.T) { 177 | mw := TrimAttrs(5) 178 | h := mw(func(ctx context.Context, rec slog.Record) error { 179 | assert.Equal(t, 1, rec.NumAttrs()) 180 | rec.Attrs(func(attr slog.Attr) bool { 181 | assert.Equal(t, "key", attr.Key) 182 | assert.Equal(t, "{a:12...", attr.Value.String()) 183 | return true 184 | }) 185 | return nil 186 | }) 187 | 188 | type testStruct struct { 189 | a int 190 | b string 191 | } 192 | 193 | var pcs [1]uintptr 194 | runtime.Callers(2, pcs[:]) 195 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 196 | rec.Add("key", testStruct{a: 1234567890, b: "abacaba"}) 197 | 198 | err := h(context.Background(), rec) 199 | require.NoError(t, err) 200 | }) 201 | } 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slogx [![Go Reference](https://pkg.go.dev/badge/github.com/cappuccinotm/slogx.svg)](https://pkg.go.dev/github.com/cappuccinotm/slogx) [![Go](https://github.com/cappuccinotm/slogx/actions/workflows/go.yaml/badge.svg)](https://github.com/cappuccinotm/slogx/actions/workflows/go.yaml) [![codecov](https://codecov.io/gh/cappuccinotm/slogx/branch/master/graph/badge.svg?token=ueQqCRqxxS)](https://codecov.io/gh/cappuccinotm/slogx) 2 | Package slogx contains extensions for standard library's slog package. 3 | 4 | ## Install 5 | ```bash 6 | go get github.com/cappuccinotm/slogx 7 | ``` 8 | 9 | ## Handlers 10 | - `slogx.Accumulator(slog.Handler) slog.Handler` - returns a handler that accumulates attributes and groups from the `WithGroup` and `WithAttrs` calls, to pass them to the underlying handler only on `Handle` call. Allows middlewares to capture the handler-level attributes and groups, but may be consuming. 11 | - `slogx.NopHandler() slog.Handler` - returns a handler that does nothing. Can be used in tests, to disable logging. 12 | - `slog.Chain` - chains the multiple "middlewares" - handlers, which can modify the log entry. 13 | - `slogt.TestHandler` - returns a handler that logs the log entry through `testing.T`'s `Log` function. It will shorten attributes, so the output will be more readable. 14 | - `fblog.Handler` - a handler that logs the log entry in the [fblog-like](https://github.com/brocode/fblog) format, like: 15 | ``` 16 | 2024-02-05 09:11:37 [INFO]: info message 17 | key: 1 18 | some_multi_line_string: "line1\nline2\nline3" 19 | multiline_any: "line1\nline2\nline3" 20 | group.int: 1 21 | group.string: "string" 22 | group.float64: 1.1 23 | group.bool: false 24 | ...some_very_very_long_key: 1 25 | ``` 26 | 27 | Some benchmarks (though this handler wasn't designed for performance, but for comfortable reading of the logs in debug/local mode): 28 | ``` 29 | BenchmarkHandler 30 | BenchmarkHandler/fblog.NewHandler 31 | BenchmarkHandler/fblog.NewHandler-8 1479525 800.9 ns/op 624 B/op 8 allocs/op 32 | BenchmarkHandler/slog.NewJSONHandler 33 | BenchmarkHandler/slog.NewJSONHandler-8 2407322 500.0 ns/op 48 B/op 1 allocs/op 34 | BenchmarkHandler/slog.NewTextHandler 35 | BenchmarkHandler/slog.NewTextHandler-8 2404581 490.0 ns/op 48 B/op 1 allocs/op 36 | ``` 37 | 38 | All the benchmarks were run on a MacBook Pro (14-inch, 2021) with Apple M1 processor, the benchmark contains the only log `lg.Info("message", slog.Int("int", 1))` 39 | 40 | ## Middlewares 41 | - `slogm.RequestID()` - adds a request ID to the context and logs it. 42 | - `slogm.ContextWithRequestID(ctx context.Context, requestID string) context.Context` - adds a request ID to the context. 43 | - `slogm.StacktraceOnError()` - adds a stacktrace to the log entry if log entry's level is ERROR. 44 | - `slogm.TrimAttrs(limit int)` - trims the length of the attributes to `limit`. 45 | - `slogx.ApplyHandler` - wraps slog.Handler as Middleware 46 | - `slogm.MaskSecrets(replacement string)` - masks secrets in logs, which are stored in the context 47 | - `slogm.AddSecrets(ctx context.Context, secret ...string) context.Context` - adds a secret value to the context 48 | - Note: secrets are stored in the context as a pointer to the container object, guarded by a mutex. Child context 49 | can safely add secrets to the context, and the secrets will be available for the parent context, but before 50 | using the secrets container, the container must be initialized in the parent context with this function, e.g.: 51 | ```go 52 | ctx = slogm.AddSecrets(ctx) 53 | ``` 54 | 55 | ## Helpers 56 | - `slogx.Error(err error)` - adds an error to the log entry under "error" key. 57 | 58 | ## Example 59 | 60 | ```go 61 | package main 62 | 63 | import ( 64 | "context" 65 | "errors" 66 | "os" 67 | 68 | "github.com/cappuccinotm/slogx" 69 | "github.com/cappuccinotm/slogx/slogm" 70 | "github.com/google/uuid" 71 | "log/slog" 72 | ) 73 | 74 | func main() { 75 | h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ 76 | AddSource: true, 77 | Level: slog.LevelInfo, 78 | }) 79 | 80 | logger := slog.New(slogx.Accumulator(slogx.NewChain(h, 81 | slogm.RequestID(), 82 | slogm.StacktraceOnError(), 83 | slogm.MaskSecrets("***"), 84 | ))) 85 | 86 | ctx := slogm.ContextWithRequestID(context.Background(), uuid.New().String()) 87 | ctx = slogm.AddSecrets(ctx, "secret") 88 | logger.InfoContext(ctx, 89 | "some message", 90 | slog.String("key", "value"), 91 | ) 92 | 93 | logger.ErrorContext(ctx, "oh no, an error occurred", 94 | slog.String("details", "some important secret error details"), 95 | slogx.Error(errors.New("some error")), 96 | ) 97 | 98 | logger.WithGroup("group1"). 99 | With(slog.String("omg", "the previous example was wrong")). 100 | WithGroup("group2"). 101 | With(slog.String("omg", "this is the right example")). 102 | With(slog.String("key", "value")). 103 | InfoContext(ctx, "some message", 104 | slog.String("key", "value")) 105 | } 106 | ``` 107 | 108 | Produces: 109 | ```json 110 | { 111 | "time": "2023-08-17T02:04:19.281961+06:00", 112 | "level": "INFO", 113 | "source": { 114 | "function": "main.main", 115 | "file": "/.../github.com/cappuccinotm/slogx/_example/main.go", 116 | "line": 25 117 | }, 118 | "msg": "some message", 119 | "key": "value", 120 | "request_id": "bcda1960-fa4d-46b3-9c1b-fec72c7c07a3" 121 | } 122 | ``` 123 | ``` json 124 | { 125 | "time": "2023-08-17T03:35:21.251385+06:00", 126 | "level": "ERROR", 127 | "source": { 128 | "function": "main.main", 129 | "file": "/Users/semior/go/src/github.com/cappuccinotm/slogx/_example/main.go", 130 | "line": 47 131 | }, 132 | "msg": "oh no, an error occurred", 133 | "details": "some important *** error details", 134 | "error": "some error", 135 | "request_id": "8ba29407-5d58-4dca-99e9-54528b1ae3f0", 136 | "stacktrace": "main.main()\n\t/Users/semior/go/src/github.com/cappuccinotm/slogx/_example/main.go:47 +0x4a4\n" 137 | } 138 | ``` 139 | ```json 140 | { 141 | "time": "2024-02-18T05:02:13.030604+06:00", 142 | "level": "INFO", 143 | "source": { 144 | "function": "main.main", 145 | "file": "/Users/semior/go/src/github.com/cappuccinotm/slogx/_example/main.go", 146 | "line": 74 147 | }, 148 | "msg": "some message", 149 | "key": "value", 150 | "group1": { 151 | "omg": "the previous example was wrong", 152 | "group2": { 153 | "omg": "this is the right example", 154 | "key": "value" 155 | } 156 | }, 157 | "request_id": "1a34889f-a5b4-464e-9a86-0a30b50376cc" 158 | } 159 | ``` 160 | 161 | ## Client/Server logger 162 | Package slogx also contains a `logger` package, which provides a `Logger` service, that could be used 163 | as an HTTP server middleware and a `http.RoundTripper`, that logs HTTP requests and responses. 164 | 165 | ### Usage 166 | ```go 167 | l := logger.New( 168 | logger.WithLogger(slog.Default()), 169 | logger.WithBody(1024), 170 | logger.WithUser(func(*http.Request) (string, error) { return "username", nil }), 171 | ) 172 | ``` 173 | 174 | ### Options 175 | - `logger.WithLogger(logger slog.Logger)` - sets the slog logger. 176 | - `logger.WithLogFn(fn func(context.Context, *LogParts))` - sets a custom function to log request and response. 177 | - `logger.WithBody(maxBodySize int)` - logs the request and response body, maximum size of the logged body is set by `maxBodySize`. 178 | - `logger.WithUser(fn func(*http.Request) (string, error))` - sets a function to get the user data from the request. 179 | 180 | ## Testing handler 181 | Library provides a `slogt.TestHandler` function to build a test handler, which will print out the log entries through `testing.T`'s `Log` function. It will shorten attributes, so the output will be more readable. 182 | 183 | ### Usage 184 | ```go 185 | func TestSomething(t *testing.T) { 186 | h := slogt.Handler(t, slogt.SplitMultiline) 187 | logger := slog.New(h) 188 | logger.Info("some\nmultiline\nmessage", slog.String("key", "value")) 189 | } 190 | 191 | // Output: 192 | // === RUN TestSomething 193 | // handler.go:306: t=11:36:28.649 l=DEBUG s=main_test.go:12 msg="some single-line message" key=value group.groupKey=groupValue 194 | // 195 | // testing.go:52: some 196 | // testing.go:52: multiline 197 | // testing.go:52: message 198 | // handler.go:306: t=11:36:28.649 l=INFO s=main_test.go:17 msg="message with newlines has been printed to t.Log" key=value 199 | ``` 200 | -------------------------------------------------------------------------------- /slogm/secret_test.go: -------------------------------------------------------------------------------- 1 | package slogm 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "log/slog" 9 | "net/netip" 10 | "runtime" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestMaskSecrets(t *testing.T) { 16 | t.Run("no attrs", func(t *testing.T) { 17 | mw := MaskSecrets("***") 18 | h := mw(func(ctx context.Context, rec slog.Record) error { 19 | assert.Equal(t, slog.LevelDebug, rec.Level) 20 | assert.Equal(t, "test", rec.Message) 21 | return nil 22 | }) 23 | 24 | var pcs [1]uintptr 25 | runtime.Callers(2, pcs[:]) 26 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 27 | 28 | ctx := context.Background() 29 | ctx = AddSecrets(ctx, "secret") 30 | 31 | err := h(ctx, rec) 32 | require.NoError(t, err) 33 | }) 34 | 35 | t.Run("message contains secrets", func(t *testing.T) { 36 | mw := MaskSecrets("***") 37 | h := mw(func(ctx context.Context, rec slog.Record) error { 38 | assert.Equal(t, 1, rec.NumAttrs()) 39 | assert.Equal(t, "some very *** ***", rec.Message) 40 | rec.Attrs(func(attr slog.Attr) bool { 41 | assert.Equal(t, "key", attr.Key) 42 | assert.Equal(t, "*** value", attr.Value.String()) 43 | return true 44 | }) 45 | return nil 46 | }) 47 | 48 | var pcs [1]uintptr 49 | runtime.Callers(2, pcs[:]) 50 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "some very secret secret", pcs[0]) 51 | rec.Add("key", "secret value") 52 | 53 | ctx := context.Background() 54 | ctx = AddSecrets(ctx, "secret") 55 | 56 | err := h(ctx, rec) 57 | require.NoError(t, err) 58 | }) 59 | 60 | t.Run("no secrets in attrs, message contains secrets", func(t *testing.T) { 61 | mw := MaskSecrets("***") 62 | h := mw(func(ctx context.Context, rec slog.Record) error { 63 | assert.Equal(t, 1, rec.NumAttrs()) 64 | assert.Equal(t, "some very *** ***", rec.Message) 65 | rec.Attrs(func(attr slog.Attr) bool { 66 | assert.Equal(t, "key", attr.Key) 67 | assert.Equal(t, "nons value", attr.Value.String()) 68 | return true 69 | }) 70 | return nil 71 | }) 72 | 73 | var pcs [1]uintptr 74 | runtime.Callers(2, pcs[:]) 75 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "some very secret secret", pcs[0]) 76 | rec.Add("key", "nons value") 77 | 78 | ctx := context.Background() 79 | ctx = AddSecrets(ctx, "secret") 80 | 81 | err := h(ctx, rec) 82 | require.NoError(t, err) 83 | }) 84 | 85 | t.Run("not-stringable attr", func(t *testing.T) { 86 | mw := MaskSecrets("***") 87 | h := mw(func(ctx context.Context, rec slog.Record) error { 88 | assert.Equal(t, 1, rec.NumAttrs()) 89 | rec.Attrs(func(attr slog.Attr) bool { 90 | assert.Equal(t, "key", attr.Key) 91 | assert.Equal(t, 12345678912.3456789, attr.Value.Float64()) 92 | return true 93 | }) 94 | return nil 95 | }) 96 | 97 | var pcs [1]uintptr 98 | runtime.Callers(2, pcs[:]) 99 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 100 | rec.Add("key", 12345678912.3456789) 101 | err := h(context.Background(), rec) 102 | require.NoError(t, err) 103 | }) 104 | 105 | t.Run("attr without secrets", func(t *testing.T) { 106 | mw := MaskSecrets("***") 107 | h := mw(func(ctx context.Context, rec slog.Record) error { 108 | assert.Equal(t, 1, rec.NumAttrs()) 109 | rec.Attrs(func(attr slog.Attr) bool { 110 | assert.Equal(t, "key", attr.Key) 111 | assert.Equal(t, "value", attr.Value.String()) 112 | return true 113 | }) 114 | return nil 115 | }) 116 | 117 | var pcs [1]uintptr 118 | runtime.Callers(2, pcs[:]) 119 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 120 | rec.Add("key", "value") 121 | 122 | ctx := context.Background() 123 | ctx = AddSecrets(ctx, "secret") 124 | 125 | err := h(ctx, rec) 126 | require.NoError(t, err) 127 | }) 128 | 129 | t.Run("string attr with secret", func(t *testing.T) { 130 | mw := MaskSecrets("***") 131 | h := mw(func(ctx context.Context, rec slog.Record) error { 132 | assert.Equal(t, 1, rec.NumAttrs()) 133 | rec.Attrs(func(attr slog.Attr) bool { 134 | assert.Equal(t, "key", attr.Key) 135 | assert.Equal(t, "value_***_long", attr.Value.String()) 136 | return true 137 | }) 138 | return nil 139 | }) 140 | 141 | var pcs [1]uintptr 142 | runtime.Callers(2, pcs[:]) 143 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 144 | rec.Add("key", "value_secret_long") 145 | 146 | ctx := context.Background() 147 | ctx = AddSecrets(ctx, "secret") 148 | 149 | err := h(ctx, rec) 150 | require.NoError(t, err) 151 | }) 152 | 153 | t.Run("text marshaler attr with secret", func(t *testing.T) { 154 | mw := MaskSecrets("***") 155 | h := mw(func(ctx context.Context, rec slog.Record) error { 156 | assert.Equal(t, 1, rec.NumAttrs()) 157 | rec.Attrs(func(attr slog.Attr) bool { 158 | assert.Equal(t, "key", attr.Key) 159 | assert.Equal(t, "***.***", attr.Value.String()) 160 | return true 161 | }) 162 | return nil 163 | }) 164 | 165 | var pcs [1]uintptr 166 | runtime.Callers(2, pcs[:]) 167 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 168 | addr, err := netip.ParseAddr("127.127.127.127") 169 | require.NoError(t, err) 170 | rec.Add("key", addr) 171 | 172 | ctx := context.Background() 173 | ctx = AddSecrets(ctx, "127.127") 174 | 175 | err = h(ctx, rec) 176 | require.NoError(t, err) 177 | }) 178 | 179 | t.Run("json marshaler attr with secret", func(t *testing.T) { 180 | mw := MaskSecrets("***") 181 | h := mw(func(ctx context.Context, rec slog.Record) error { 182 | assert.Equal(t, 1, rec.NumAttrs()) 183 | rec.Attrs(func(attr slog.Attr) bool { 184 | assert.Equal(t, "key", attr.Key) 185 | assert.Equal(t, "some *** string", attr.Value.String()) 186 | return true 187 | }) 188 | return nil 189 | }) 190 | 191 | var pcs [1]uintptr 192 | runtime.Callers(2, pcs[:]) 193 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 194 | rec.Add("key", json.RawMessage("some very long string")) 195 | 196 | ctx := context.Background() 197 | ctx = AddSecrets(ctx, "very long") 198 | 199 | err := h(ctx, rec) 200 | require.NoError(t, err) 201 | }) 202 | 203 | t.Run("byte slice attr with secret", func(t *testing.T) { 204 | mw := MaskSecrets("***") 205 | h := mw(func(ctx context.Context, rec slog.Record) error { 206 | assert.Equal(t, 1, rec.NumAttrs()) 207 | rec.Attrs(func(attr slog.Attr) bool { 208 | assert.Equal(t, "key", attr.Key) 209 | assert.Equal(t, "some *** string", attr.Value.String()) 210 | return true 211 | }) 212 | return nil 213 | }) 214 | 215 | var pcs [1]uintptr 216 | runtime.Callers(2, pcs[:]) 217 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 218 | rec.Add("key", []byte("some very long string")) 219 | 220 | ctx := context.Background() 221 | ctx = AddSecrets(ctx, "very long") 222 | 223 | err := h(ctx, rec) 224 | require.NoError(t, err) 225 | }) 226 | 227 | t.Run("byte slice (custom named) attr with secret", func(t *testing.T) { 228 | mw := MaskSecrets("***") 229 | h := mw(func(ctx context.Context, rec slog.Record) error { 230 | assert.Equal(t, 1, rec.NumAttrs()) 231 | rec.Attrs(func(attr slog.Attr) bool { 232 | assert.Equal(t, "key", attr.Key) 233 | assert.Equal(t, "some *** string", attr.Value.String()) 234 | return true 235 | }) 236 | return nil 237 | }) 238 | 239 | var pcs [1]uintptr 240 | runtime.Callers(2, pcs[:]) 241 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 242 | type trickyByteSlice []uint8 243 | rec.Add("key", trickyByteSlice("some very long string")) 244 | 245 | ctx := context.Background() 246 | ctx = AddSecrets(ctx, "very long") 247 | 248 | err := h(ctx, rec) 249 | require.NoError(t, err) 250 | }) 251 | 252 | t.Run("unserializable attr with secret", func(t *testing.T) { 253 | mw := MaskSecrets("***") 254 | h := mw(func(ctx context.Context, rec slog.Record) error { 255 | assert.Equal(t, 1, rec.NumAttrs()) 256 | rec.Attrs(func(attr slog.Attr) bool { 257 | assert.Equal(t, "key", attr.Key) 258 | assert.Equal(t, "{a:1234567890 b:some***value}", attr.Value.String()) 259 | return true 260 | }) 261 | return nil 262 | }) 263 | 264 | type testStruct struct { 265 | a int 266 | b string 267 | } 268 | 269 | var pcs [1]uintptr 270 | runtime.Callers(2, pcs[:]) 271 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 272 | rec.Add("key", testStruct{a: 1234567890, b: "somesecretvalue"}) 273 | 274 | ctx := context.Background() 275 | ctx = AddSecrets(ctx, "secret") 276 | 277 | err := h(ctx, rec) 278 | require.NoError(t, err) 279 | }) 280 | 281 | t.Run("secret added in the child context", func(t *testing.T) { 282 | mw := MaskSecrets("***") 283 | h := mw(func(ctx context.Context, rec slog.Record) error { 284 | assert.Equal(t, 1, rec.NumAttrs()) 285 | rec.Attrs(func(attr slog.Attr) bool { 286 | assert.Equal(t, "key", attr.Key) 287 | assert.Equal(t, "value_***_***", attr.Value.String()) 288 | return true 289 | }) 290 | return nil 291 | }) 292 | 293 | var pcs [1]uintptr 294 | runtime.Callers(2, pcs[:]) 295 | rec := slog.NewRecord(time.Now(), slog.LevelDebug, "test", pcs[0]) 296 | rec.Add("key", "value_secret_long") 297 | 298 | ctx := context.Background() 299 | ctx = AddSecrets(ctx, "secret") 300 | 301 | type someKey struct{} 302 | AddSecrets(context.WithValue(ctx, someKey{}, "some value"), "long") 303 | 304 | err := h(ctx, rec) 305 | require.NoError(t, err) 306 | }) 307 | } 308 | -------------------------------------------------------------------------------- /fblog/logger_test.go: -------------------------------------------------------------------------------- 1 | package fblog 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "log/slog" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/cappuccinotm/slogx" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestSimple(t *testing.T) { 16 | attrs := []interface{}{ 17 | slog.Int("int", 1), 18 | slog.String("string", "string"), 19 | slog.Float64("float64", 1.1), 20 | slog.Bool("bool", true), 21 | slog.String("some_multi_line_string", "line1\nline2\nline3"), 22 | slog.Any("multiline_any", "line1\nline2\nline3"), 23 | slogx.Error(nil), 24 | slog.Group("group", 25 | slog.Int("int", 1), 26 | slog.String("string", "string"), 27 | slog.Float64("float64", 1.1), 28 | slog.Bool("bool", false), 29 | slog.Group("too_wide_group", 30 | slog.Int("some_very_very_long_key", 1), 31 | ), 32 | ), 33 | } 34 | 35 | t.Run("simple with source", func(t *testing.T) { 36 | t.Run("pos", func(t *testing.T) { 37 | buf := bytes.NewBuffer(nil) 38 | lg := slog.New(NewHandler( 39 | WithLevel(slog.LevelDebug), 40 | WithSource(SourceFormatPos), 41 | Out(buf), 42 | )) 43 | lg.Info("info message", attrs...) 44 | const expected = ` 45 | 2006-01-02 15:04:05 [INFO]: info message 46 | source: "logger_test.go:43" 47 | int: 1 48 | string: "string" 49 | float64: 1.1 50 | bool: true 51 | some_multi_line_string: "line1\nline2\nline3" 52 | multiline_any: "line1\nline2\nline3" 53 | error: 54 | group.int: 1 55 | group.string: "string" 56 | group.float64: 1.1 57 | group.bool: false 58 | ....some_very_very_long_key: 1 59 | ` 60 | assert.Equal(t, expected[1:], correctTimestamps(buf.String())) 61 | }) 62 | 63 | t.Run("pos", func(t *testing.T) { 64 | buf := bytes.NewBuffer(nil) 65 | lg := slog.New(NewHandler( 66 | WithLevel(slog.LevelDebug), 67 | WithSource(SourceFormatFunc), 68 | Out(buf), 69 | )) 70 | lg.Info("info message", attrs...) 71 | const expected = ` 72 | 2006-01-02 15:04:05 [INFO]: info message 73 | source: "github.com/cappuccinotm/slogx/fblog.TestSimple.func1.2" 74 | int: 1 75 | string: "string" 76 | float64: 1.1 77 | bool: true 78 | some_multi_line_string: "line1\nline2\nline3" 79 | multiline_any: "line1\nline2\nline3" 80 | error: 81 | group.int: 1 82 | group.string: "string" 83 | group.float64: 1.1 84 | group.bool: false 85 | ....some_very_very_long_key: 1 86 | ` 87 | assert.Equal(t, expected[1:], correctTimestamps(buf.String())) 88 | }) 89 | 90 | t.Run("long", func(t *testing.T) { 91 | buf := bytes.NewBuffer(nil) 92 | lg := slog.New(NewHandler( 93 | WithLevel(slog.LevelDebug), 94 | WithSource(SourceFormatLong), 95 | Out(buf), 96 | )) 97 | lg.Info("info message", attrs...) 98 | const expected = ` 99 | 2006-01-02 15:04:05 [INFO]: info message 100 | source: "{rootpath}slogx/fblog/logger_test.go:97:fblog.TestSimple.func1.3" 101 | int: 1 102 | string: "string" 103 | float64: 1.1 104 | bool: true 105 | some_multi_line_string: "line1\nline2\nline3" 106 | multiline_any: "line1\nline2\nline3" 107 | error: 108 | group.int: 1 109 | group.string: "string" 110 | group.float64: 1.1 111 | group.bool: false 112 | ....some_very_very_long_key: 1 113 | ` 114 | got := buf.String() 115 | 116 | lines := strings.Split(got, "\n") 117 | pkgPathIdx := strings.Index(lines[1], "slogx/fblog") 118 | t.Logf("got:\n%s", got) 119 | t.Logf("pkgPathIdx: %d", pkgPathIdx) 120 | lines[1] = lines[1][:30] + "{rootpath}" + lines[1][pkgPathIdx:] 121 | got = strings.Join(lines, "\n") 122 | assert.Equal(t, expected[1:], correctTimestamps(got)) 123 | }) 124 | }) 125 | 126 | t.Run("without fixed key size", func(t *testing.T) { 127 | buf := bytes.NewBuffer(nil) 128 | lg := slog.New(NewHandler( 129 | WithLevel(slog.LevelDebug), 130 | Out(buf), Err(buf), 131 | )) 132 | lg.Info("info message", attrs...) 133 | lg.Warn("warn message", attrs...) 134 | lg.Error("error message", attrs...) 135 | lg.Debug("debug message", attrs...) 136 | const expected = ` 137 | 2006-01-02 15:04:05 [INFO]: info message 138 | int: 1 139 | string: "string" 140 | float64: 1.1 141 | bool: true 142 | some_multi_line_string: "line1\nline2\nline3" 143 | multiline_any: "line1\nline2\nline3" 144 | error: 145 | group.int: 1 146 | group.string: "string" 147 | group.float64: 1.1 148 | group.bool: false 149 | ....some_very_very_long_key: 1 150 | 2006-01-02 15:04:05 [WARN]: warn message 151 | int: 1 152 | string: "string" 153 | float64: 1.1 154 | bool: true 155 | some_multi_line_string: "line1\nline2\nline3" 156 | multiline_any: "line1\nline2\nline3" 157 | error: 158 | group.int: 1 159 | group.string: "string" 160 | group.float64: 1.1 161 | group.bool: false 162 | ....some_very_very_long_key: 1 163 | 2006-01-02 15:04:05 [ERROR]: error message 164 | int: 1 165 | string: "string" 166 | float64: 1.1 167 | bool: true 168 | some_multi_line_string: "line1\nline2\nline3" 169 | multiline_any: "line1\nline2\nline3" 170 | error: 171 | group.int: 1 172 | group.string: "string" 173 | group.float64: 1.1 174 | group.bool: false 175 | ....some_very_very_long_key: 1 176 | 2006-01-02 15:04:05 [DEBUG]: debug message 177 | int: 1 178 | string: "string" 179 | float64: 1.1 180 | bool: true 181 | some_multi_line_string: "line1\nline2\nline3" 182 | multiline_any: "line1\nline2\nline3" 183 | error: 184 | group.int: 1 185 | group.string: "string" 186 | group.float64: 1.1 187 | group.bool: false 188 | ....some_very_very_long_key: 1 189 | ` 190 | assert.Equal(t, expected[1:], correctTimestamps(buf.String())) 191 | }) 192 | 193 | t.Run("fixed key size - long", func(t *testing.T) { 194 | buf := bytes.NewBuffer(nil) 195 | lg := slog.New(NewHandler( 196 | WithLevel(slog.LevelDebug), 197 | WithMaxKeySize(100), 198 | Out(buf), 199 | )) 200 | lg.Info("info message", attrs...) 201 | const expected = ` 202 | 2006-01-02 15:04:05 [INFO]: info message 203 | int: 1 204 | string: "string" 205 | float64: 1.1 206 | bool: true 207 | some_multi_line_string: "line1\nline2\nline3" 208 | multiline_any: "line1\nline2\nline3" 209 | error: 210 | group.int: 1 211 | group.string: "string" 212 | group.float64: 1.1 213 | group.bool: false 214 | group.too_wide_group.some_very_very_long_key: 1 215 | ` 216 | assert.Equal(t, expected[1:], correctTimestamps(buf.String())) 217 | }) 218 | 219 | t.Run("fixed key size - short", func(t *testing.T) { 220 | buf := bytes.NewBuffer(nil) 221 | lg := slog.New(NewHandler( 222 | WithLevel(slog.LevelDebug), 223 | WithMaxKeySize(10), 224 | Out(buf), 225 | )) 226 | lg.Info("info message", attrs...) 227 | const expected = ` 228 | ... [INFO]: info message 229 | int: 1 230 | string: "string" 231 | float64: 1.1 232 | bool: true 233 | ...e_string: "line1\nline2\nline3" 234 | ...line_any: "line1\nline2\nline3" 235 | error: 236 | group.int: 1 237 | ...p.string: "string" 238 | ....float64: 1.1 239 | group.bool: false 240 | ...long_key: 1 241 | ` 242 | assert.Equal(t, expected[1:], correctTimestamps(buf.String())) 243 | }) 244 | 245 | t.Run("fixed key size - minimum", func(t *testing.T) { 246 | t.Run("with empty ts", func(t *testing.T) { 247 | buf := bytes.NewBuffer(nil) 248 | lg := slog.New(NewHandler( 249 | WithLevel(slog.LevelDebug), 250 | WithMaxKeySize(7), 251 | WithLogTimeFormat(""), 252 | Out(buf), 253 | )) 254 | lg.Debug("info message", attrs...) 255 | const expected = ` 256 | [DEBUG]: info message 257 | int: 1 258 | string: "string" 259 | float64: 1.1 260 | bool: true 261 | ...ring: "line1\nline2\nline3" 262 | ..._any: "line1\nline2\nline3" 263 | error: 264 | ....int: 1 265 | ...ring: "string" 266 | ...at64: 1.1 267 | ...bool: false 268 | ..._key: 1 269 | ` 270 | assert.Equal(t, expected[1:], correctTimestamps(buf.String())) 271 | }) 272 | 273 | t.Run("with default ts", func(t *testing.T) { 274 | buf := bytes.NewBuffer(nil) 275 | lg := slog.New(NewHandler( 276 | WithLevel(slog.LevelDebug), 277 | WithMaxKeySize(0), 278 | Out(buf), 279 | )) 280 | lg.Debug("info message", attrs...) 281 | const expected = ` 282 | 2006-01-02 15:04:05 [DEBUG]: info message 283 | int: 1 284 | string: "string" 285 | float64: 1.1 286 | bool: true 287 | some_multi_line_string: "line1\nline2\nline3" 288 | multiline_any: "line1\nline2\nline3" 289 | error: 290 | group.int: 1 291 | group.string: "string" 292 | group.float64: 1.1 293 | group.bool: false 294 | ....some_very_very_long_key: 1 295 | ` 296 | assert.Equal(t, expected[1:], correctTimestamps(buf.String())) 297 | }) 298 | }) 299 | 300 | t.Run("with child handler", func(t *testing.T) { 301 | buf := bytes.NewBuffer(nil) 302 | lg := slog.New(NewHandler( 303 | WithLevel(slog.LevelDebug), 304 | Out(buf), 305 | )) 306 | lg = lg.WithGroup("group") 307 | lg = lg.With(slog.Int("grouped_int", 1), 308 | slog.String("grouped_string", "string")) 309 | lg.Info("info message", 310 | slog.Int("ungrouped_int", 1), 311 | slog.String("ungrouped_string", "string"), 312 | ) 313 | 314 | const expected = ` 315 | 2006-01-02 15:04:05 [INFO]: info message 316 | group.ungrouped_int: 1 317 | group.ungrouped_string: "string" 318 | group.grouped_int: 1 319 | group.grouped_string: "string" 320 | ` 321 | assert.Equal(t, expected[1:], correctTimestamps(buf.String())) 322 | }) 323 | 324 | t.Run("with replacer", func(t *testing.T) { 325 | buf := bytes.NewBuffer(nil) 326 | lg := slog.New(NewHandler( 327 | WithLevel(slog.LevelDebug), 328 | WithReplaceAttrs(func(groups []string, a slog.Attr) slog.Attr { 329 | if a.Key == "int" { 330 | return slog.Int("int", 2) 331 | } 332 | return a 333 | }), 334 | Out(buf), 335 | )) 336 | lg.Info("info message", attrs...) 337 | const expected = ` 338 | 2006-01-02 15:04:05 [INFO]: info message 339 | int: 2 340 | string: "string" 341 | float64: 1.1 342 | bool: true 343 | some_multi_line_string: "line1\nline2\nline3" 344 | multiline_any: "line1\nline2\nline3" 345 | error: 346 | group.int: 2 347 | group.string: "string" 348 | group.float64: 1.1 349 | group.bool: false 350 | ....some_very_very_long_key: 1 351 | ` 352 | assert.Equal(t, expected[1:], correctTimestamps(buf.String())) 353 | }) 354 | 355 | t.Run("unlimited key size", func(t *testing.T) { 356 | buf := bytes.NewBuffer(nil) 357 | lg := slog.New(NewHandler( 358 | WithLevel(slog.LevelDebug), 359 | WithMaxKeySize(UnlimitedKeySize), 360 | Out(buf), 361 | )) 362 | lg.Info("some very long message", attrs...) 363 | const expected = ` 364 | 2006-01-02 15:04:05 [INFO]: some very long message 365 | int: 1 366 | string: "string" 367 | float64: 1.1 368 | bool: true 369 | some_multi_line_string: "line1\nline2\nline3" 370 | multiline_any: "line1\nline2\nline3" 371 | error: 372 | group.int: 1 373 | group.string: "string" 374 | group.float64: 1.1 375 | group.bool: false 376 | ....some_very_very_long_key: 1 377 | ` 378 | assert.Equal(t, expected[1:], correctTimestamps(buf.String())) 379 | }) 380 | } 381 | 382 | func correctTimestamps(s string) string { 383 | trimSpaces := func(s string) (result string, spacesCount int) { 384 | for _, c := range s { 385 | if c != ' ' { 386 | break 387 | } 388 | spacesCount++ 389 | } 390 | return s[spacesCount:], spacesCount 391 | } 392 | 393 | // find all timestamps in the s 394 | lines := strings.Split(s, "\n") 395 | for i := range lines { 396 | line, spacesNum := trimSpaces(lines[i]) 397 | if len(line) < 19 { 398 | continue 399 | } 400 | if _, err := time.Parse("2006-01-02 15:04:05", line[:19]); err != nil { 401 | continue 402 | } 403 | lines[i] = `2006-01-02 15:04:05` + line[19:] 404 | lines[i] = strings.Repeat(" ", spacesNum) + lines[i] 405 | } 406 | return strings.Join(lines, "\n") 407 | } 408 | 409 | func BenchmarkHandler(b *testing.B) { 410 | b.Run("fblog.NewHandler", func(b *testing.B) { 411 | b.ReportAllocs() 412 | h := NewHandler(Out(io.Discard)) 413 | lg := slog.New(h) 414 | 415 | b.ResetTimer() 416 | for i := 0; i < b.N; i++ { 417 | lg.Info("message", slog.Int("int", 1)) 418 | } 419 | b.StopTimer() 420 | }) 421 | 422 | b.Run("slog.NewJSONHandler", func(b *testing.B) { 423 | b.ReportAllocs() 424 | h := slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{}) 425 | lg := slog.New(h) 426 | 427 | b.ResetTimer() 428 | for i := 0; i < b.N; i++ { 429 | lg.Info("message", slog.Int("int", 1)) 430 | } 431 | b.StopTimer() 432 | }) 433 | 434 | b.Run("slog.NewTextHandler", func(b *testing.B) { 435 | b.ReportAllocs() 436 | h := slog.NewTextHandler(io.Discard, &slog.HandlerOptions{}) 437 | lg := slog.New(h) 438 | 439 | b.ResetTimer() 440 | for i := 0; i < b.N; i++ { 441 | lg.Info("message", slog.Int("int", 1)) 442 | } 443 | b.StopTimer() 444 | }) 445 | } 446 | --------------------------------------------------------------------------------