├── .github ├── dependabot.yml └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── README.md ├── bench_test.go ├── buffer.go ├── buffer_test.go ├── doc └── img │ ├── output-with-source.png │ └── output.png ├── duration.go ├── duration_test.go ├── encoding.go ├── example └── main.go ├── go.mod ├── handler.go ├── handler_test.go ├── theme.go └── utils_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.21' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -race -coverprofile=coverage.txt -covermode=atomic -v ./... 29 | 30 | - name: Upload coverage to Codecov 31 | uses: codecov/codecov-action@v3 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Pierre-Henri Symoneaux 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # console-slog 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/phsym/console-slog.svg)](https://pkg.go.dev/github.com/phsym/console-slog) [![license](http://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://raw.githubusercontent.com/phsym/console-slog/master/LICENSE) [![Build](https://github.com/phsym/console-slog/actions/workflows/go.yml/badge.svg?branch=main)](https://github.com/phsym/slog-console/actions/workflows/go.yml) [![codecov](https://codecov.io/gh/phsym/console-slog/graph/badge.svg?token=ZIJT9L79QP)](https://codecov.io/gh/phsym/console-slog) [![Go Report Card](https://goreportcard.com/badge/github.com/phsym/console-slog)](https://goreportcard.com/report/github.com/phsym/console-slog) 4 | 5 | A handler for slog that prints colorized logs, similar to zerolog's console writer output without sacrificing performances. 6 | 7 | ## Installation 8 | ```bash 9 | go get github.com/phsym/console-slog@latest 10 | ``` 11 | 12 | ## Example 13 | ```go 14 | package main 15 | 16 | import ( 17 | "errors" 18 | "log/slog" 19 | "os" 20 | 21 | "github.com/phsym/console-slog" 22 | ) 23 | 24 | func main() { 25 | logger := slog.New( 26 | console.NewHandler(os.Stderr, &console.HandlerOptions{Level: slog.LevelDebug}), 27 | ) 28 | slog.SetDefault(logger) 29 | slog.Info("Hello world!", "foo", "bar") 30 | slog.Debug("Debug message") 31 | slog.Warn("Warning message") 32 | slog.Error("Error message", "err", errors.New("the error")) 33 | 34 | logger = logger.With("foo", "bar"). 35 | WithGroup("the-group"). 36 | With("bar", "baz") 37 | 38 | logger.Info("group info", "attr", "value") 39 | } 40 | ``` 41 | 42 | ![output](./doc/img/output.png) 43 | 44 | When setting `console.HandlerOptions.AddSource` to `true`: 45 | ```go 46 | console.NewHandler(os.Stderr, &console.HandlerOptions{Level: slog.LevelDebug, AddSource: true}) 47 | ``` 48 | ![output-with-source](./doc/img/output-with-source.png) 49 | 50 | ## Performances 51 | See [benchmark file](./bench_test.go) for details. 52 | 53 | The handler itself performs quite well compared to std-lib's handlers. It does no allocation: 54 | ``` 55 | goos: linux 56 | goarch: amd64 57 | pkg: github.com/phsym/console-slog 58 | cpu: Intel(R) Core(TM) i5-6300U CPU @ 2.40GHz 59 | BenchmarkHandlers/dummy-4 128931026 8.732 ns/op 0 B/op 0 allocs/op 60 | BenchmarkHandlers/console-4 849837 1294 ns/op 0 B/op 0 allocs/op 61 | BenchmarkHandlers/std-text-4 542583 2097 ns/op 4 B/op 2 allocs/op 62 | BenchmarkHandlers/std-json-4 583784 1911 ns/op 120 B/op 3 allocs/op 63 | ``` 64 | 65 | However, the go 1.21.0 `slog.Logger` adds some overhead: 66 | ``` 67 | goos: linux 68 | goarch: amd64 69 | pkg: github.com/phsym/console-slog 70 | cpu: Intel(R) Core(TM) i5-6300U CPU @ 2.40GHz 71 | BenchmarkLoggers/dummy-4 1239873 893.2 ns/op 128 B/op 1 allocs/op 72 | BenchmarkLoggers/console-4 483354 2338 ns/op 128 B/op 1 allocs/op 73 | BenchmarkLoggers/std-text-4 368828 3141 ns/op 132 B/op 3 allocs/op 74 | BenchmarkLoggers/std-json-4 393322 2909 ns/op 248 B/op 4 allocs/op 75 | ``` -------------------------------------------------------------------------------- /bench_test.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "log/slog" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | type DummyHandler struct{} 13 | 14 | func (*DummyHandler) Enabled(context.Context, slog.Level) bool { return true } 15 | func (*DummyHandler) Handle(context.Context, slog.Record) error { return nil } 16 | func (h *DummyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return h } 17 | func (h *DummyHandler) WithGroup(name string) slog.Handler { return h } 18 | 19 | var handlers = []struct { 20 | name string 21 | hdl slog.Handler 22 | }{ 23 | {"dummy", &DummyHandler{}}, 24 | {"console", NewHandler(io.Discard, &HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, 25 | {"std-text", slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, 26 | {"std-json", slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, 27 | } 28 | 29 | var attrs = []slog.Attr{ 30 | slog.String("foo", "bar"), 31 | slog.Int("int", 12), 32 | slog.Duration("dur", 3*time.Second), 33 | slog.Bool("bool", true), 34 | slog.Float64("float", 23.7), 35 | slog.Time("thetime", time.Now()), 36 | slog.Any("err", errors.New("yo")), 37 | slog.Group("empty"), 38 | slog.Group("group", slog.String("bar", "baz")), 39 | } 40 | 41 | var attrsAny = func() (a []any) { 42 | for _, attr := range attrs { 43 | a = append(a, attr) 44 | } 45 | return 46 | }() 47 | 48 | func BenchmarkHandlers(b *testing.B) { 49 | ctx := context.Background() 50 | rec := slog.NewRecord(time.Now(), slog.LevelInfo, "hello", 0) 51 | rec.AddAttrs(attrs...) 52 | 53 | for _, tc := range handlers { 54 | b.Run(tc.name, func(b *testing.B) { 55 | l := tc.hdl.WithAttrs(attrs).WithGroup("test").WithAttrs(attrs) 56 | // Warm-up 57 | _ = l.Handle(ctx, rec) 58 | b.ResetTimer() 59 | for i := 0; i < b.N; i++ { 60 | _ = l.Handle(ctx, rec) 61 | } 62 | }) 63 | } 64 | } 65 | 66 | func BenchmarkLoggers(b *testing.B) { 67 | for _, tc := range handlers { 68 | ctx := context.Background() 69 | b.Run(tc.name, func(b *testing.B) { 70 | l := slog.New(tc.hdl).With(attrsAny...).WithGroup("test").With(attrsAny...) 71 | // Warm-up 72 | l.LogAttrs(ctx, slog.LevelInfo, "hello", attrs...) 73 | b.ResetTimer() 74 | for i := 0; i < b.N; i++ { 75 | l.LogAttrs(ctx, slog.LevelInfo, "hello", attrs...) 76 | } 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /buffer.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "io" 5 | "slices" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | type buffer []byte 11 | 12 | func (b *buffer) Grow(n int) { 13 | *b = slices.Grow(*b, n) 14 | } 15 | 16 | func (b *buffer) Bytes() []byte { 17 | return *b 18 | } 19 | 20 | func (b *buffer) String() string { 21 | return string(*b) 22 | } 23 | 24 | func (b *buffer) Len() int { 25 | return len(*b) 26 | } 27 | 28 | func (b *buffer) Cap() int { 29 | return cap(*b) 30 | } 31 | 32 | func (b *buffer) WriteTo(dst io.Writer) (int64, error) { 33 | l := len(*b) 34 | if l == 0 { 35 | return 0, nil 36 | } 37 | n, err := dst.Write(*b) 38 | if err != nil { 39 | return int64(n), err 40 | } 41 | if n < l { 42 | return int64(n), io.ErrShortWrite 43 | } 44 | b.Reset() 45 | return int64(n), nil 46 | } 47 | 48 | func (b *buffer) Reset() { 49 | *b = (*b)[:0] 50 | } 51 | 52 | func (b *buffer) Clone() buffer { 53 | return append(buffer(nil), *b...) 54 | } 55 | 56 | func (b *buffer) Clip() { 57 | *b = slices.Clip(*b) 58 | } 59 | 60 | func (b *buffer) copy(src *buffer) { 61 | if src.Len() > 0 { 62 | b.Append(src.Bytes()) 63 | } 64 | } 65 | 66 | func (b *buffer) Append(data []byte) { 67 | *b = append(*b, data...) 68 | } 69 | 70 | func (b *buffer) AppendString(s string) { 71 | *b = append(*b, s...) 72 | } 73 | 74 | // func (b *buffer) AppendQuotedString(s string) { 75 | // b.buff = strconv.AppendQuote(b.buff, s) 76 | // } 77 | 78 | func (b *buffer) AppendByte(byt byte) { 79 | *b = append(*b, byt) 80 | } 81 | 82 | func (b *buffer) AppendTime(t time.Time, format string) { 83 | *b = t.AppendFormat(*b, format) 84 | } 85 | 86 | func (b *buffer) AppendInt(i int64) { 87 | *b = strconv.AppendInt(*b, i, 10) 88 | } 89 | 90 | func (b *buffer) AppendUint(i uint64) { 91 | *b = strconv.AppendUint(*b, i, 10) 92 | } 93 | 94 | func (b *buffer) AppendFloat(i float64) { 95 | *b = strconv.AppendFloat(*b, i, 'g', -1, 64) 96 | } 97 | 98 | func (b *buffer) AppendBool(i bool) { 99 | *b = strconv.AppendBool(*b, i) 100 | } 101 | 102 | func (b *buffer) AppendDuration(d time.Duration) { 103 | *b = appendDuration(*b, d) 104 | } 105 | -------------------------------------------------------------------------------- /buffer_test.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestBuffer_Append(t *testing.T) { 12 | b := new(buffer) 13 | AssertZero(t, b.Len()) 14 | b.AppendString("foobar") 15 | AssertEqual(t, 6, b.Len()) 16 | b.AppendString("baz") 17 | AssertEqual(t, 9, b.Len()) 18 | AssertEqual(t, "foobarbaz", b.String()) 19 | 20 | b.AppendByte('.') 21 | AssertEqual(t, 10, b.Len()) 22 | AssertEqual(t, "foobarbaz.", b.String()) 23 | 24 | b.AppendBool(true) 25 | b.AppendBool(false) 26 | b.AppendFloat(3.14) 27 | b.AppendInt(42) 28 | b.AppendUint(12) 29 | b.Append([]byte("foo")) 30 | b.AppendDuration(1 * time.Second) 31 | now := time.Now() 32 | b.AppendTime(now, time.RFC3339) 33 | 34 | AssertEqual(t, "foobarbaz.truefalse3.144212foo1s"+now.Format(time.RFC3339), b.String()) 35 | } 36 | 37 | func TestBuffer_WriteTo(t *testing.T) { 38 | dest := bytes.Buffer{} 39 | b := new(buffer) 40 | n, err := b.WriteTo(&dest) 41 | AssertNoError(t, err) 42 | AssertZero(t, n) 43 | b.AppendString("foobar") 44 | n, err = b.WriteTo(&dest) 45 | AssertEqual(t, len("foobar"), int(n)) 46 | AssertNoError(t, err) 47 | AssertEqual(t, "foobar", dest.String()) 48 | AssertZero(t, b.Len()) 49 | } 50 | 51 | func TestBuffer_Clone(t *testing.T) { 52 | b := new(buffer) 53 | b.AppendString("foobar") 54 | b2 := b.Clone() 55 | AssertEqual(t, b.String(), b2.String()) 56 | AssertNotEqual(t, &b.Bytes()[0], &b2.Bytes()[0]) 57 | } 58 | 59 | func TestBuffer_Copy(t *testing.T) { 60 | b := new(buffer) 61 | b.AppendString("foobar") 62 | b2 := new(buffer) 63 | b2.copy(b) 64 | AssertEqual(t, b.String(), b2.String()) 65 | AssertNotEqual(t, &b.Bytes()[0], &b2.Bytes()[0]) 66 | } 67 | 68 | func TestBuffer_Reset(t *testing.T) { 69 | b := new(buffer) 70 | b.AppendString("foobar") 71 | AssertEqual(t, "foobar", b.String()) 72 | AssertEqual(t, len("foobar"), b.Len()) 73 | bufCap := b.Cap() 74 | b.Reset() 75 | AssertZero(t, b.Len()) 76 | AssertEqual(t, bufCap, b.Cap()) 77 | } 78 | 79 | func TestBuffer_Grow(t *testing.T) { 80 | b := new(buffer) 81 | AssertZero(t, b.Cap()) 82 | b.Grow(12) 83 | AssertGreaterOrEqual(t, 12, b.Cap()) 84 | b.Grow(6) 85 | AssertGreaterOrEqual(t, 12, b.Cap()) 86 | b.Grow(24) 87 | AssertGreaterOrEqual(t, 24, b.Cap()) 88 | } 89 | 90 | func TestBuffer_Clip(t *testing.T) { 91 | b := new(buffer) 92 | b.AppendString("foobar") 93 | b.Grow(12) 94 | AssertGreaterOrEqual(t, 12, b.Cap()) 95 | b.Clip() 96 | AssertEqual(t, "foobar", b.String()) 97 | AssertEqual(t, len("foobar"), b.Cap()) 98 | } 99 | 100 | func TestBuffer_WriteTo_Err(t *testing.T) { 101 | w := writerFunc(func(b []byte) (int, error) { return 0, errors.New("nope") }) 102 | b := new(buffer) 103 | b.AppendString("foobar") 104 | _, err := b.WriteTo(w) 105 | AssertError(t, err) 106 | 107 | w = writerFunc(func(b []byte) (int, error) { return 0, nil }) 108 | _, err = b.WriteTo(w) 109 | AssertError(t, err) 110 | if !errors.Is(err, io.ErrShortWrite) { 111 | t.Fatalf("Expected io.ErrShortWrite, go %T", err) 112 | } 113 | } 114 | 115 | func BenchmarkBuffer(b *testing.B) { 116 | data := []byte("foobarbaz") 117 | 118 | b.Run("std", func(b *testing.B) { 119 | buf := bytes.Buffer{} 120 | for i := 0; i < b.N; i++ { 121 | buf.Write(data) 122 | buf.WriteByte('.') 123 | buf.Reset() 124 | } 125 | }) 126 | 127 | b.Run("buffer", func(b *testing.B) { 128 | buf := buffer{} 129 | for i := 0; i < b.N; i++ { 130 | buf.Append(data) 131 | buf.AppendByte('.') 132 | buf.Reset() 133 | } 134 | }) 135 | } 136 | -------------------------------------------------------------------------------- /doc/img/output-with-source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phsym/console-slog/d4a3eecda60c3313618ace336d68f3c8d0078522/doc/img/output-with-source.png -------------------------------------------------------------------------------- /doc/img/output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phsym/console-slog/d4a3eecda60c3313618ace336d68f3c8d0078522/doc/img/output.png -------------------------------------------------------------------------------- /duration.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import "time" 4 | 5 | // appendDuration appends a string representing the duration in the form "72h3m0.5s". 6 | // Leading zero units are omitted. As a special case, durations less than one 7 | // second format use a smaller unit (milli-, micro-, or nanoseconds) to ensure 8 | // that the leading digit is non-zero. The zero duration formats as 0s. 9 | func appendDuration(dst []byte, d time.Duration) []byte { 10 | // Largest time is 2540400h10m10.000000000s 11 | var buf [32]byte 12 | w := len(buf) 13 | 14 | u := uint64(d) 15 | neg := d < 0 16 | if neg { 17 | u = -u 18 | } 19 | 20 | if u < uint64(time.Second) { 21 | // Special case: if duration is smaller than a second, 22 | // use smaller units, like 1.2ms 23 | var prec int 24 | w-- 25 | buf[w] = 's' 26 | w-- 27 | switch { 28 | case u == 0: 29 | return append(dst, "0s"...) 30 | case u < uint64(time.Microsecond): 31 | // print nanoseconds 32 | prec = 0 33 | buf[w] = 'n' 34 | case u < uint64(time.Millisecond): 35 | // print microseconds 36 | prec = 3 37 | // U+00B5 'µ' micro sign == 0xC2 0xB5 38 | w-- // Need room for two bytes. 39 | copy(buf[w:], "µ") 40 | default: 41 | // print milliseconds 42 | prec = 6 43 | buf[w] = 'm' 44 | } 45 | w, u = fmtFrac(buf[:w], u, prec) 46 | w = fmtInt(buf[:w], u) 47 | } else { 48 | w-- 49 | buf[w] = 's' 50 | 51 | w, u = fmtFrac(buf[:w], u, 9) 52 | 53 | // u is now integer seconds 54 | w = fmtInt(buf[:w], u%60) 55 | u /= 60 56 | 57 | // u is now integer minutes 58 | if u > 0 { 59 | w-- 60 | buf[w] = 'm' 61 | w = fmtInt(buf[:w], u%60) 62 | u /= 60 63 | 64 | // u is now integer hours 65 | // Stop at hours because days can be different lengths. 66 | if u > 0 { 67 | w-- 68 | buf[w] = 'h' 69 | w = fmtInt(buf[:w], u%24) 70 | u /= 24 71 | if u > 0 { 72 | w-- 73 | buf[w] = 'd' 74 | w = fmtInt(buf[:w], u) 75 | } 76 | } 77 | } 78 | } 79 | 80 | if neg { 81 | w-- 82 | buf[w] = '-' 83 | } 84 | return append(dst, buf[w:]...) 85 | } 86 | 87 | // fmtFrac formats the fraction of v/10**prec (e.g., ".12345") into the 88 | // tail of buf, omitting trailing zeros. It omits the decimal 89 | // point too when the fraction is 0. It returns the index where the 90 | // output bytes begin and the value v/10**prec. 91 | func fmtFrac(buf []byte, v uint64, prec int) (nw int, nv uint64) { 92 | // Omit trailing zeros up to and including decimal point. 93 | w := len(buf) 94 | print := false 95 | for i := 0; i < prec; i++ { 96 | digit := v % 10 97 | print = print || digit != 0 98 | if print { 99 | w-- 100 | buf[w] = byte(digit) + '0' 101 | } 102 | v /= 10 103 | } 104 | if print { 105 | w-- 106 | buf[w] = '.' 107 | } 108 | return w, v 109 | } 110 | 111 | // fmtInt formats v into the tail of buf. 112 | // It returns the index where the output begins. 113 | func fmtInt(buf []byte, v uint64) int { 114 | w := len(buf) 115 | if v == 0 { 116 | w-- 117 | buf[w] = '0' 118 | } else { 119 | for v > 0 { 120 | w-- 121 | buf[w] = byte(v%10) + '0' 122 | v /= 10 123 | } 124 | } 125 | return w 126 | } 127 | -------------------------------------------------------------------------------- /duration_test.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestDuration(t *testing.T) { 10 | times := []time.Duration{ 11 | 2*time.Hour + 3*time.Minute + 4*time.Second + 5*time.Millisecond + 6*time.Microsecond + 7*time.Nanosecond, 12 | 3*time.Minute + 4*time.Second + 5*time.Millisecond + 6*time.Microsecond + 7*time.Nanosecond, 13 | 4*time.Second + 5*time.Millisecond + 6*time.Microsecond + 7*time.Nanosecond, 14 | 5*time.Millisecond + 6*time.Microsecond + 7*time.Nanosecond, 15 | 6*time.Microsecond + 7*time.Nanosecond, 16 | 7 * time.Nanosecond, 17 | time.Duration(0), 18 | 19 | 2*time.Hour + 7*time.Nanosecond, 20 | -2*time.Hour + 7*time.Nanosecond, 21 | } 22 | 23 | b := [4096]byte{} 24 | for _, tm := range times { 25 | bd := appendDuration(b[:0], tm) 26 | AssertEqual(t, tm.String(), string(bd)) 27 | } 28 | 29 | bd := appendDuration(b[:0], 49*time.Hour+1*time.Second) 30 | AssertEqual(t, "2d1h0m1s", string(bd)) 31 | } 32 | 33 | func BenchmarkDuration(b *testing.B) { 34 | d := 12*time.Hour + 13*time.Minute + 43*time.Second + 12*time.Millisecond 35 | b.Run("std", func(b *testing.B) { 36 | w := new(bytes.Buffer) 37 | w.Grow(2048) 38 | b.ResetTimer() 39 | for i := 0; i < b.N; i++ { 40 | w.WriteString(d.String()) 41 | w.Reset() 42 | } 43 | }) 44 | 45 | b.Run("append", func(b *testing.B) { 46 | w := new(buffer) 47 | w.Grow(2048) 48 | b.ResetTimer() 49 | for i := 0; i < b.N; i++ { 50 | w.AppendDuration(d) 51 | w.Reset() 52 | } 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /encoding.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "path/filepath" 7 | "runtime" 8 | "time" 9 | ) 10 | 11 | type encoder struct { 12 | opts HandlerOptions 13 | } 14 | 15 | func (e encoder) NewLine(buf *buffer) { 16 | buf.AppendByte('\n') 17 | } 18 | 19 | func (e encoder) withColor(b *buffer, c ANSIMod, f func()) { 20 | if c == "" || e.opts.NoColor { 21 | f() 22 | return 23 | } 24 | b.AppendString(string(c)) 25 | f() 26 | b.AppendString(string(ResetMod)) 27 | } 28 | 29 | func (e encoder) writeColoredTime(w *buffer, t time.Time, format string, c ANSIMod) { 30 | e.withColor(w, c, func() { 31 | w.AppendTime(t, format) 32 | }) 33 | } 34 | 35 | func (e encoder) writeColoredString(w *buffer, s string, c ANSIMod) { 36 | e.withColor(w, c, func() { 37 | w.AppendString(s) 38 | }) 39 | } 40 | 41 | func (e encoder) writeColoredInt(w *buffer, i int64, c ANSIMod) { 42 | e.withColor(w, c, func() { 43 | w.AppendInt(i) 44 | }) 45 | } 46 | 47 | func (e encoder) writeColoredUint(w *buffer, i uint64, c ANSIMod) { 48 | e.withColor(w, c, func() { 49 | w.AppendUint(i) 50 | }) 51 | } 52 | 53 | func (e encoder) writeColoredFloat(w *buffer, i float64, c ANSIMod) { 54 | e.withColor(w, c, func() { 55 | w.AppendFloat(i) 56 | }) 57 | } 58 | 59 | func (e encoder) writeColoredBool(w *buffer, b bool, c ANSIMod) { 60 | e.withColor(w, c, func() { 61 | w.AppendBool(b) 62 | }) 63 | } 64 | 65 | func (e encoder) writeColoredDuration(w *buffer, d time.Duration, c ANSIMod) { 66 | e.withColor(w, c, func() { 67 | w.AppendDuration(d) 68 | }) 69 | } 70 | 71 | func (e encoder) writeTimestamp(buf *buffer, tt time.Time) { 72 | if !tt.IsZero() { 73 | e.writeColoredTime(buf, tt, e.opts.TimeFormat, e.opts.Theme.Timestamp()) 74 | buf.AppendByte(' ') 75 | } 76 | } 77 | 78 | func (e encoder) writeSource(buf *buffer, pc uintptr, cwd string) { 79 | frame, _ := runtime.CallersFrames([]uintptr{pc}).Next() 80 | if cwd != "" { 81 | if ff, err := filepath.Rel(cwd, frame.File); err == nil { 82 | frame.File = ff 83 | } 84 | } 85 | e.withColor(buf, e.opts.Theme.Source(), func() { 86 | buf.AppendString(frame.File) 87 | buf.AppendByte(':') 88 | buf.AppendInt(int64(frame.Line)) 89 | }) 90 | e.writeColoredString(buf, " > ", e.opts.Theme.AttrKey()) 91 | } 92 | 93 | func (e encoder) writeMessage(buf *buffer, level slog.Level, msg string) { 94 | if level >= slog.LevelInfo { 95 | e.writeColoredString(buf, msg, e.opts.Theme.Message()) 96 | } else { 97 | e.writeColoredString(buf, msg, e.opts.Theme.MessageDebug()) 98 | } 99 | } 100 | 101 | func (e encoder) writeAttr(buf *buffer, a slog.Attr, group string) { 102 | // Elide empty Attrs. 103 | if a.Equal(slog.Attr{}) { 104 | return 105 | } 106 | value := a.Value.Resolve() 107 | if value.Kind() == slog.KindGroup { 108 | subgroup := a.Key 109 | if group != "" { 110 | subgroup = group + "." + a.Key 111 | } 112 | for _, attr := range value.Group() { 113 | e.writeAttr(buf, attr, subgroup) 114 | } 115 | return 116 | } 117 | buf.AppendByte(' ') 118 | e.withColor(buf, e.opts.Theme.AttrKey(), func() { 119 | if group != "" { 120 | buf.AppendString(group) 121 | buf.AppendByte('.') 122 | } 123 | buf.AppendString(a.Key) 124 | buf.AppendByte('=') 125 | }) 126 | e.writeValue(buf, value) 127 | } 128 | 129 | func (e encoder) writeValue(buf *buffer, value slog.Value) { 130 | attrValue := e.opts.Theme.AttrValue() 131 | switch value.Kind() { 132 | case slog.KindInt64: 133 | e.writeColoredInt(buf, value.Int64(), attrValue) 134 | case slog.KindBool: 135 | e.writeColoredBool(buf, value.Bool(), attrValue) 136 | case slog.KindFloat64: 137 | e.writeColoredFloat(buf, value.Float64(), attrValue) 138 | case slog.KindTime: 139 | e.writeColoredTime(buf, value.Time(), e.opts.TimeFormat, attrValue) 140 | case slog.KindUint64: 141 | e.writeColoredUint(buf, value.Uint64(), attrValue) 142 | case slog.KindDuration: 143 | e.writeColoredDuration(buf, value.Duration(), attrValue) 144 | case slog.KindAny: 145 | switch v := value.Any().(type) { 146 | case error: 147 | e.writeColoredString(buf, v.Error(), e.opts.Theme.AttrValueError()) 148 | return 149 | case fmt.Stringer: 150 | e.writeColoredString(buf, v.String(), attrValue) 151 | return 152 | } 153 | fallthrough 154 | case slog.KindString: 155 | fallthrough 156 | default: 157 | e.writeColoredString(buf, value.String(), attrValue) 158 | } 159 | } 160 | 161 | func (e encoder) writeLevel(buf *buffer, l slog.Level) { 162 | var style ANSIMod 163 | var str string 164 | var delta int 165 | switch { 166 | case l >= slog.LevelError: 167 | style = e.opts.Theme.LevelError() 168 | str = "ERR" 169 | delta = int(l - slog.LevelError) 170 | case l >= slog.LevelWarn: 171 | style = e.opts.Theme.LevelWarn() 172 | str = "WRN" 173 | delta = int(l - slog.LevelWarn) 174 | case l >= slog.LevelInfo: 175 | style = e.opts.Theme.LevelInfo() 176 | str = "INF" 177 | delta = int(l - slog.LevelInfo) 178 | case l >= slog.LevelDebug: 179 | style = e.opts.Theme.LevelDebug() 180 | str = "DBG" 181 | delta = int(l - slog.LevelDebug) 182 | default: 183 | style = e.opts.Theme.LevelDebug() 184 | str = "DBG" 185 | delta = int(l - slog.LevelDebug) 186 | } 187 | if delta != 0 { 188 | str = fmt.Sprintf("%s%+d", str, delta) 189 | } 190 | e.writeColoredString(buf, str, style) 191 | buf.AppendByte(' ') 192 | } 193 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "log/slog" 6 | "os" 7 | 8 | "github.com/phsym/console-slog" 9 | ) 10 | 11 | func main() { 12 | logger := slog.New( 13 | console.NewHandler(os.Stderr, &console.HandlerOptions{Level: slog.LevelDebug, AddSource: true}), 14 | ) 15 | slog.SetDefault(logger) 16 | slog.Info("Hello world!", "foo", "bar") 17 | slog.Debug("Debug message") 18 | slog.Warn("Warning message") 19 | slog.Error("Error message", "err", errors.New("the error")) 20 | 21 | logger = logger.With("foo", "bar"). 22 | WithGroup("the-group"). 23 | With("bar", "baz") 24 | 25 | logger.Info("group info", "attr", "value") 26 | } 27 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/phsym/console-slog 2 | 3 | go 1.21 4 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "strings" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | var bufferPool = &sync.Pool{ 14 | New: func() any { return new(buffer) }, 15 | } 16 | 17 | var cwd, _ = os.Getwd() 18 | 19 | // HandlerOptions are options for a ConsoleHandler. 20 | // A zero HandlerOptions consists entirely of default values. 21 | type HandlerOptions struct { 22 | // AddSource causes the handler to compute the source code position 23 | // of the log statement and add a SourceKey attribute to the output. 24 | AddSource bool 25 | 26 | // Level reports the minimum record level that will be logged. 27 | // The handler discards records with lower levels. 28 | // If Level is nil, the handler assumes LevelInfo. 29 | // The handler calls Level.Level for each record processed; 30 | // to adjust the minimum level dynamically, use a LevelVar. 31 | Level slog.Leveler 32 | 33 | // Disable colorized output 34 | NoColor bool 35 | 36 | // TimeFormat is the format used for time.DateTime 37 | TimeFormat string 38 | 39 | // Theme defines the colorized output using ANSI escape sequences 40 | Theme Theme 41 | } 42 | 43 | type Handler struct { 44 | opts HandlerOptions 45 | out io.Writer 46 | group string 47 | context buffer 48 | enc *encoder 49 | } 50 | 51 | var _ slog.Handler = (*Handler)(nil) 52 | 53 | // NewHandler creates a Handler that writes to w, 54 | // using the given options. 55 | // If opts is nil, the default options are used. 56 | func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { 57 | if opts == nil { 58 | opts = new(HandlerOptions) 59 | } 60 | if opts.Level == nil { 61 | opts.Level = slog.LevelInfo 62 | } 63 | if opts.TimeFormat == "" { 64 | opts.TimeFormat = time.DateTime 65 | } 66 | if opts.Theme == nil { 67 | opts.Theme = NewDefaultTheme() 68 | } 69 | return &Handler{ 70 | opts: *opts, // Copy struct 71 | out: out, 72 | group: "", 73 | context: nil, 74 | enc: &encoder{opts: *opts}, 75 | } 76 | } 77 | 78 | // Enabled implements slog.Handler. 79 | func (h *Handler) Enabled(_ context.Context, l slog.Level) bool { 80 | return l >= h.opts.Level.Level() 81 | } 82 | 83 | // Handle implements slog.Handler. 84 | func (h *Handler) Handle(_ context.Context, rec slog.Record) error { 85 | buf := bufferPool.Get().(*buffer) 86 | 87 | h.enc.writeTimestamp(buf, rec.Time) 88 | h.enc.writeLevel(buf, rec.Level) 89 | if h.opts.AddSource && rec.PC > 0 { 90 | h.enc.writeSource(buf, rec.PC, cwd) 91 | } 92 | h.enc.writeMessage(buf, rec.Level, rec.Message) 93 | buf.copy(&h.context) 94 | rec.Attrs(func(a slog.Attr) bool { 95 | h.enc.writeAttr(buf, a, h.group) 96 | return true 97 | }) 98 | h.enc.NewLine(buf) 99 | if _, err := buf.WriteTo(h.out); err != nil { 100 | buf.Reset() 101 | bufferPool.Put(buf) 102 | return err 103 | } 104 | bufferPool.Put(buf) 105 | return nil 106 | } 107 | 108 | // WithAttrs implements slog.Handler. 109 | func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { 110 | newCtx := h.context 111 | for _, a := range attrs { 112 | h.enc.writeAttr(&newCtx, a, h.group) 113 | } 114 | newCtx.Clip() 115 | return &Handler{ 116 | opts: h.opts, 117 | out: h.out, 118 | group: h.group, 119 | context: newCtx, 120 | enc: h.enc, 121 | } 122 | } 123 | 124 | // WithGroup implements slog.Handler. 125 | func (h *Handler) WithGroup(name string) slog.Handler { 126 | name = strings.TrimSpace(name) 127 | if h.group != "" { 128 | name = h.group + "." + name 129 | } 130 | return &Handler{ 131 | opts: h.opts, 132 | out: h.out, 133 | group: name, 134 | context: h.context, 135 | enc: h.enc, 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /handler_test.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | "os" 10 | "path/filepath" 11 | "runtime" 12 | "testing" 13 | "time" 14 | ) 15 | 16 | func TestHandler_TimeFormat(t *testing.T) { 17 | buf := bytes.Buffer{} 18 | h := NewHandler(&buf, &HandlerOptions{TimeFormat: time.RFC3339Nano, NoColor: true}) 19 | now := time.Now() 20 | rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) 21 | endTime := now.Add(time.Second) 22 | rec.AddAttrs(slog.Time("endtime", endTime)) 23 | AssertNoError(t, h.Handle(context.Background(), rec)) 24 | 25 | expected := fmt.Sprintf("%s INF foobar endtime=%s\n", now.Format(time.RFC3339Nano), endTime.Format(time.RFC3339Nano)) 26 | AssertEqual(t, expected, buf.String()) 27 | } 28 | 29 | // Handlers should not log the time field if it is zero. 30 | // '- If r.Time is the zero time, ignore the time.' 31 | // https://pkg.go.dev/log/slog@master#Handler 32 | func TestHandler_TimeZero(t *testing.T) { 33 | buf := bytes.Buffer{} 34 | h := NewHandler(&buf, &HandlerOptions{TimeFormat: time.RFC3339Nano, NoColor: true}) 35 | rec := slog.NewRecord(time.Time{}, slog.LevelInfo, "foobar", 0) 36 | AssertNoError(t, h.Handle(context.Background(), rec)) 37 | 38 | expected := fmt.Sprintf("INF foobar\n") 39 | AssertEqual(t, expected, buf.String()) 40 | } 41 | 42 | func TestHandler_NoColor(t *testing.T) { 43 | buf := bytes.Buffer{} 44 | h := NewHandler(&buf, &HandlerOptions{NoColor: true}) 45 | now := time.Now() 46 | rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) 47 | AssertNoError(t, h.Handle(context.Background(), rec)) 48 | 49 | expected := fmt.Sprintf("%s INF foobar\n", now.Format(time.DateTime)) 50 | AssertEqual(t, expected, buf.String()) 51 | } 52 | 53 | type theStringer struct{} 54 | 55 | func (t theStringer) String() string { return "stringer" } 56 | 57 | type noStringer struct { 58 | Foo string 59 | } 60 | 61 | var _ slog.LogValuer = &theValuer{} 62 | 63 | type theValuer struct { 64 | word string 65 | } 66 | 67 | // LogValue implements the slog.LogValuer interface. 68 | // This only works if the attribute value is a pointer to theValuer: 69 | // 70 | // slog.Any("field", &theValuer{"word"} 71 | func (v *theValuer) LogValue() slog.Value { 72 | return slog.StringValue(fmt.Sprintf("The word is '%s'", v.word)) 73 | } 74 | 75 | func TestHandler_Attr(t *testing.T) { 76 | buf := bytes.Buffer{} 77 | h := NewHandler(&buf, &HandlerOptions{NoColor: true}) 78 | now := time.Now() 79 | rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) 80 | rec.AddAttrs( 81 | slog.Bool("bool", true), 82 | slog.Int("int", -12), 83 | slog.Uint64("uint", 12), 84 | slog.Float64("float", 3.14), 85 | slog.String("foo", "bar"), 86 | slog.Time("time", now), 87 | slog.Duration("dur", time.Second), 88 | slog.Group("group", slog.String("foo", "bar"), slog.Group("subgroup", slog.String("foo", "bar"))), 89 | slog.Any("err", errors.New("the error")), 90 | slog.Any("stringer", theStringer{}), 91 | slog.Any("nostringer", noStringer{Foo: "bar"}), 92 | // Resolve LogValuer items in addition to Stringer items. 93 | // '- Attr's values should be resolved.' 94 | // https://pkg.go.dev/log/slog@master#Handler 95 | // https://pkg.go.dev/log/slog@master#LogValuer 96 | slog.Any("valuer", &theValuer{"distant"}), 97 | // Handlers are supposed to avoid logging empty attributes. 98 | // '- If an Attr's key and value are both the zero value, ignore the Attr.' 99 | // https://pkg.go.dev/log/slog@master#Handler 100 | slog.Attr{}, 101 | slog.Any("", nil), 102 | ) 103 | AssertNoError(t, h.Handle(context.Background(), rec)) 104 | 105 | expected := fmt.Sprintf("%s INF foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=%s dur=1s group.foo=bar group.subgroup.foo=bar err=the error stringer=stringer nostringer={bar} valuer=The word is 'distant'\n", now.Format(time.DateTime), now.Format(time.DateTime)) 106 | AssertEqual(t, expected, buf.String()) 107 | } 108 | 109 | // Handlers should not log groups (or subgroups) without fields. 110 | // '- If a group has no Attrs (even if it has a non-empty key), ignore it.' 111 | // https://pkg.go.dev/log/slog@master#Handler 112 | func TestHandler_GroupEmpty(t *testing.T) { 113 | buf := bytes.Buffer{} 114 | h := NewHandler(&buf, &HandlerOptions{NoColor: true}) 115 | now := time.Now() 116 | rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) 117 | rec.AddAttrs( 118 | slog.Group("group", slog.String("foo", "bar")), 119 | slog.Group("empty"), 120 | ) 121 | AssertNoError(t, h.Handle(context.Background(), rec)) 122 | 123 | expected := fmt.Sprintf("%s INF foobar group.foo=bar\n", now.Format(time.DateTime)) 124 | AssertEqual(t, expected, buf.String()) 125 | } 126 | 127 | // Handlers should expand groups named "" (the empty string) into the enclosing log record. 128 | // '- If a group's key is empty, inline the group's Attrs.' 129 | // https://pkg.go.dev/log/slog@master#Handler 130 | func TestHandler_GroupInline(t *testing.T) { 131 | buf := bytes.Buffer{} 132 | h := NewHandler(&buf, &HandlerOptions{NoColor: true}) 133 | now := time.Now() 134 | rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) 135 | rec.AddAttrs( 136 | slog.Group("group", slog.String("foo", "bar")), 137 | slog.Group("", slog.String("foo", "bar")), 138 | ) 139 | AssertNoError(t, h.Handle(context.Background(), rec)) 140 | 141 | expected := fmt.Sprintf("%s INF foobar group.foo=bar foo=bar\n", now.Format(time.DateTime)) 142 | AssertEqual(t, expected, buf.String()) 143 | } 144 | 145 | // A Handler should call Resolve on attribute values in groups. 146 | // https://cs.opensource.google/go/x/exp/+/0dcbfd60:slog/slogtest/slogtest.go 147 | func TestHandler_GroupResolve(t *testing.T) { 148 | buf := bytes.Buffer{} 149 | h := NewHandler(&buf, &HandlerOptions{NoColor: true}) 150 | now := time.Now() 151 | rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) 152 | rec.AddAttrs( 153 | slog.Group("group", "stringer", theStringer{}, "valuer", &theValuer{"surreal"}), 154 | ) 155 | AssertNoError(t, h.Handle(context.Background(), rec)) 156 | 157 | expected := fmt.Sprintf("%s INF foobar group.stringer=stringer group.valuer=The word is 'surreal'\n", now.Format(time.DateTime)) 158 | AssertEqual(t, expected, buf.String()) 159 | } 160 | 161 | func TestHandler_WithAttr(t *testing.T) { 162 | buf := bytes.Buffer{} 163 | h := NewHandler(&buf, &HandlerOptions{NoColor: true}) 164 | now := time.Now() 165 | rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) 166 | h2 := h.WithAttrs([]slog.Attr{ 167 | slog.Bool("bool", true), 168 | slog.Int("int", -12), 169 | slog.Uint64("uint", 12), 170 | slog.Float64("float", 3.14), 171 | slog.String("foo", "bar"), 172 | slog.Time("time", now), 173 | slog.Duration("dur", time.Second), 174 | // A Handler should call Resolve on attribute values from WithAttrs. 175 | // https://cs.opensource.google/go/x/exp/+/0dcbfd60:slog/slogtest/slogtest.go 176 | slog.Any("stringer", theStringer{}), 177 | slog.Any("valuer", &theValuer{"awesome"}), 178 | slog.Group("group", 179 | slog.String("foo", "bar"), 180 | slog.Group("subgroup", 181 | slog.String("foo", "bar"), 182 | ), 183 | // A Handler should call Resolve on attribute values in groups from WithAttrs. 184 | // https://cs.opensource.google/go/x/exp/+/0dcbfd60:slog/slogtest/slogtest.go 185 | "stringer", theStringer{}, 186 | "valuer", &theValuer{"pizza"}, 187 | )}) 188 | AssertNoError(t, h2.Handle(context.Background(), rec)) 189 | 190 | expected := fmt.Sprintf("%s INF foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=%s dur=1s stringer=stringer valuer=The word is 'awesome' group.foo=bar group.subgroup.foo=bar group.stringer=stringer group.valuer=The word is 'pizza'\n", now.Format(time.DateTime), now.Format(time.DateTime)) 191 | AssertEqual(t, expected, buf.String()) 192 | 193 | buf.Reset() 194 | AssertNoError(t, h.Handle(context.Background(), rec)) 195 | AssertEqual(t, fmt.Sprintf("%s INF foobar\n", now.Format(time.DateTime)), buf.String()) 196 | } 197 | 198 | func TestHandler_WithGroup(t *testing.T) { 199 | buf := bytes.Buffer{} 200 | h := NewHandler(&buf, &HandlerOptions{NoColor: true}) 201 | now := time.Now() 202 | rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) 203 | rec.Add("int", 12) 204 | h2 := h.WithGroup("group1").WithAttrs([]slog.Attr{slog.String("foo", "bar")}) 205 | AssertNoError(t, h2.Handle(context.Background(), rec)) 206 | expected := fmt.Sprintf("%s INF foobar group1.foo=bar group1.int=12\n", now.Format(time.DateTime)) 207 | AssertEqual(t, expected, buf.String()) 208 | buf.Reset() 209 | 210 | h3 := h2.WithGroup("group2") 211 | AssertNoError(t, h3.Handle(context.Background(), rec)) 212 | expected = fmt.Sprintf("%s INF foobar group1.foo=bar group1.group2.int=12\n", now.Format(time.DateTime)) 213 | AssertEqual(t, expected, buf.String()) 214 | 215 | buf.Reset() 216 | AssertNoError(t, h.Handle(context.Background(), rec)) 217 | AssertEqual(t, fmt.Sprintf("%s INF foobar int=12\n", now.Format(time.DateTime)), buf.String()) 218 | } 219 | 220 | func TestHandler_Levels(t *testing.T) { 221 | levels := map[slog.Level]string{ 222 | slog.LevelDebug - 1: "DBG-1", 223 | slog.LevelDebug: "DBG", 224 | slog.LevelDebug + 1: "DBG+1", 225 | slog.LevelInfo: "INF", 226 | slog.LevelInfo + 1: "INF+1", 227 | slog.LevelWarn: "WRN", 228 | slog.LevelWarn + 1: "WRN+1", 229 | slog.LevelError: "ERR", 230 | slog.LevelError + 1: "ERR+1", 231 | } 232 | 233 | for l := range levels { 234 | t.Run(l.String(), func(t *testing.T) { 235 | buf := bytes.Buffer{} 236 | h := NewHandler(&buf, &HandlerOptions{Level: l, NoColor: true}) 237 | for ll, s := range levels { 238 | AssertEqual(t, ll >= l, h.Enabled(context.Background(), ll)) 239 | now := time.Now() 240 | rec := slog.NewRecord(now, ll, "foobar", 0) 241 | if ll >= l { 242 | AssertNoError(t, h.Handle(context.Background(), rec)) 243 | AssertEqual(t, fmt.Sprintf("%s %s foobar\n", now.Format(time.DateTime), s), buf.String()) 244 | buf.Reset() 245 | } 246 | } 247 | }) 248 | } 249 | } 250 | 251 | func TestHandler_Source(t *testing.T) { 252 | buf := bytes.Buffer{} 253 | h := NewHandler(&buf, &HandlerOptions{NoColor: true, AddSource: true}) 254 | h2 := NewHandler(&buf, &HandlerOptions{NoColor: true, AddSource: false}) 255 | pc, file, line, _ := runtime.Caller(0) 256 | now := time.Now() 257 | rec := slog.NewRecord(now, slog.LevelInfo, "foobar", pc) 258 | AssertNoError(t, h.Handle(context.Background(), rec)) 259 | cwd, _ := os.Getwd() 260 | file, _ = filepath.Rel(cwd, file) 261 | AssertEqual(t, fmt.Sprintf("%s INF %s:%d > foobar\n", now.Format(time.DateTime), file, line), buf.String()) 262 | buf.Reset() 263 | AssertNoError(t, h2.Handle(context.Background(), rec)) 264 | AssertEqual(t, fmt.Sprintf("%s INF foobar\n", now.Format(time.DateTime)), buf.String()) 265 | buf.Reset() 266 | // If the PC is zero then this field and its associated group should not be logged. 267 | // '- If r.PC is zero, ignore it.' 268 | // https://pkg.go.dev/log/slog@master#Handler 269 | rec.PC = 0 270 | AssertNoError(t, h.Handle(context.Background(), rec)) 271 | AssertEqual(t, fmt.Sprintf("%s INF foobar\n", now.Format(time.DateTime)), buf.String()) 272 | } 273 | 274 | func TestHandler_Err(t *testing.T) { 275 | w := writerFunc(func(b []byte) (int, error) { return 0, errors.New("nope") }) 276 | h := NewHandler(w, &HandlerOptions{NoColor: true}) 277 | rec := slog.NewRecord(time.Now(), slog.LevelInfo, "foobar", 0) 278 | AssertError(t, h.Handle(context.Background(), rec)) 279 | } 280 | 281 | func TestThemes(t *testing.T) { 282 | for _, theme := range []Theme{ 283 | NewDefaultTheme(), 284 | NewBrightTheme(), 285 | } { 286 | t.Run(theme.Name(), func(t *testing.T) { 287 | level := slog.LevelInfo 288 | rec := slog.Record{} 289 | buf := bytes.Buffer{} 290 | bufBytes := buf.Bytes() 291 | now := time.Now() 292 | timeFormat := time.Kitchen 293 | index := -1 294 | toIndex := -1 295 | h := NewHandler(&buf, &HandlerOptions{ 296 | AddSource: true, 297 | TimeFormat: timeFormat, 298 | Theme: theme, 299 | }).WithAttrs([]slog.Attr{{Key: "pid", Value: slog.IntValue(37556)}}) 300 | var pcs [1]uintptr 301 | runtime.Callers(1, pcs[:]) 302 | 303 | checkANSIMod := func(t *testing.T, name string, ansiMod ANSIMod) { 304 | t.Run(name, func(t *testing.T) { 305 | index = bytes.IndexByte(bufBytes, '\x1b') 306 | AssertNotEqual(t, -1, index) 307 | toIndex = index + len(ansiMod) 308 | AssertEqual(t, ansiMod, ANSIMod(bufBytes[index:toIndex])) 309 | bufBytes = bufBytes[toIndex:] 310 | index = bytes.IndexByte(bufBytes, '\x1b') 311 | AssertNotEqual(t, -1, index) 312 | toIndex = index + len(ResetMod) 313 | AssertEqual(t, ResetMod, ANSIMod(bufBytes[index:toIndex])) 314 | bufBytes = bufBytes[toIndex:] 315 | }) 316 | } 317 | 318 | checkLog := func(level slog.Level, attrCount int) { 319 | t.Run("CheckLog_"+level.String(), func(t *testing.T) { 320 | println("log: ", string(buf.Bytes())) 321 | 322 | // Timestamp 323 | if theme.Timestamp() != "" { 324 | checkANSIMod(t, "Timestamp", theme.Timestamp()) 325 | } 326 | 327 | // Level 328 | if theme.Level(level) != "" { 329 | checkANSIMod(t, level.String(), theme.Level(level)) 330 | } 331 | 332 | // Source 333 | if theme.Source() != "" { 334 | checkANSIMod(t, "Source", theme.Source()) 335 | checkANSIMod(t, "AttrKey", theme.AttrKey()) 336 | } 337 | 338 | // Message 339 | if level >= slog.LevelInfo { 340 | if theme.Message() != "" { 341 | checkANSIMod(t, "Message", theme.Message()) 342 | } 343 | } else { 344 | if theme.MessageDebug() != "" { 345 | checkANSIMod(t, "MessageDebug", theme.MessageDebug()) 346 | } 347 | } 348 | 349 | for i := 0; i < attrCount; i++ { 350 | // AttrKey 351 | if theme.AttrKey() != "" { 352 | checkANSIMod(t, "AttrKey", theme.AttrKey()) 353 | } 354 | 355 | // AttrValue 356 | if theme.AttrValue() != "" { 357 | checkANSIMod(t, "AttrValue", theme.AttrValue()) 358 | } 359 | } 360 | }) 361 | } 362 | 363 | buf.Reset() 364 | level = slog.LevelDebug - 1 365 | rec = slog.NewRecord(now, level, "Access", pcs[0]) 366 | rec.Add("database", "myapp", "host", "localhost:4962") 367 | h.Handle(context.Background(), rec) 368 | bufBytes = buf.Bytes() 369 | checkLog(level, 3) 370 | 371 | buf.Reset() 372 | level = slog.LevelDebug 373 | rec = slog.NewRecord(now, level, "Access", pcs[0]) 374 | rec.Add("database", "myapp", "host", "localhost:4962") 375 | h.Handle(context.Background(), rec) 376 | bufBytes = buf.Bytes() 377 | checkLog(level, 3) 378 | 379 | buf.Reset() 380 | level = slog.LevelDebug + 1 381 | rec = slog.NewRecord(now, level, "Access", pcs[0]) 382 | rec.Add("database", "myapp", "host", "localhost:4962") 383 | h.Handle(context.Background(), rec) 384 | bufBytes = buf.Bytes() 385 | checkLog(level, 3) 386 | 387 | buf.Reset() 388 | level = slog.LevelInfo 389 | rec = slog.NewRecord(now, level, "Starting listener", pcs[0]) 390 | rec.Add("listen", ":8080") 391 | h.Handle(context.Background(), rec) 392 | bufBytes = buf.Bytes() 393 | checkLog(level, 2) 394 | 395 | buf.Reset() 396 | level = slog.LevelInfo + 1 397 | rec = slog.NewRecord(now, level, "Access", pcs[0]) 398 | rec.Add("method", "GET", "path", "/users", "resp_time", time.Millisecond*10) 399 | h.Handle(context.Background(), rec) 400 | bufBytes = buf.Bytes() 401 | checkLog(level, 4) 402 | 403 | buf.Reset() 404 | level = slog.LevelWarn 405 | rec = slog.NewRecord(now, level, "Slow request", pcs[0]) 406 | rec.Add("method", "POST", "path", "/posts", "resp_time", time.Second*532) 407 | h.Handle(context.Background(), rec) 408 | bufBytes = buf.Bytes() 409 | checkLog(level, 4) 410 | 411 | buf.Reset() 412 | level = slog.LevelWarn + 1 413 | rec = slog.NewRecord(now, level, "Slow request", pcs[0]) 414 | rec.Add("method", "POST", "path", "/posts", "resp_time", time.Second*532) 415 | h.Handle(context.Background(), rec) 416 | bufBytes = buf.Bytes() 417 | checkLog(level, 4) 418 | 419 | buf.Reset() 420 | level = slog.LevelError 421 | rec = slog.NewRecord(now, level, "Database connection lost", pcs[0]) 422 | rec.Add("database", "myapp", "error", errors.New("connection reset by peer")) 423 | h.Handle(context.Background(), rec) 424 | bufBytes = buf.Bytes() 425 | checkLog(level, 3) 426 | 427 | buf.Reset() 428 | level = slog.LevelError + 1 429 | rec = slog.NewRecord(now, level, "Database connection lost", pcs[0]) 430 | rec.Add("database", "myapp", "error", errors.New("connection reset by peer")) 431 | h.Handle(context.Background(), rec) 432 | bufBytes = buf.Bytes() 433 | checkLog(level, 3) 434 | }) 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /theme.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | ) 7 | 8 | type ANSIMod string 9 | 10 | var ResetMod = ToANSICode(Reset) 11 | 12 | const ( 13 | Reset = iota 14 | Bold 15 | Faint 16 | Italic 17 | Underline 18 | CrossedOut = 9 19 | ) 20 | 21 | const ( 22 | Black = iota + 30 23 | Red 24 | Green 25 | Yellow 26 | Blue 27 | Magenta 28 | Cyan 29 | Gray 30 | ) 31 | 32 | const ( 33 | BrightBlack = iota + 90 34 | BrightRed 35 | BrightGreen 36 | BrightYellow 37 | BrightBlue 38 | BrightMagenta 39 | BrightCyan 40 | White 41 | ) 42 | 43 | func (c ANSIMod) String() string { 44 | return string(c) 45 | } 46 | 47 | func ToANSICode(modes ...int) ANSIMod { 48 | if len(modes) == 0 { 49 | return "" 50 | } 51 | 52 | var s string 53 | for i, m := range modes { 54 | if i > 0 { 55 | s += ";" 56 | } 57 | s += fmt.Sprintf("%d", m) 58 | } 59 | return ANSIMod("\x1b[" + s + "m") 60 | } 61 | 62 | type Theme interface { 63 | Name() string 64 | Timestamp() ANSIMod 65 | Source() ANSIMod 66 | 67 | Message() ANSIMod 68 | MessageDebug() ANSIMod 69 | AttrKey() ANSIMod 70 | AttrValue() ANSIMod 71 | AttrValueError() ANSIMod 72 | LevelError() ANSIMod 73 | LevelWarn() ANSIMod 74 | LevelInfo() ANSIMod 75 | LevelDebug() ANSIMod 76 | Level(level slog.Level) ANSIMod 77 | } 78 | 79 | type ThemeDef struct { 80 | name string 81 | timestamp ANSIMod 82 | source ANSIMod 83 | message ANSIMod 84 | messageDebug ANSIMod 85 | attrKey ANSIMod 86 | attrValue ANSIMod 87 | attrValueError ANSIMod 88 | levelError ANSIMod 89 | levelWarn ANSIMod 90 | levelInfo ANSIMod 91 | levelDebug ANSIMod 92 | } 93 | 94 | func (t ThemeDef) Name() string { return t.name } 95 | func (t ThemeDef) Timestamp() ANSIMod { return t.timestamp } 96 | func (t ThemeDef) Source() ANSIMod { return t.source } 97 | func (t ThemeDef) Message() ANSIMod { return t.message } 98 | func (t ThemeDef) MessageDebug() ANSIMod { return t.messageDebug } 99 | func (t ThemeDef) AttrKey() ANSIMod { return t.attrKey } 100 | func (t ThemeDef) AttrValue() ANSIMod { return t.attrValue } 101 | func (t ThemeDef) AttrValueError() ANSIMod { return t.attrValueError } 102 | func (t ThemeDef) LevelError() ANSIMod { return t.levelError } 103 | func (t ThemeDef) LevelWarn() ANSIMod { return t.levelWarn } 104 | func (t ThemeDef) LevelInfo() ANSIMod { return t.levelInfo } 105 | func (t ThemeDef) LevelDebug() ANSIMod { return t.levelDebug } 106 | func (t ThemeDef) Level(level slog.Level) ANSIMod { 107 | switch { 108 | case level >= slog.LevelError: 109 | return t.LevelError() 110 | case level >= slog.LevelWarn: 111 | return t.LevelWarn() 112 | case level >= slog.LevelInfo: 113 | return t.LevelInfo() 114 | default: 115 | return t.LevelDebug() 116 | } 117 | } 118 | 119 | func NewDefaultTheme() Theme { 120 | return ThemeDef{ 121 | name: "Default", 122 | timestamp: ToANSICode(BrightBlack), 123 | source: ToANSICode(Bold, BrightBlack), 124 | message: ToANSICode(Bold), 125 | messageDebug: ToANSICode(), 126 | attrKey: ToANSICode(Cyan), 127 | attrValue: ToANSICode(), 128 | attrValueError: ToANSICode(Bold, Red), 129 | levelError: ToANSICode(Red), 130 | levelWarn: ToANSICode(Yellow), 131 | levelInfo: ToANSICode(Green), 132 | levelDebug: ToANSICode(), 133 | } 134 | } 135 | 136 | func NewBrightTheme() Theme { 137 | return ThemeDef{ 138 | name: "Bright", 139 | timestamp: ToANSICode(Gray), 140 | source: ToANSICode(Bold, Gray), 141 | message: ToANSICode(Bold, White), 142 | messageDebug: ToANSICode(), 143 | attrKey: ToANSICode(BrightCyan), 144 | attrValue: ToANSICode(), 145 | attrValueError: ToANSICode(Bold, BrightRed), 146 | levelError: ToANSICode(BrightRed), 147 | levelWarn: ToANSICode(BrightYellow), 148 | levelInfo: ToANSICode(BrightGreen), 149 | levelDebug: ToANSICode(), 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "cmp" 5 | "testing" 6 | ) 7 | 8 | func AssertZero[E comparable](t *testing.T, v E) { 9 | t.Helper() 10 | var zero E 11 | if v != zero { 12 | t.Errorf("expected zero value, got %v", v) 13 | } 14 | } 15 | 16 | func AssertEqual[E comparable](t *testing.T, expected, value E) { 17 | t.Helper() 18 | if expected != value { 19 | t.Errorf("expected %v, got %v", expected, value) 20 | } 21 | } 22 | 23 | func AssertNotEqual[E comparable](t *testing.T, expected, value E) { 24 | t.Helper() 25 | if expected == value { 26 | t.Errorf("expected to be different, got %v", value) 27 | } 28 | } 29 | 30 | func AssertGreaterOrEqual[E cmp.Ordered](t *testing.T, expected, value E) { 31 | t.Helper() 32 | if expected > value { 33 | t.Errorf("expected to be %v to be greater than %v", value, expected) 34 | } 35 | } 36 | 37 | func AssertNoError(t *testing.T, err error) { 38 | t.Helper() 39 | if err != nil { 40 | t.Errorf("expected no error, got %q", err.Error()) 41 | } 42 | } 43 | 44 | func AssertError(t *testing.T, err error) { 45 | t.Helper() 46 | if err == nil { 47 | t.Error("expected an error, got nil") 48 | } 49 | } 50 | 51 | // func AssertNil(t *testing.T, value any) { 52 | // t.Helper() 53 | // if value != nil { 54 | // t.Errorf("expected nil, got %v", value) 55 | // } 56 | // } 57 | 58 | type writerFunc func([]byte) (int, error) 59 | 60 | func (w writerFunc) Write(b []byte) (int, error) { 61 | return w(b) 62 | } 63 | --------------------------------------------------------------------------------