Skip to content

Commit 6454c38

Browse files
authored
Add slog logging handler
1 parent 459ce4c commit 6454c38

File tree

5 files changed

+369
-0
lines changed

5 files changed

+369
-0
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.22
55
toolchain go1.22.0
66

77
require (
8+
github.com/samber/lo v1.47.0
89
github.com/sirupsen/logrus v1.9.3
910
github.com/stretchr/testify v1.9.0
1011
github.com/uptrace/opentelemetry-go-extra/otellogrus v0.3.1
@@ -27,6 +28,7 @@ require (
2728
go.opentelemetry.io/otel/log v0.4.0 // indirect
2829
go.opentelemetry.io/otel/metric v1.29.0 // indirect
2930
golang.org/x/sys v0.24.0 // indirect
31+
golang.org/x/text v0.16.0 // indirect
3032
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
3133
gopkg.in/yaml.v3 v3.0.1 // indirect
3234
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
2626
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
2727
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
2828
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
29+
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
30+
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
2931
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
3032
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
3133
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -51,6 +53,8 @@ go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+M
5153
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5254
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
5355
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
56+
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
57+
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
5458
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
5559
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
5660
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

slog/converter.go

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package slog
2+
3+
import (
4+
"context"
5+
"github.com/samber/lo"
6+
"log/slog"
7+
"runtime"
8+
"slices"
9+
)
10+
11+
var sourceKey = "source"
12+
var errorKeys = []string{"error", "err"}
13+
14+
func convert(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record) map[string]any {
15+
attrs := appendRecordAttrsToAttrs(loggerAttr, groups, record)
16+
17+
attrs = replaceError(attrs, errorKeys...)
18+
if addSource {
19+
attrs = append(attrs, source(sourceKey, record))
20+
}
21+
attrs = replaceAttrs(replaceAttr, []string{}, attrs...)
22+
attrs = removeEmptyAttrs(attrs)
23+
24+
output := attrsToMap(attrs...)
25+
26+
return output
27+
}
28+
29+
type replaceAttrFn = func(groups []string, a slog.Attr) slog.Attr
30+
31+
func appendRecordAttrsToAttrs(attrs []slog.Attr, groups []string, record *slog.Record) []slog.Attr {
32+
output := slices.Clone(attrs)
33+
34+
slices.Reverse(groups)
35+
record.Attrs(func(attr slog.Attr) bool {
36+
for i := range groups {
37+
attr = slog.Group(groups[i], attr)
38+
}
39+
output = append(output, attr)
40+
return true
41+
})
42+
43+
return output
44+
}
45+
46+
func replaceError(attrs []slog.Attr, errorKeys ...string) []slog.Attr {
47+
replaceAttr := func(groups []string, a slog.Attr) slog.Attr {
48+
if len(groups) > 1 {
49+
return a
50+
}
51+
52+
for i := range errorKeys {
53+
if a.Key == errorKeys[i] {
54+
if err, ok := a.Value.Any().(error); ok {
55+
return slog.Any(a.Key, err.Error())
56+
}
57+
}
58+
}
59+
return a
60+
}
61+
return replaceAttrs(replaceAttr, []string{}, attrs...)
62+
}
63+
64+
func replaceAttrs(fn replaceAttrFn, groups []string, attrs ...slog.Attr) []slog.Attr {
65+
for i := range attrs {
66+
attr := attrs[i]
67+
value := attr.Value.Resolve()
68+
if value.Kind() == slog.KindGroup {
69+
attrs[i].Value = slog.GroupValue(replaceAttrs(fn, append(groups, attr.Key), value.Group()...)...)
70+
} else if fn != nil {
71+
attrs[i] = fn(groups, attr)
72+
}
73+
}
74+
75+
return attrs
76+
}
77+
78+
func source(sourceKey string, r *slog.Record) slog.Attr {
79+
fs := runtime.CallersFrames([]uintptr{r.PC})
80+
f, _ := fs.Next()
81+
var args []any
82+
if f.Function != "" {
83+
args = append(args, slog.String("function", f.Function))
84+
}
85+
if f.File != "" {
86+
args = append(args, slog.String("file", f.File))
87+
}
88+
if f.Line != 0 {
89+
args = append(args, slog.Int("line", f.Line))
90+
}
91+
92+
return slog.Group(sourceKey, args...)
93+
}
94+
95+
func removeEmptyAttrs(attrs []slog.Attr) []slog.Attr {
96+
return lo.FilterMap(attrs, func(attr slog.Attr, _ int) (slog.Attr, bool) {
97+
if attr.Key == "" {
98+
return attr, false
99+
}
100+
101+
if attr.Value.Kind() == slog.KindGroup {
102+
values := removeEmptyAttrs(attr.Value.Group())
103+
if len(values) == 0 {
104+
return attr, false
105+
}
106+
107+
attr.Value = slog.GroupValue(values...)
108+
return attr, true
109+
}
110+
111+
return attr, !attr.Value.Equal(slog.Value{})
112+
})
113+
}
114+
115+
func attrsToMap(attrs ...slog.Attr) map[string]any {
116+
output := map[string]any{}
117+
118+
attrsByKey := groupValuesByKey(attrs)
119+
for k, values := range attrsByKey {
120+
v := mergeAttrValues(values...)
121+
if v.Kind() == slog.KindGroup {
122+
output[k] = attrsToMap(v.Group()...)
123+
} else {
124+
output[k] = v.Any()
125+
}
126+
}
127+
128+
return output
129+
}
130+
131+
func groupValuesByKey(attrs []slog.Attr) map[string][]slog.Value {
132+
result := map[string][]slog.Value{}
133+
134+
for _, item := range attrs {
135+
key := item.Key
136+
result[key] = append(result[key], item.Value)
137+
}
138+
139+
return result
140+
}
141+
142+
func mergeAttrValues(values ...slog.Value) slog.Value {
143+
v := values[0]
144+
145+
for i := 1; i < len(values); i++ {
146+
if v.Kind() != slog.KindGroup || values[i].Kind() != slog.KindGroup {
147+
v = values[i]
148+
continue
149+
}
150+
151+
v = slog.GroupValue(append(v.Group(), values[i].Group()...)...)
152+
}
153+
154+
return v
155+
}
156+
157+
func appendAttrsToGroup(groups []string, actualAttrs []slog.Attr, newAttrs ...slog.Attr) []slog.Attr {
158+
actualAttrs = slices.Clone(actualAttrs)
159+
160+
if len(groups) == 0 {
161+
return uniqAttrs(append(actualAttrs, newAttrs...))
162+
}
163+
164+
for i := range actualAttrs {
165+
attr := actualAttrs[i]
166+
if attr.Key == groups[0] && attr.Value.Kind() == slog.KindGroup {
167+
actualAttrs[i] = slog.Group(groups[0], lo.ToAnySlice(appendAttrsToGroup(groups[1:], attr.Value.Group(), newAttrs...))...)
168+
return actualAttrs
169+
}
170+
}
171+
172+
return uniqAttrs(
173+
append(
174+
actualAttrs,
175+
slog.Group(
176+
groups[0],
177+
lo.ToAnySlice(appendAttrsToGroup(groups[1:], []slog.Attr{}, newAttrs...))...,
178+
),
179+
),
180+
)
181+
}
182+
183+
func uniqAttrs(attrs []slog.Attr) []slog.Attr {
184+
return uniqByLast(attrs, func(item slog.Attr) string {
185+
return item.Key
186+
})
187+
}
188+
189+
func uniqByLast[T any, U comparable](collection []T, iteratee func(item T) U) []T {
190+
result := make([]T, 0, len(collection))
191+
seen := make(map[U]int, len(collection))
192+
seenIndex := 0
193+
194+
for _, item := range collection {
195+
key := iteratee(item)
196+
197+
if index, ok := seen[key]; ok {
198+
result[index] = item
199+
continue
200+
}
201+
202+
seen[key] = seenIndex
203+
seenIndex++
204+
result = append(result, item)
205+
}
206+
207+
return result
208+
}
209+
210+
func contextExtractor(ctx context.Context, fns []func(ctx context.Context) []slog.Attr) []slog.Attr {
211+
var attrs []slog.Attr
212+
for _, fn := range fns {
213+
attrs = append(attrs, fn(ctx)...)
214+
}
215+
return attrs
216+
}

slog/handler.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package slog
2+
3+
import (
4+
"context"
5+
"github.com/snabble/go-logging/v2"
6+
7+
"log/slog"
8+
9+
"github.com/sirupsen/logrus"
10+
)
11+
12+
var logLevelsFromSlog = map[slog.Level]logrus.Level{
13+
slog.LevelDebug: logrus.DebugLevel,
14+
slog.LevelInfo: logrus.InfoLevel,
15+
slog.LevelWarn: logrus.WarnLevel,
16+
slog.LevelError: logrus.ErrorLevel,
17+
}
18+
19+
var logLevelsToSlog = map[logrus.Level]slog.Level{
20+
logrus.DebugLevel: slog.LevelDebug,
21+
logrus.InfoLevel: slog.LevelInfo,
22+
logrus.WarnLevel: slog.LevelWarn,
23+
logrus.ErrorLevel: slog.LevelError,
24+
}
25+
26+
type Option struct {
27+
Level slog.Level
28+
Logger *logging.Logger
29+
AttrFromContext []func(ctx context.Context) []slog.Attr
30+
AddSource bool
31+
ReplaceAttr func(groups []string, a slog.Attr) slog.Attr
32+
}
33+
34+
func New() *slog.Logger {
35+
return slog.New(
36+
Option{
37+
Level: logLevelsToSlog[logging.Log.GetLevel()],
38+
Logger: logging.Log,
39+
}.newLogrusHandler())
40+
}
41+
42+
func (o Option) newLogrusHandler() slog.Handler {
43+
if o.AttrFromContext == nil {
44+
o.AttrFromContext = []func(ctx context.Context) []slog.Attr{}
45+
}
46+
47+
return &LogrusHandler{
48+
option: o,
49+
attrs: []slog.Attr{},
50+
groups: []string{},
51+
}
52+
}
53+
54+
type LogrusHandler struct {
55+
option Option
56+
attrs []slog.Attr
57+
groups []string
58+
}
59+
60+
func (h *LogrusHandler) Enabled(_ context.Context, level slog.Level) bool {
61+
return level >= h.option.Level.Level()
62+
}
63+
64+
func (h *LogrusHandler) Handle(ctx context.Context, record slog.Record) error {
65+
level := logLevelsFromSlog[record.Level]
66+
fromContext := contextExtractor(ctx, h.option.AttrFromContext)
67+
args := convert(h.option.AddSource, h.option.ReplaceAttr, append(h.attrs, fromContext...), h.groups, &record)
68+
69+
logging.NewEntry(h.option.Logger).
70+
WithContext(ctx).
71+
WithTime(record.Time).
72+
WithFields(args).
73+
Log(level, record.Message)
74+
75+
return nil
76+
}
77+
78+
func (h *LogrusHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
79+
return &LogrusHandler{
80+
option: h.option,
81+
attrs: appendAttrsToGroup(h.groups, h.attrs, attrs...),
82+
groups: h.groups,
83+
}
84+
}
85+
86+
func (h *LogrusHandler) WithGroup(name string) slog.Handler {
87+
if name == "" {
88+
return h
89+
}
90+
91+
return &LogrusHandler{
92+
option: h.option,
93+
attrs: h.attrs,
94+
groups: append(h.groups, name),
95+
}
96+
}

slog/handler_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package slog
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"github.com/sirupsen/logrus"
8+
"github.com/snabble/go-logging/v2"
9+
"github.com/stretchr/testify/assert"
10+
"testing"
11+
)
12+
13+
func Test_Slog_Set(t *testing.T) {
14+
a := assert.New(t)
15+
16+
// given: an error logger in text format
17+
logging.Set("error", true)
18+
defer logging.Set("info", false)
19+
logging.Log.Formatter.(*logrus.TextFormatter).DisableColors = true
20+
b := bytes.NewBuffer(nil)
21+
logging.Log.Out = b
22+
23+
slog := New()
24+
25+
// when: I log something
26+
slog.Info("should be ignored ..")
27+
slog.With("foo", "bar").Error("oops")
28+
29+
// then: only the error text is contained, and it is text formatted
30+
a.Regexp(`^time.* level\=error msg\=oops foo\=bar.*`, b.String())
31+
}
32+
33+
func Test_Slog_WithError(t *testing.T) {
34+
a := assert.New(t)
35+
36+
// given: an logger in text format
37+
logging.Set("info", true)
38+
defer logging.Set("info", false)
39+
logging.Log.Formatter.(*logrus.TextFormatter).DisableColors = true
40+
b := bytes.NewBuffer(nil)
41+
logging.Log.Out = b
42+
43+
slog := New()
44+
45+
err := func() error {
46+
return fmt.Errorf("found an error: %w", errors.New("an error occurred"))
47+
}()
48+
slog.Error("oops", "error", err)
49+
50+
a.Regexp(`^time.* level\=error msg\=oops error\="found an error: an error occurred"`, b.String())
51+
}

0 commit comments

Comments
 (0)