├── .github └── workflows │ ├── build.yml │ └── lint.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── benchmark_test.go ├── buffer.go ├── examples ├── main.go └── screenshot.png ├── go.mod ├── go.sum ├── log.go └── log_test.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | tags: 8 | - "v*" 9 | pull_request: 10 | 11 | jobs: 12 | build: 13 | strategy: 14 | matrix: 15 | go-version: [~1.17, ~1.18] 16 | os: [ubuntu-latest, macos-latest, windows-latest] 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | - uses: actions/setup-go@v3 23 | with: 24 | go-version: ${{ matrix.go-version }} 25 | - uses: actions/cache@v3 26 | with: 27 | path: ~/go/pkg/mod 28 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 29 | restore-keys: | 30 | ${{ runner.os }}-go- 31 | 32 | - run: go test -v -failfast -race -coverpkg=./... -covermode=atomic -coverprofile=coverage.txt 33 | 34 | - name: Run GoReleaser 35 | uses: goreleaser/goreleaser-action@v2 36 | with: 37 | version: latest 38 | args: release --rm-dist 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - main 8 | pull_request: 9 | jobs: 10 | golangci: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/setup-go@v3 15 | with: 16 | go-version: ~1.16 17 | - uses: actions/checkout@v3 18 | - name: golangci-lint 19 | uses: golangci/golangci-lint-action@v3 20 | with: 21 | skip-go-installation: true 22 | -------------------------------------------------------------------------------- /.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 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | config.toml 18 | .env 19 | bin/ 20 | data/ 21 | dist/ 22 | .vscode/ 23 | coverage.txt -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - skip: true 6 | 7 | snapshot: 8 | name_template: '{{ incpatch .Version }}-next' 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Karan Sharma 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | go test -v -failfast -race -coverpkg=./... -covermode=atomic -coverprofile=coverage.txt 4 | 5 | benchmark: 6 | go test -bench=. -benchmem 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 💥 logf 4 | 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/zerodha/logf.svg)](https://pkg.go.dev/github.com/zerodha/logf) 6 | [![Go Report Card](https://goreportcard.com/badge/zerodha/logf)](https://goreportcard.com/report/zerodha/logf) 7 | [![GitHub Actions](https://github.com/zerodha/logf/actions/workflows/build.yml/badge.svg)](https://github.com/zerodha/logf/actions/workflows/build.yml) 8 | 9 | `logf` is a high-performance, zero-alloc logging library for Go applications with a minimal API overhead. It's also the fastest logfmt logging library for Go. 10 | 11 | `logf` emits structured logs in [`logfmt`](https://brandur.org/logfmt) style. `logfmt` is a flexible format that involves `key=value` pairs to emit structured log lines. `logfmt` achieves the goal of generating logs that are not just machine-friendly but also readable by humans, unlike the clunky JSON lines. 12 | 13 | ## Example 14 | 15 | ```go 16 | package main 17 | 18 | import ( 19 | "time" 20 | 21 | "github.com/zerodha/logf" 22 | ) 23 | 24 | func main() { 25 | logger := logf.New(logf.Opts{ 26 | EnableColor: true, 27 | Level: logf.DebugLevel, 28 | CallerSkipFrameCount: 3, 29 | EnableCaller: true, 30 | TimestampFormat: time.RFC3339Nano, 31 | DefaultFields: []any{"scope", "example"}, 32 | }) 33 | 34 | // Basic logs. 35 | logger.Info("starting app") 36 | logger.Debug("meant for debugging app") 37 | 38 | // Add extra keys to the log. 39 | logger.Info("logging with some extra metadata", "component", "api", "user", "karan") 40 | 41 | // Log with error key. 42 | logger.Error("error fetching details", "error", "this is a dummy error") 43 | 44 | // Log the error and set exit code as 1. 45 | logger.Fatal("goodbye world") 46 | } 47 | ``` 48 | 49 | ### Text Output 50 | 51 | ```bash 52 | timestamp=2022-07-07T12:09:10.221+05:30 level=info message="starting app" 53 | timestamp=2022-07-07T12:09:10.221+05:30 level=info message="logging with some extra metadata" component=api user=karan 54 | timestamp=2022-07-07T12:09:10.221+05:30 level=error message="error fetching details" error="this is a dummy error" 55 | timestamp=2022-07-07T12:09:10.221+05:30 level=fatal message="goodbye world" 56 | ``` 57 | 58 | ### Console Output 59 | 60 | ![](examples/screenshot.png) 61 | 62 | ## Why another lib 63 | 64 | There are several logging libraries, but the available options didn't meet our use case. 65 | 66 | `logf` meets our constraints of: 67 | 68 | - Clean API 69 | - Minimal dependencies 70 | - Structured logging but human-readable (`logfmt`!) 71 | - Sane defaults out of the box 72 | 73 | ## Benchmarks 74 | 75 | You can run benchmarks with `make bench`. 76 | 77 | ### No Colors (Default) 78 | 79 | ``` 80 | BenchmarkNoField-8 7884771 144.2 ns/op 0 B/op 0 allocs/op 81 | BenchmarkOneField-8 6251565 186.7 ns/op 0 B/op 0 allocs/op 82 | BenchmarkThreeFields-8 6273717 188.2 ns/op 0 B/op 0 allocs/op 83 | BenchmarkErrorField-8 6687260 174.8 ns/op 0 B/op 0 allocs/op 84 | BenchmarkHugePayload-8 3395139 360.3 ns/op 0 B/op 0 allocs/op 85 | BenchmarkThreeFields_WithCaller-8 2764860 437.9 ns/op 216 B/op 2 allocs/op 86 | ``` 87 | 88 | ### With Colors 89 | 90 | ``` 91 | BenchmarkNoField_WithColor-8 6501867 186.6 ns/op 0 B/op 0 allocs/op 92 | BenchmarkOneField_WithColor-8 5938155 205.7 ns/op 0 B/op 0 allocs/op 93 | BenchmarkThreeFields_WithColor-8 4613145 379.4 ns/op 0 B/op 0 allocs/op 94 | BenchmarkErrorField_WithColor-8 3512522 353.6 ns/op 0 B/op 0 allocs/op 95 | BenchmarkHugePayload_WithColor-8 1520659 799.5 ns/op 0 B/op 0 allocs/op 96 | ``` 97 | 98 | For a comparison with existing popular libs, visit [uber-go/zap#performance](https://github.com/uber-go/zap#performance). 99 | 100 | ## LICENSE 101 | 102 | [LICENSE](./LICENSE) 103 | -------------------------------------------------------------------------------- /benchmark_test.go: -------------------------------------------------------------------------------- 1 | package logf_test 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "testing" 7 | 8 | "github.com/zerodha/logf" 9 | ) 10 | 11 | func BenchmarkNoField(b *testing.B) { 12 | logger := logf.New(logf.Opts{Writer: io.Discard}) 13 | b.ReportAllocs() 14 | b.ResetTimer() 15 | b.RunParallel(func(p *testing.PB) { 16 | for p.Next() { 17 | logger.Info("hello world") 18 | } 19 | }) 20 | } 21 | 22 | func BenchmarkOneField(b *testing.B) { 23 | logger := logf.New(logf.Opts{Writer: io.Discard}) 24 | b.ReportAllocs() 25 | b.ResetTimer() 26 | b.RunParallel(func(p *testing.PB) { 27 | for p.Next() { 28 | logger.Info("hello world", "stack", "testing") 29 | } 30 | }) 31 | } 32 | 33 | func BenchmarkOneFieldWithDefaultFields(b *testing.B) { 34 | logger := logf.New(logf.Opts{Writer: io.Discard, DefaultFields: []interface{}{"component", "logf"}}) 35 | b.ReportAllocs() 36 | b.ResetTimer() 37 | b.RunParallel(func(p *testing.PB) { 38 | for p.Next() { 39 | logger.Info("hello world", "stack", "testing") 40 | } 41 | }) 42 | } 43 | 44 | func BenchmarkThreeFields(b *testing.B) { 45 | logger := logf.New(logf.Opts{Writer: io.Discard}) 46 | b.ReportAllocs() 47 | b.ResetTimer() 48 | 49 | b.RunParallel(func(p *testing.PB) { 50 | for p.Next() { 51 | logger.Info("request completed", 52 | "component", "api", "method", "GET", "bytes", 1<<18, 53 | ) 54 | } 55 | }) 56 | } 57 | 58 | func BenchmarkErrorField(b *testing.B) { 59 | logger := logf.New(logf.Opts{Writer: io.Discard}) 60 | b.ReportAllocs() 61 | b.ResetTimer() 62 | 63 | fakeErr := errors.New("fake error") 64 | 65 | b.RunParallel(func(p *testing.PB) { 66 | for p.Next() { 67 | logger.Error("request fields", "error", fakeErr) 68 | } 69 | }) 70 | } 71 | 72 | func BenchmarkHugePayload(b *testing.B) { 73 | logger := logf.New(logf.Opts{Writer: io.Discard}) 74 | b.ReportAllocs() 75 | b.ResetTimer() 76 | 77 | b.RunParallel(func(p *testing.PB) { 78 | for p.Next() { 79 | logger.Info("fetched details", 80 | "id", 11, 81 | "title", "perfume Oil", 82 | "description", "Mega Discount, Impression of A...", 83 | "price", 13, 84 | "discountPercentage", 8.4, 85 | "rating", 4.26, 86 | "stock", 65, 87 | "brand", "Impression of Acqua Di Gio", 88 | "category", "fragrances", 89 | "thumbnail", "https://dummyjson.com/image/i/products/11/thumbnail.jpg", 90 | ) 91 | } 92 | }) 93 | } 94 | 95 | func BenchmarkThreeFields_WithCaller(b *testing.B) { 96 | logger := logf.New(logf.Opts{Writer: io.Discard, CallerSkipFrameCount: 3, EnableCaller: true}) 97 | b.ReportAllocs() 98 | b.ResetTimer() 99 | 100 | b.RunParallel(func(p *testing.PB) { 101 | for p.Next() { 102 | logger.Info("request completed", 103 | "component", "api", "method", "GET", "bytes", 1<<18, 104 | ) 105 | } 106 | }) 107 | } 108 | 109 | func BenchmarkNoField_WithColor(b *testing.B) { 110 | logger := logf.New(logf.Opts{Writer: io.Discard, EnableColor: true}) 111 | b.ReportAllocs() 112 | b.ResetTimer() 113 | 114 | b.RunParallel(func(p *testing.PB) { 115 | for p.Next() { 116 | logger.Info("hello world") 117 | } 118 | }) 119 | } 120 | 121 | func BenchmarkOneField_WithColor(b *testing.B) { 122 | logger := logf.New(logf.Opts{Writer: io.Discard, EnableColor: true}) 123 | b.ReportAllocs() 124 | b.ResetTimer() 125 | b.RunParallel(func(p *testing.PB) { 126 | for p.Next() { 127 | logger.Info("hello world", "stack", "testing") 128 | } 129 | }) 130 | } 131 | 132 | func BenchmarkThreeFields_WithColor(b *testing.B) { 133 | logger := logf.New(logf.Opts{Writer: io.Discard, EnableColor: true}) 134 | b.ReportAllocs() 135 | b.ResetTimer() 136 | 137 | b.RunParallel(func(p *testing.PB) { 138 | for p.Next() { 139 | logger.Info("request completed", 140 | "component", "api", "method", "GET", "bytes", 1<<18, 141 | ) 142 | } 143 | }) 144 | } 145 | 146 | func BenchmarkErrorField_WithColor(b *testing.B) { 147 | logger := logf.New(logf.Opts{Writer: io.Discard, EnableColor: true}) 148 | b.ReportAllocs() 149 | b.ResetTimer() 150 | 151 | fakeErr := errors.New("fake error") 152 | 153 | b.RunParallel(func(p *testing.PB) { 154 | for p.Next() { 155 | logger.Error("request fields", "error", fakeErr) 156 | } 157 | }) 158 | } 159 | 160 | func BenchmarkHugePayload_WithColor(b *testing.B) { 161 | logger := logf.New(logf.Opts{Writer: io.Discard, EnableColor: true}) 162 | b.ReportAllocs() 163 | b.ResetTimer() 164 | 165 | b.RunParallel(func(p *testing.PB) { 166 | for p.Next() { 167 | logger.Info("fetched details", 168 | "id", 11, 169 | "title", "perfume Oil", 170 | "description", "Mega Discount, Impression of A...", 171 | "price", 13, 172 | "discountPercentage", 8.4, 173 | "rating", 4.26, 174 | "stock", 65, 175 | "brand", "Impression of Acqua Di Gio", 176 | "category", "fragrances", 177 | "thumbnail", "https://dummyjson.com/image/i/products/11/thumbnail.jpg", 178 | ) 179 | } 180 | }) 181 | } 182 | -------------------------------------------------------------------------------- /buffer.go: -------------------------------------------------------------------------------- 1 | package logf 2 | 3 | import ( 4 | "strconv" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // ref: https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/lib/bytesutil/bytebuffer.go 10 | // byteBufferPool is a pool of byteBuffer 11 | type byteBufferPool struct { 12 | p sync.Pool 13 | } 14 | 15 | // Get returns a new instance of byteBuffer or gets from the object pool 16 | func (bbp *byteBufferPool) Get() *byteBuffer { 17 | bbv := bbp.p.Get() 18 | if bbv == nil { 19 | return &byteBuffer{} 20 | } 21 | return bbv.(*byteBuffer) 22 | } 23 | 24 | // Put puts back the ByteBuffer into the object pool 25 | func (bbp *byteBufferPool) Put(bb *byteBuffer) { 26 | bb.Reset() 27 | bbp.p.Put(bb) 28 | } 29 | 30 | // byteBuffer is a wrapper around byte array 31 | type byteBuffer struct { 32 | B []byte 33 | } 34 | 35 | // AppendByte appends a single byte to the buffer. 36 | func (bb *byteBuffer) AppendByte(b byte) { 37 | bb.B = append(bb.B, b) 38 | } 39 | 40 | // AppendString appends a string to the buffer. 41 | func (bb *byteBuffer) AppendString(s string) { 42 | bb.B = append(bb.B, s...) 43 | } 44 | 45 | // AppendInt appends an integer to the underlying buffer (assuming base 10). 46 | func (bb *byteBuffer) AppendInt(i int64) { 47 | bb.B = strconv.AppendInt(bb.B, i, 10) 48 | } 49 | 50 | // AppendTime appends the time formatted using the specified layout. 51 | func (bb *byteBuffer) AppendTime(t time.Time, layout string) { 52 | bb.B = t.AppendFormat(bb.B, layout) 53 | } 54 | 55 | // AppendBool appends a bool to the underlying buffer. 56 | func (bb *byteBuffer) AppendBool(v bool) { 57 | bb.B = strconv.AppendBool(bb.B, v) 58 | } 59 | 60 | // AppendFloat appends a float to the underlying buffer. 61 | func (bb *byteBuffer) AppendFloat(f float64, bitSize int) { 62 | bb.B = strconv.AppendFloat(bb.B, f, 'f', -1, bitSize) 63 | } 64 | 65 | // Bytes returns a mutable reference to the underlying buffer. 66 | func (bb *byteBuffer) Bytes() []byte { 67 | return bb.B 68 | } 69 | 70 | // Reset resets the underlying buffer. 71 | func (bb *byteBuffer) Reset() { 72 | bb.B = bb.B[:0] 73 | } 74 | -------------------------------------------------------------------------------- /examples/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/zerodha/logf" 7 | ) 8 | 9 | func main() { 10 | logger := logf.New(logf.Opts{ 11 | EnableColor: true, 12 | Level: logf.DebugLevel, 13 | CallerSkipFrameCount: 3, 14 | EnableCaller: true, 15 | TimestampFormat: time.RFC3339Nano, 16 | DefaultFields: []interface{}{"scope", "example"}, 17 | }) 18 | 19 | // Basic logs. 20 | logger.Info("starting app") 21 | logger.Debug("meant for debugging app") 22 | 23 | // Add extra keys to the log. 24 | logger.Info("logging with some extra metadata", "component", "api", "user", "karan") 25 | 26 | // Log with error key. 27 | logger.Error("error fetching details", "error", "this is a dummy error") 28 | 29 | // Log the error and set exit code as 1. 30 | logger.Fatal("goodbye world") 31 | } 32 | -------------------------------------------------------------------------------- /examples/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zerodha/logf/ee661385deecc689184de434d2e0f6cc04aed3f5/examples/screenshot.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zerodha/logf 2 | 3 | go 1.17 4 | 5 | require github.com/stretchr/testify v1.8.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/kr/pretty v0.1.0 // indirect 10 | github.com/pmezard/go-difflib v1.0.0 // indirect 11 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 12 | gopkg.in/yaml.v3 v3.0.1 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /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/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 5 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 6 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 7 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 8 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 12 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 13 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 14 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 15 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 18 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package logf 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | stdlog "log" 7 | "os" 8 | "runtime" 9 | "strings" 10 | "sync" 11 | "time" 12 | "unicode/utf8" 13 | ) 14 | 15 | const ( 16 | tsKey = "timestamp=" 17 | defaultTSFormat = "2006-01-02T15:04:05.999Z07:00" 18 | 19 | // ANSI escape codes for coloring text in console. 20 | reset = "\033[0m" 21 | purple = "\033[35m" 22 | red = "\033[31m" 23 | yellow = "\033[33m" 24 | cyan = "\033[36m" 25 | ) 26 | 27 | const ( 28 | DebugLevel Level = iota + 1 // 1 29 | InfoLevel // 2 30 | WarnLevel // 3 31 | ErrorLevel // 4 32 | FatalLevel // 5 33 | ) 34 | 35 | // syncWriter is a wrapper around io.Writer that 36 | // synchronizes writes using a mutex. 37 | type syncWriter struct { 38 | sync.Mutex 39 | w io.Writer 40 | } 41 | 42 | // Severity level of the log. 43 | type Level int 44 | 45 | // Opts represents the config options for the package. 46 | type Opts struct { 47 | Writer io.Writer 48 | Level Level 49 | TimestampFormat string 50 | EnableColor bool 51 | EnableCaller bool 52 | CallerSkipFrameCount int 53 | 54 | // These fields will be printed with every log. 55 | DefaultFields []interface{} 56 | } 57 | 58 | // Logger is the interface for all log operations related to emitting logs. 59 | type Logger struct { 60 | // Output destination. 61 | out io.Writer 62 | Opts 63 | } 64 | 65 | var ( 66 | hex = "0123456789abcdef" 67 | bufPool byteBufferPool 68 | exit = func() { os.Exit(1) } 69 | 70 | // Map colors with log level. 71 | colorLvlMap = [...]string{ 72 | DebugLevel: purple, 73 | InfoLevel: cyan, 74 | WarnLevel: yellow, 75 | ErrorLevel: red, 76 | FatalLevel: red, 77 | } 78 | ) 79 | 80 | // New instantiates a logger object. 81 | func New(opts Opts) Logger { 82 | // Initialize fallbacks if unspecified by user. 83 | if opts.Writer == nil { 84 | opts.Writer = os.Stderr 85 | } 86 | if opts.TimestampFormat == "" { 87 | opts.TimestampFormat = defaultTSFormat 88 | } 89 | if opts.Level == 0 { 90 | opts.Level = InfoLevel 91 | } 92 | if opts.CallerSkipFrameCount == 0 { 93 | opts.CallerSkipFrameCount = 3 94 | } 95 | if len(opts.DefaultFields)%2 != 0 { 96 | opts.DefaultFields = opts.DefaultFields[0 : len(opts.DefaultFields)-1] 97 | } 98 | 99 | return Logger{ 100 | out: newSyncWriter(opts.Writer), 101 | Opts: opts, 102 | } 103 | } 104 | 105 | // newSyncWriter wraps an io.Writer with syncWriter. It can 106 | // be used as an io.Writer as syncWriter satisfies the io.Writer interface. 107 | func newSyncWriter(in io.Writer) *syncWriter { 108 | if in == nil { 109 | return &syncWriter{w: os.Stderr} 110 | } 111 | 112 | return &syncWriter{w: in} 113 | } 114 | 115 | // Write synchronously to the underlying io.Writer. 116 | func (w *syncWriter) Write(p []byte) (int, error) { 117 | w.Lock() 118 | n, err := w.w.Write(p) 119 | w.Unlock() 120 | return n, err 121 | } 122 | 123 | // String representation of the log severity. 124 | func (l Level) String() string { 125 | switch l { 126 | case DebugLevel: 127 | return "debug" 128 | case InfoLevel: 129 | return "info" 130 | case WarnLevel: 131 | return "warn" 132 | case ErrorLevel: 133 | return "error" 134 | case FatalLevel: 135 | return "fatal" 136 | default: 137 | return "invalid lvl" 138 | } 139 | } 140 | 141 | func LevelFromString(lvl string) (Level, error) { 142 | switch lvl { 143 | case "debug": 144 | return DebugLevel, nil 145 | case "info": 146 | return InfoLevel, nil 147 | case "warn": 148 | return WarnLevel, nil 149 | case "error": 150 | return ErrorLevel, nil 151 | case "fatal": 152 | return FatalLevel, nil 153 | default: 154 | return 0, fmt.Errorf("invalid level") 155 | } 156 | } 157 | 158 | // Debug emits a debug log line. 159 | func (l Logger) Debug(msg string, fields ...interface{}) { 160 | l.handleLog(msg, DebugLevel, fields...) 161 | } 162 | 163 | // Info emits a info log line. 164 | func (l Logger) Info(msg string, fields ...interface{}) { 165 | l.handleLog(msg, InfoLevel, fields...) 166 | } 167 | 168 | // Warn emits a warning log line. 169 | func (l Logger) Warn(msg string, fields ...interface{}) { 170 | l.handleLog(msg, WarnLevel, fields...) 171 | } 172 | 173 | // Error emits an error log line. 174 | func (l Logger) Error(msg string, fields ...interface{}) { 175 | l.handleLog(msg, ErrorLevel, fields...) 176 | } 177 | 178 | // Fatal emits a fatal level log line. 179 | // It aborts the current program with an exit code of 1. 180 | func (l Logger) Fatal(msg string, fields ...interface{}) { 181 | l.handleLog(msg, FatalLevel, fields...) 182 | exit() 183 | } 184 | 185 | // handleLog emits the log after filtering log level 186 | // and applying formatting of the fields. 187 | func (l Logger) handleLog(msg string, lvl Level, fields ...interface{}) { 188 | // Discard the log if the verbosity is higher. 189 | // For eg, if the lvl is `3` (error), but the incoming message is `0` (debug), skip it. 190 | if lvl < l.Opts.Level { 191 | return 192 | } 193 | 194 | // Get a buffer from the pool. 195 | buf := bufPool.Get() 196 | 197 | // Write fixed keys to the buffer before writing user provided ones. 198 | writeTimeToBuf(buf, l.Opts.TimestampFormat, lvl, l.Opts.EnableColor) 199 | writeToBuf(buf, "level", lvl, lvl, l.Opts.EnableColor, true) 200 | writeStringToBuf(buf, "message", msg, lvl, l.Opts.EnableColor, true) 201 | 202 | if l.Opts.EnableCaller { 203 | writeCallerToBuf(buf, "caller", l.Opts.CallerSkipFrameCount, lvl, l.EnableColor, true) 204 | } 205 | 206 | // Format the line as logfmt. 207 | var ( 208 | count int // to find out if this is the last key in while itering fields. 209 | fieldCount = len(l.DefaultFields) + len(fields) 210 | key string 211 | ) 212 | 213 | // If there are odd number of fields, ignore the last. 214 | if fieldCount%2 != 0 { 215 | fields = fields[0 : len(fields)-1] 216 | } 217 | 218 | for i := range l.DefaultFields { 219 | space := false 220 | if count != fieldCount-1 { 221 | space = true 222 | } 223 | 224 | if i%2 == 0 { 225 | key = l.DefaultFields[i].(string) 226 | continue 227 | } 228 | 229 | writeToBuf(buf, key, l.DefaultFields[i], lvl, l.Opts.EnableColor, space) 230 | count++ 231 | } 232 | 233 | for i := range fields { 234 | space := false 235 | if count != fieldCount-1 { 236 | space = true 237 | } 238 | 239 | if i%2 == 0 { 240 | key = fields[i].(string) 241 | continue 242 | } 243 | 244 | writeToBuf(buf, key, fields[i], lvl, l.Opts.EnableColor, space) 245 | count++ 246 | } 247 | 248 | buf.AppendString("\n") 249 | 250 | _, err := l.out.Write(buf.Bytes()) 251 | if err != nil { 252 | // Should ideally never happen. 253 | stdlog.Printf("error logging: %v", err) 254 | } 255 | 256 | // Put the writer back in the pool. It resets the underlying byte buffer. 257 | bufPool.Put(buf) 258 | } 259 | 260 | // writeTimeToBuf writes timestamp key + timestamp into buffer. 261 | func writeTimeToBuf(buf *byteBuffer, format string, lvl Level, color bool) { 262 | if color { 263 | buf.AppendString(getColoredKey(tsKey, lvl)) 264 | } else { 265 | buf.AppendString(tsKey) 266 | } 267 | 268 | buf.AppendTime(time.Now(), format) 269 | buf.AppendByte(' ') 270 | } 271 | 272 | // writeStringToBuf takes key, value and additional options to write to the buffer in logfmt. 273 | func writeStringToBuf(buf *byteBuffer, key, val string, lvl Level, color, space bool) { 274 | if color { 275 | escapeAndWriteString(buf, getColoredKey(key, lvl)) 276 | } else { 277 | escapeAndWriteString(buf, key) 278 | } 279 | 280 | buf.AppendByte('=') 281 | escapeAndWriteString(buf, val) 282 | 283 | if space { 284 | buf.AppendByte(' ') 285 | } 286 | } 287 | 288 | func writeCallerToBuf(buf *byteBuffer, key string, depth int, lvl Level, color, space bool) { 289 | _, file, line, ok := runtime.Caller(depth) 290 | if !ok { 291 | file = "???" 292 | line = 0 293 | } 294 | 295 | if color { 296 | buf.AppendString(getColoredKey(key, lvl)) 297 | } else { 298 | buf.AppendString(key) 299 | } 300 | 301 | buf.AppendByte('=') 302 | escapeAndWriteString(buf, file) 303 | buf.AppendByte(':') 304 | buf.AppendInt(int64(line)) 305 | 306 | if space { 307 | buf.AppendByte(' ') 308 | } 309 | } 310 | 311 | // writeToBuf takes key, value and additional options to write to the buffer in logfmt. 312 | func writeToBuf(buf *byteBuffer, key string, val interface{}, lvl Level, color, space bool) { 313 | if color { 314 | escapeAndWriteString(buf, getColoredKey(key, lvl)) 315 | } else { 316 | escapeAndWriteString(buf, key) 317 | } 318 | 319 | buf.AppendByte('=') 320 | 321 | switch v := val.(type) { 322 | case nil: 323 | buf.AppendString("null") 324 | case []byte: 325 | escapeAndWriteString(buf, string(v)) 326 | case string: 327 | escapeAndWriteString(buf, v) 328 | case int: 329 | buf.AppendInt(int64(v)) 330 | case int8: 331 | buf.AppendInt(int64(v)) 332 | case int16: 333 | buf.AppendInt(int64(v)) 334 | case int32: 335 | buf.AppendInt(int64(v)) 336 | case int64: 337 | buf.AppendInt(v) 338 | case float32: 339 | buf.AppendFloat(float64(v), 32) 340 | case float64: 341 | buf.AppendFloat(v, 64) 342 | case bool: 343 | buf.AppendBool(v) 344 | case error: 345 | escapeAndWriteString(buf, v.Error()) 346 | case fmt.Stringer: 347 | escapeAndWriteString(buf, v.String()) 348 | default: 349 | escapeAndWriteString(buf, fmt.Sprintf("%v", val)) 350 | } 351 | 352 | if space { 353 | buf.AppendByte(' ') 354 | } 355 | } 356 | 357 | // escapeAndWriteString escapes the string if interface{} unwanted chars are there. 358 | func escapeAndWriteString(buf *byteBuffer, s string) { 359 | idx := strings.IndexFunc(s, checkEscapingRune) 360 | if idx != -1 || s == "null" { 361 | writeQuotedString(buf, s) 362 | return 363 | } 364 | 365 | buf.AppendString(s) 366 | } 367 | 368 | // getColoredKey returns a color formatter key based on the log level. 369 | func getColoredKey(k string, lvl Level) string { 370 | return colorLvlMap[lvl] + k + reset 371 | } 372 | 373 | // checkEscapingRune returns true if the rune is to be escaped. 374 | func checkEscapingRune(r rune) bool { 375 | return r == '=' || r == ' ' || r == '"' || r == utf8.RuneError 376 | } 377 | 378 | // writeQuotedString quotes a string before writing to the buffer. 379 | // Taken from: https://github.com/go-logfmt/logfmt/blob/99455b83edb21b32a1f1c0a32f5001b77487b721/jsonstring.go#L95 380 | func writeQuotedString(buf *byteBuffer, s string) { 381 | buf.AppendByte('"') 382 | start := 0 383 | for i := 0; i < len(s); { 384 | if b := s[i]; b < utf8.RuneSelf { 385 | if 0x20 <= b && b != '\\' && b != '"' { 386 | i++ 387 | continue 388 | } 389 | 390 | if start < i { 391 | buf.AppendString(s[start:i]) 392 | } 393 | 394 | switch b { 395 | case '\\', '"': 396 | buf.AppendByte('\\') 397 | buf.AppendByte(b) 398 | case '\n': 399 | buf.AppendByte('\\') 400 | buf.AppendByte('n') 401 | case '\r': 402 | buf.AppendByte('\\') 403 | buf.AppendByte('r') 404 | case '\t': 405 | buf.AppendByte('\\') 406 | buf.AppendByte('t') 407 | default: 408 | // This encodes bytes < 0x20 except for \n, \r, and \t. 409 | buf.AppendString(`\u00`) 410 | buf.AppendByte(hex[b>>4]) 411 | buf.AppendByte(hex[b&0xF]) 412 | } 413 | 414 | i++ 415 | start = i 416 | continue 417 | } 418 | 419 | c, size := utf8.DecodeRuneInString(s[i:]) 420 | if c == utf8.RuneError { 421 | if start < i { 422 | buf.AppendString(s[start:i]) 423 | } 424 | 425 | buf.AppendString(`\ufffd`) 426 | 427 | i += size 428 | start = i 429 | continue 430 | } 431 | i += size 432 | } 433 | 434 | if start < len(s) { 435 | buf.AppendString(s[start:]) 436 | } 437 | 438 | buf.AppendByte('"') 439 | } 440 | -------------------------------------------------------------------------------- /log_test.go: -------------------------------------------------------------------------------- 1 | package logf 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "strconv" 9 | "sync" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestLogFormatWithEnableCaller(t *testing.T) { 16 | buf := &bytes.Buffer{} 17 | l := New(Opts{Writer: buf, EnableCaller: true}) 18 | 19 | l.Info("hello world") 20 | require.Contains(t, buf.String(), `level=info message="hello world" caller=`) 21 | require.Contains(t, buf.String(), `logf/log_test.go:19`) 22 | buf.Reset() 23 | 24 | lC := New(Opts{Writer: buf, EnableCaller: true, EnableColor: true}) 25 | lC.Info("hello world") 26 | require.Contains(t, buf.String(), `logf/log_test.go:25`) 27 | buf.Reset() 28 | } 29 | 30 | func TestLevelParsing(t *testing.T) { 31 | cases := []struct { 32 | String string 33 | Lvl Level 34 | Num int 35 | }{ 36 | {"debug", DebugLevel, 1}, 37 | {"info", InfoLevel, 2}, 38 | {"warn", WarnLevel, 3}, 39 | {"error", ErrorLevel, 4}, 40 | {"fatal", FatalLevel, 5}, 41 | } 42 | 43 | for _, c := range cases { 44 | t.Run(c.String, func(t *testing.T) { 45 | require.Equal(t, c.Lvl.String(), c.String, "level should be equal") 46 | }) 47 | } 48 | 49 | // Test LevelFromString. 50 | for _, c := range cases { 51 | t.Run(fmt.Sprintf("from-string-%v", c.String), func(t *testing.T) { 52 | str, err := LevelFromString(c.String) 53 | if err != nil { 54 | t.Fatalf("error parsing level: %v", err) 55 | } 56 | require.Equal(t, c.Lvl, str, "level should be equal") 57 | }) 58 | } 59 | 60 | // Check for an invalid case. 61 | t.Run("invalid", func(t *testing.T) { 62 | var invalidLvl Level = 10 63 | require.Equal(t, invalidLvl.String(), "invalid lvl", "invalid level") 64 | }) 65 | } 66 | 67 | func TestNewLoggerDefault(t *testing.T) { 68 | l := New(Opts{}) 69 | require.Equal(t, l.Opts.Level, InfoLevel, "level is info") 70 | require.Equal(t, l.Opts.EnableColor, false, "color output is disabled") 71 | require.Equal(t, l.Opts.EnableCaller, false, "caller is disabled") 72 | require.Equal(t, l.Opts.CallerSkipFrameCount, 3, "skip frame count is 3") 73 | require.Equal(t, l.Opts.TimestampFormat, defaultTSFormat, "timestamp format is default") 74 | } 75 | 76 | func TestNewSyncWriterWithNil(t *testing.T) { 77 | w := newSyncWriter(nil) 78 | require.NotNil(t, w.w, "writer should not be nil") 79 | } 80 | 81 | func TestLogFormat(t *testing.T) { 82 | buf := &bytes.Buffer{} 83 | 84 | l := New(Opts{Writer: buf, Level: DebugLevel}) 85 | // Debug log. 86 | l.Debug("debug log") 87 | require.Contains(t, buf.String(), `level=debug message="debug log"`) 88 | buf.Reset() 89 | 90 | l = New(Opts{Writer: buf}) 91 | 92 | // Debug log but with default level set to info. 93 | l.Debug("debug log") 94 | require.NotContains(t, buf.String(), `level=debug message="debug log"`) 95 | buf.Reset() 96 | 97 | // Info log. 98 | l.Info("hello world") 99 | require.Contains(t, buf.String(), `level=info message="hello world"`, "info log") 100 | buf.Reset() 101 | 102 | // Log with field. 103 | l.Warn("testing fields", "stack", "testing") 104 | require.Contains(t, buf.String(), `level=warn message="testing fields" stack=testing`, "warning log") 105 | buf.Reset() 106 | 107 | // Log with error. 108 | fakeErr := errors.New("this is a fake error") 109 | l.Error("testing error", "error", fakeErr) 110 | require.Contains(t, buf.String(), `level=error message="testing error" error="this is a fake error"`, "error log") 111 | buf.Reset() 112 | 113 | // Fatal log 114 | var hadExit bool 115 | exit = func() { 116 | hadExit = true 117 | } 118 | 119 | l.Fatal("fatal log") 120 | require.True(t, hadExit, "exit should have been called") 121 | require.Contains(t, buf.String(), `level=fatal message="fatal log"`, "fatal log") 122 | buf.Reset() 123 | } 124 | 125 | func TestLogFormatWithColor(t *testing.T) { 126 | buf := &bytes.Buffer{} 127 | l := New(Opts{Writer: buf, EnableColor: true}) 128 | 129 | // Info log. 130 | l.Info("hello world") 131 | require.Contains(t, buf.String(), "\x1b[36mlevel\x1b[0m=info \x1b[36mmessage\x1b[0m=\"hello world\" \n") 132 | buf.Reset() 133 | } 134 | 135 | func TestLoggerTypes(t *testing.T) { 136 | buf := &bytes.Buffer{} 137 | l := New(Opts{Writer: buf, Level: DebugLevel}) 138 | type foo struct { 139 | A int 140 | } 141 | l.Info("hello world", 142 | "string", "foo", 143 | "int", 1, 144 | "int8", int8(1), 145 | "int16", int16(1), 146 | "int32", int32(1), 147 | "int64", int64(1), 148 | "float32", float32(1.0), 149 | "float64", float64(1.0), 150 | "struct", foo{A: 1}, 151 | "bool", true, 152 | ) 153 | 154 | require.Contains(t, buf.String(), "level=info message=\"hello world\" string=foo int=1 int8=1 int16=1 int32=1 int64=1 float32=1 float64=1 struct={1} bool=true \n") 155 | } 156 | 157 | func TestLogFormatWithDefaultFields(t *testing.T) { 158 | buf := &bytes.Buffer{} 159 | l := New(Opts{Writer: buf, DefaultFields: []interface{}{"defaultkey", "defaultvalue"}}) 160 | 161 | l.Info("hello world") 162 | require.Contains(t, buf.String(), `level=info message="hello world" defaultkey=defaultvalue`) 163 | buf.Reset() 164 | 165 | l.Info("hello world", "component", "logf") 166 | require.Contains(t, buf.String(), `level=info message="hello world" defaultkey=defaultvalue component=logf`) 167 | buf.Reset() 168 | } 169 | 170 | type errWriter struct{} 171 | 172 | func (w *errWriter) Write(p []byte) (int, error) { 173 | return 0, errors.New("dummy error") 174 | } 175 | 176 | func TestIoWriterError(t *testing.T) { 177 | w := &errWriter{} 178 | l := New(Opts{Writer: w}) 179 | buf := &bytes.Buffer{} 180 | log.SetOutput(buf) 181 | log.SetFlags(0) 182 | l.Info("hello world") 183 | require.Contains(t, buf.String(), "error logging: dummy error\n") 184 | } 185 | 186 | func TestWriteQuotedStringCases(t *testing.T) { 187 | buf := &bytes.Buffer{} 188 | l := New(Opts{Writer: buf}) 189 | 190 | // cases from 191 | // https://github.com/go-logfmt/logfmt/blob/99455b83edb21b32a1f1c0a32f5001b77487b721/encode_test.go 192 | data := []struct { 193 | key, value interface{} 194 | want string 195 | }{ 196 | {key: "k", value: "v", want: "k=v"}, 197 | {key: "k", value: nil, want: "k=null"}, 198 | {key: `\`, value: "v", want: `\=v`}, 199 | {key: "k", value: "", want: "k="}, 200 | {key: "k", value: "null", want: `k="null"`}, 201 | {key: "k", value: "", want: `k=`}, 202 | {key: "k", value: true, want: "k=true"}, 203 | {key: "k", value: 1, want: "k=1"}, 204 | {key: "k", value: 1.025, want: "k=1.025"}, 205 | {key: "k", value: 1e-3, want: "k=0.001"}, 206 | {key: "k", value: 3.5 + 2i, want: "k=(3.5+2i)"}, 207 | {key: "k", value: "v v", want: `k="v v"`}, 208 | {key: "k", value: " ", want: `k=" "`}, 209 | {key: "k", value: `"`, want: `k="\""`}, 210 | {key: "k", value: `=`, want: `k="="`}, 211 | {key: "k", value: `\`, want: `k=\`}, 212 | {key: "k", value: `=\`, want: `k="=\\"`}, 213 | {key: "k", value: `\"`, want: `k="\\\""`}, 214 | {key: "k", value: "\xbd", want: `k="\ufffd"`}, 215 | {key: "k", value: "\ufffd\x00", want: `k="\ufffd\u0000"`}, 216 | {key: "k", value: "\ufffd", want: `k="\ufffd"`}, 217 | {key: "k", value: []byte("\ufffd\x00"), want: `k="\ufffd\u0000"`}, 218 | {key: "k", value: []byte("\ufffd"), want: `k="\ufffd"`}, 219 | } 220 | 221 | for _, d := range data { 222 | l.Info("hello world", d.key, d.value) 223 | require.Contains(t, buf.String(), d.want) 224 | buf.Reset() 225 | } 226 | } 227 | 228 | func TestOddNumberedFields(t *testing.T) { 229 | buf := &bytes.Buffer{} 230 | l := New(Opts{Writer: buf}) 231 | 232 | // Give a odd number of fields. 233 | l.Info("hello world", "key1", "val1", "key2") 234 | require.Contains(t, buf.String(), `level=info message="hello world" key1=val1`) 235 | buf.Reset() 236 | } 237 | 238 | func TestOddNumberedFieldsWithDefaultFields(t *testing.T) { 239 | buf := &bytes.Buffer{} 240 | l := New(Opts{Writer: buf, DefaultFields: []interface{}{ 241 | "defaultkey", "defaultval", 242 | }}) 243 | 244 | // Give a odd number of fields. 245 | l.Info("hello world", "key1", "val1", "key2") 246 | require.Contains(t, buf.String(), `level=info message="hello world" defaultkey=defaultval key1=val1`) 247 | buf.Reset() 248 | } 249 | 250 | // These test are typically meant to be run with the data race detector. 251 | func TestLoggerConcurrency(t *testing.T) { 252 | buf := &bytes.Buffer{} 253 | l := New(Opts{Writer: buf}) 254 | 255 | for _, n := range []int{10, 100, 1000} { 256 | wg := sync.WaitGroup{} 257 | wg.Add(n) 258 | for i := 0; i < n; i++ { 259 | go func() { genLogs(l); wg.Done() }() 260 | } 261 | wg.Wait() 262 | } 263 | } 264 | 265 | func genLogs(l Logger) { 266 | for i := 0; i < 100; i++ { 267 | l.Info("random log", "index", strconv.FormatInt(int64(i), 10)) 268 | } 269 | } 270 | --------------------------------------------------------------------------------