Skip to content

Commit 118bace

Browse files
authored
fblog-like handler (#7)
* feat: added fblog handler, benchmarks and tests
1 parent cafa245 commit 118bace

File tree

8 files changed

+983
-10
lines changed

8 files changed

+983
-10
lines changed

.golangci.yml

+11-10
Original file line numberDiff line numberDiff line change
@@ -54,21 +54,22 @@ run:
5454
issues:
5555
exclude-rules:
5656
- text: "should have a package comment, unless it's in another file for this package"
57-
linters:
58-
- golint
57+
linters: [gosec]
5958
- text: "G505: Blocklisted import crypto/sha1: weak cryptographic primitive"
60-
linters:
61-
- gosec
59+
linters: [gosec]
6260
- text: "Use of weak cryptographic primitive"
63-
linters:
64-
- gosec
61+
linters: [gosec]
6562
- path: _test\.go
6663
text: "Potential Slowloris Attack because ReadHeaderTimeout is not configured in the http.Server"
67-
linters:
68-
- gosec
64+
linters: [gosec]
6965
- path: _test\.go
7066
text: "Deferring unsafe method \"Close\" on type \"io.ReadCloser\""
71-
linters:
72-
- gosec
67+
linters: [gosec]
68+
- path: fblog
69+
text: "Errors unhandled"
70+
linters: [gosec]
71+
- path: fblog
72+
text: "Use of unsafe calls should be audited"
73+
linters: [gosec]
7374

7475
exclude-use-default: false

README.md

+28
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,34 @@ Package slogx contains extensions for standard library's slog package.
66
go get github.com/cappuccinotm/slogx
77
```
88

9+
## Handlers
10+
- `slog.Chain` - chains the multiple "middlewares" - handlers, which can modify the log entry.
11+
- `fblog.Handler` - a handler that logs the log entry in the [fblog-like](https://github.com/brocode/fblog) format, like:
12+
```
13+
2024-02-05 09:11:37 [INFO]: info message
14+
key: 1
15+
some_multi_line_string: "line1\nline2\nline3"
16+
multiline_any: "line1\nline2\nline3"
17+
group.int: 1
18+
group.string: "string"
19+
group.float64: 1.1
20+
group.bool: false
21+
...some_very_very_long_key: 1
22+
```
23+
24+
Some benchmarks (though this handler wasn't designed for performance, but for comfortable reading of the logs in debug/local mode):
25+
```
26+
BenchmarkHandler
27+
BenchmarkHandler/fblog.NewHandler
28+
BenchmarkHandler/fblog.NewHandler-8 1479525 800.9 ns/op 624 B/op 8 allocs/op
29+
BenchmarkHandler/slog.NewJSONHandler
30+
BenchmarkHandler/slog.NewJSONHandler-8 2407322 500.0 ns/op 48 B/op 1 allocs/op
31+
BenchmarkHandler/slog.NewTextHandler
32+
BenchmarkHandler/slog.NewTextHandler-8 2404581 490.0 ns/op 48 B/op 1 allocs/op
33+
```
34+
35+
All the benchmarks were run on a MacBook Pro (14-inch, 2021) with Apple M1 processor, the benchmark contains the only log `lg.Info("message", slog.Int("int", 1))`
36+
937
## Middlewares
1038
- `slogm.RequestID()` - adds a request ID to the context and logs it.
1139
- `slogm.ContextWithRequestID(ctx context.Context, requestID string) context.Context` - adds a request ID to the context.

fblog/entry.go

+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package fblog
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"io"
7+
"log/slog"
8+
"strings"
9+
10+
"github.com/cappuccinotm/slogx/fblog/internal/misc"
11+
)
12+
13+
type entry struct {
14+
timeFmt string
15+
rep func([]string, slog.Attr) slog.Attr
16+
17+
// internal use
18+
headerLen int
19+
buf *bytes.Buffer
20+
q *misc.Queue[grouped]
21+
}
22+
23+
const lvlSize = 7 // length of the level with braces, e.g. "[DEBUG]"
24+
25+
func newEntry(timeFmt string, rep func([]string, slog.Attr) slog.Attr, numAttrs int) *entry {
26+
return &entry{
27+
buf: bytes.NewBuffer(nil),
28+
q: misc.NewQueue[grouped](numAttrs),
29+
timeFmt: timeFmt,
30+
rep: rep,
31+
}
32+
}
33+
34+
func (e *entry) WriteHeader(logTimeFmt string, maxKeySize int, rec slog.Record) {
35+
if logTimeFmt != "" {
36+
tmFmts := rec.Time.Format(logTimeFmt)
37+
maxKeySize -= lvlSize
38+
switch {
39+
case maxKeySize == UnlimitedKeySize-lvlSize:
40+
e.buf.WriteString(tmFmts) // TODO handle the case with expanding space prefixes for a long
41+
case maxKeySize == HeaderKeySize-lvlSize:
42+
e.buf.WriteString(tmFmts)
43+
case len(tmFmts) > maxKeySize:
44+
// trim the timestamp from the left, add "..." at the beginning
45+
e.buf.WriteString("...")
46+
e.buf.WriteString(tmFmts[len(tmFmts)-maxKeySize+3:])
47+
case maxKeySize > len(tmFmts):
48+
e.spaces(maxKeySize-len(tmFmts), false)
49+
e.buf.WriteString(tmFmts)
50+
default:
51+
e.buf.WriteString(tmFmts)
52+
}
53+
e.buf.WriteString(" ")
54+
}
55+
56+
switch rec.Level {
57+
case slog.LevelDebug:
58+
e.buf.WriteString("[DEBUG]")
59+
case slog.LevelInfo:
60+
e.buf.WriteString(" [INFO]")
61+
case slog.LevelWarn:
62+
e.buf.WriteString(" [WARN]")
63+
case slog.LevelError:
64+
e.buf.WriteString("[ERROR]")
65+
default:
66+
e.buf.WriteString("[UNKNW]")
67+
}
68+
69+
e.headerLen = e.buf.Len()
70+
e.buf.WriteString(": ")
71+
e.buf.WriteString(rec.Message)
72+
e.buf.WriteString("\n")
73+
}
74+
75+
type grouped struct {
76+
group []string
77+
attr slog.Attr
78+
}
79+
80+
func (e *entry) WriteAttr(group []string, a slog.Attr) {
81+
e.q.PushBack(grouped{group: group, attr: a})
82+
83+
for e.q.Len() > 0 {
84+
g := e.q.PopFront()
85+
86+
groups, attr := g.group, g.attr
87+
attr = e.rep(groups, attr)
88+
attr.Value = attr.Value.Resolve() // resolve the value before writing
89+
90+
if attr.Value.Kind() == slog.KindGroup {
91+
for _, a := range attr.Value.Group() {
92+
e.q.PushBack(grouped{group: append(groups, attr.Key), attr: a})
93+
}
94+
continue
95+
}
96+
97+
e.WriteKey(groups, attr)
98+
e.buf.WriteString(": ")
99+
e.WriteTextValue(attr)
100+
e.buf.WriteString("\n")
101+
}
102+
}
103+
104+
func (e *entry) WriteTextValue(attr slog.Attr) {
105+
switch attr.Value.Kind() {
106+
case slog.KindString:
107+
_, _ = fmt.Fprintf(e.buf, "%q", attr.Value.String()) // escape the string
108+
case slog.KindTime:
109+
e.buf.WriteString(attr.Value.Time().Format(e.timeFmt))
110+
case slog.KindDuration:
111+
e.buf.WriteString(attr.Value.Duration().String())
112+
case slog.KindGroup:
113+
panic("impossible case, group should be resolved to this point, please, file an issue")
114+
default:
115+
_, _ = fmt.Fprintf(e.buf, "%+v", attr.Value.Any())
116+
}
117+
}
118+
119+
func (e *entry) WriteKey(groups []string, attr slog.Attr) {
120+
key := &strings.Builder{}
121+
for _, g := range groups {
122+
key.WriteString(g)
123+
key.WriteString(".")
124+
}
125+
key.WriteString(attr.Key)
126+
s := key.String()
127+
128+
e.buf.Grow(e.headerLen) // preallocate the space for the key
129+
if e.headerLen-key.Len() < 0 {
130+
// trim the key from the left, add "..." at the beginning
131+
e.buf.WriteString("...")
132+
s = s[key.Len()-e.headerLen+3:]
133+
}
134+
135+
if e.headerLen-key.Len() > 0 {
136+
e.spaces(e.headerLen-key.Len(), true)
137+
}
138+
139+
e.buf.WriteString(s)
140+
}
141+
142+
func (e *entry) WriteTo(wr io.Writer) (int64, error) {
143+
// TODO: append offset in case of UnlimitedKeySize
144+
n, err := wr.Write(e.buf.Bytes())
145+
return int64(n), err
146+
}
147+
148+
func (e *entry) spaces(n int, alreadyGrown bool) {
149+
if !alreadyGrown {
150+
e.buf.Grow(n)
151+
}
152+
for i := 0; i < n; i++ {
153+
e.buf.WriteByte(' ')
154+
}
155+
}

fblog/internal/misc/queue.go

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Package misc provides miscellaneous data types and functions
2+
// for better processing of log entries.
3+
package misc
4+
5+
const minCap = 4
6+
7+
// Queue is an implementation of the list data structure,
8+
// it is a FIFO (first in, first out) data structure over a slice.
9+
type Queue[T any] struct {
10+
l []T
11+
idx int
12+
end int
13+
}
14+
15+
// NewQueue returns a new Queue.
16+
func NewQueue[T any](capacity int) *Queue[T] {
17+
// assume always that the amount of attrs is not less than minCap
18+
if capacity < minCap {
19+
capacity = minCap
20+
}
21+
22+
return &Queue[T]{l: make([]T, 0, capacity)}
23+
}
24+
25+
// Len returns the length of the queue.
26+
func (q *Queue[T]) Len() int { return q.end - q.idx }
27+
28+
// PushBack adds an element to the end of the queue.
29+
func (q *Queue[T]) PushBack(e T) {
30+
if q.end < len(q.l) {
31+
q.l[q.end] = e
32+
} else {
33+
q.l = append(q.l, e)
34+
}
35+
q.end++
36+
}
37+
38+
// PopFront removes the first element from the queue and returns it.
39+
func (q *Queue[T]) PopFront() T {
40+
if q.idx >= q.end {
41+
panic("pop from empty queue")
42+
}
43+
e := q.l[q.idx]
44+
q.idx++
45+
46+
// if the index is too far from the beginning of the slice
47+
// (half of the slice or more), then we need to copy the
48+
// remaining elements to the beginning of the slice and reset
49+
// the index, to avoid memory leaks.
50+
if q.idx >= cap(q.l)/2 {
51+
q.shift()
52+
}
53+
54+
return e
55+
}
56+
57+
// shift moves the remaining elements to the beginning of the slice
58+
// and resets the index.
59+
func (q *Queue[T]) shift() {
60+
if q.end-q.idx == 0 {
61+
q.idx, q.end = 0, 0
62+
return
63+
}
64+
65+
for i := 0; i < q.end-q.idx; i++ {
66+
q.l[i] = q.l[q.idx+i]
67+
}
68+
q.end -= q.idx
69+
q.idx = 0
70+
}

fblog/internal/misc/queue_test.go

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package misc
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestQueue(t *testing.T) {
10+
t.Run("simple rotation", func(t *testing.T) {
11+
q := NewQueue[int](9)
12+
q.idx = 3
13+
q.end = 8
14+
q.l = []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
15+
assert.Equal(t, 4, q.PopFront())
16+
assert.Equal(t, 0, q.idx)
17+
assert.Equal(t, []int{5, 6, 7, 8, 5, 6, 7, 8, 9}, q.l)
18+
assert.Equal(t, 4, q.end)
19+
})
20+
21+
t.Run("from the end to start", func(t *testing.T) {
22+
q := NewQueue[int](6)
23+
q.l = []int{0, 1, 2, 3, 4, 5}
24+
q.idx = 4
25+
q.end = 6
26+
g := q.PopFront()
27+
assert.Equal(t, 4, g)
28+
assert.Equal(t, []int{5, 1, 2, 3, 4, 5}, q.l)
29+
assert.Equal(t, 1, q.end)
30+
assert.Equal(t, 0, q.idx)
31+
})
32+
33+
t.Run("push-pop", func(t *testing.T) {
34+
q := NewQueue[int](6)
35+
q.PushBack(1)
36+
q.PushBack(2)
37+
q.PushBack(3)
38+
assert.Equal(t, 3, q.Len())
39+
assert.Equal(t, 1, q.PopFront())
40+
assert.Equal(t, 2, q.PopFront())
41+
assert.Equal(t, 3, q.PopFront())
42+
})
43+
44+
t.Run("push to rotated", func(t *testing.T) {
45+
q := NewQueue[int](2)
46+
q.PushBack(1)
47+
q.PushBack(2)
48+
assert.Equal(t, 2, q.Len())
49+
assert.Equal(t, 1, q.PopFront())
50+
q.PushBack(3)
51+
assert.Equal(t, 2, q.PopFront())
52+
assert.Equal(t, 3, q.PopFront())
53+
q.PushBack(4)
54+
assert.Equal(t, 4, q.PopFront())
55+
})
56+
57+
t.Run("pop from empty queue", func(t *testing.T) {
58+
q := NewQueue[int](6)
59+
assert.Panics(t, func() { q.PopFront() })
60+
})
61+
62+
t.Run("min cap always preset", func(t *testing.T) {
63+
q := NewQueue[int](-10)
64+
assert.Equal(t, minCap, cap(q.l))
65+
})
66+
}

0 commit comments

Comments
 (0)