├── .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 | [](https://pkg.go.dev/github.com/zerodha/logf)
6 | [](https://goreportcard.com/report/zerodha/logf)
7 | [](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 | 
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 |
--------------------------------------------------------------------------------