Skip to content

Commit a3a9385

Browse files
committed
support slog
When built with Go >= 1.21, zapr implements an additional interface which adds support for directly logging a slog.Record. The verbosity level in such records gets adjusted by the logger's verbosity, but only if the record has a level < slog.LevelError. To use zapr as slog handler, use slogr.NewSlogHandler(zapr.NewLogger(...)). In addition to supporting usage as a SlogHandler, special slog values (Group, LogValuer) are also supported, regardless of which front-end API is used.
1 parent b34ac77 commit a3a9385

File tree

10 files changed

+679
-32
lines changed

10 files changed

+679
-32
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ require (
88
github.com/stretchr/testify v1.8.0
99
go.uber.org/zap v1.24.0
1010
)
11+
12+
replace github.com/go-logr/logr => github.com/pohly/logr v1.0.1-0.20230825095352-71b33dce969d

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZx
33
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
44
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
55
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6-
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
7-
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
86
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
97
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
108
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -15,6 +13,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
1513
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
1614
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1715
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
16+
github.com/pohly/logr v1.0.1-0.20230825095352-71b33dce969d h1:62UYJwmNslNP9Cz19zBYqNJ+t0RyMFewuqGPOR0XdY0=
17+
github.com/pohly/logr v1.0.1-0.20230825095352-71b33dce969d/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
1818
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
1919
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
2020
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=

slog_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
//go:build go1.21
2+
// +build go1.21
3+
4+
/*
5+
Copyright 2023 The logr Authors.
6+
7+
Licensed under the Apache License, Version 2.0 (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing, software
14+
distributed under the License is distributed on an "AS IS" BASIS,
15+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
See the License for the specific language governing permissions and
17+
limitations under the License.
18+
*/
19+
20+
package zapr_test
21+
22+
import (
23+
"bytes"
24+
"context"
25+
"encoding/json"
26+
"log/slog"
27+
"strings"
28+
"testing"
29+
"testing/slogtest"
30+
31+
"github.com/go-logr/logr/slogr"
32+
"github.com/go-logr/zapr"
33+
"github.com/stretchr/testify/require"
34+
"go.uber.org/zap"
35+
"go.uber.org/zap/zapcore"
36+
)
37+
38+
func TestSlogHandler(t *testing.T) {
39+
var buffer bytes.Buffer
40+
encoder := zapcore.NewJSONEncoder(zapcore.EncoderConfig{
41+
MessageKey: slog.MessageKey,
42+
TimeKey: slog.TimeKey,
43+
LevelKey: slog.LevelKey,
44+
EncodeLevel: func(level zapcore.Level, encoder zapcore.PrimitiveArrayEncoder) {
45+
encoder.AppendInt(int(level))
46+
},
47+
})
48+
core := zapcore.NewCore(encoder, zapcore.AddSync(&buffer), zapcore.Level(0))
49+
zl := zap.New(core)
50+
logger := zapr.NewLogger(zl)
51+
handler := slogr.NewSlogHandler(logger)
52+
53+
err := slogtest.TestHandler(handler, func() []map[string]any {
54+
zl.Sync()
55+
return parseOutput(t, buffer.Bytes())
56+
})
57+
t.Logf("Log output:\n%s\nAs JSON:\n%v\n", buffer.String(), parseOutput(t, buffer.Bytes()))
58+
// Correlating failures with individual test cases is hard with the current API.
59+
// See https://github.com/golang/go/issues/61758
60+
t.Logf("Output:\n%s", buffer.String())
61+
if err != nil {
62+
if err, ok := err.(interface {
63+
Unwrap() []error
64+
}); ok {
65+
for _, err := range err.Unwrap() {
66+
if !containsOne(err.Error(),
67+
"a Handler should ignore a zero Record.Time", // zapr always writes a time field.
68+
"a Handler should not output groups for an empty Record", // Relies on WithGroup and that always opens a group. Text may change, see https://go.dev/cl/516155
69+
) {
70+
t.Errorf("Unexpected error: %v", err)
71+
}
72+
}
73+
} else {
74+
// Shouldn't be reached, errors from errors.Join can be split up.
75+
t.Errorf("Unexpected errors:\n%v", err)
76+
}
77+
}
78+
}
79+
80+
func containsOne(hay string, needles ...string) bool {
81+
for _, needle := range needles {
82+
if strings.Contains(hay, needle) {
83+
return true
84+
}
85+
}
86+
return false
87+
}
88+
89+
// TestSlogCases covers some gaps in the coverage we get from
90+
// slogtest.TestHandler (empty and invalud PC, see
91+
// https://github.com/golang/go/issues/62280) and verbosity handling in
92+
// combination with V().
93+
func TestSlogCases(t *testing.T) {
94+
for name, tc := range map[string]struct {
95+
record slog.Record
96+
v int
97+
expected string
98+
}{
99+
"empty": {
100+
expected: `{"msg":"", "level":"info", "v":0}`,
101+
},
102+
"invalid-pc": {
103+
record: slog.Record{PC: 1},
104+
expected: `{"msg":"", "level":"info", "v":0}`,
105+
},
106+
"debug": {
107+
record: slog.Record{Level: slog.LevelDebug},
108+
expected: `{"msg":"", "level":"Level(-4)", "v":4}`,
109+
},
110+
"warn": {
111+
record: slog.Record{Level: slog.LevelWarn},
112+
expected: `{"msg":"", "level":"warn", "v":0}`,
113+
},
114+
"error": {
115+
record: slog.Record{Level: slog.LevelError},
116+
expected: `{"msg":"", "level":"error"}`,
117+
},
118+
"debug-v1": {
119+
v: 1,
120+
record: slog.Record{Level: slog.LevelDebug},
121+
expected: `{"msg":"", "level":"Level(-5)", "v":5}`,
122+
},
123+
"warn-v1": {
124+
v: 1,
125+
record: slog.Record{Level: slog.LevelWarn},
126+
expected: `{"msg":"", "level":"info", "v":0}`,
127+
},
128+
"error-v1": {
129+
v: 1,
130+
record: slog.Record{Level: slog.LevelError},
131+
expected: `{"msg":"", "level":"error"}`,
132+
},
133+
"debug-v4": {
134+
v: 4,
135+
record: slog.Record{Level: slog.LevelDebug},
136+
expected: `{"msg":"", "level":"Level(-8)", "v":8}`,
137+
},
138+
"warn-v4": {
139+
v: 4,
140+
record: slog.Record{Level: slog.LevelWarn},
141+
expected: `{"msg":"", "level":"info", "v":0}`,
142+
},
143+
"error-v4": {
144+
v: 4,
145+
record: slog.Record{Level: slog.LevelError},
146+
expected: `{"msg":"", "level":"error"}`,
147+
},
148+
} {
149+
t.Run(name, func(t *testing.T) {
150+
var buffer bytes.Buffer
151+
encoder := zapcore.NewJSONEncoder(zapcore.EncoderConfig{
152+
MessageKey: slog.MessageKey,
153+
LevelKey: slog.LevelKey,
154+
EncodeLevel: func(level zapcore.Level, encoder zapcore.PrimitiveArrayEncoder) {
155+
encoder.AppendString(level.String())
156+
},
157+
})
158+
core := zapcore.NewCore(encoder, zapcore.AddSync(&buffer), zapcore.Level(-10))
159+
zl := zap.New(core)
160+
logger := zapr.NewLoggerWithOptions(zl, zapr.LogInfoLevel("v"))
161+
handler := slogr.NewSlogHandler(logger.V(tc.v))
162+
require.NoError(t, handler.Handle(context.Background(), tc.record))
163+
zl.Sync()
164+
require.JSONEq(t, tc.expected, buffer.String())
165+
})
166+
}
167+
}
168+
169+
func parseOutput(t *testing.T, output []byte) []map[string]any {
170+
var ms []map[string]any
171+
for _, line := range bytes.Split(output, []byte{'\n'}) {
172+
if len(line) == 0 {
173+
continue
174+
}
175+
var m map[string]any
176+
if err := json.Unmarshal(line, &m); err != nil {
177+
t.Fatal(err)
178+
}
179+
ms = append(ms, m)
180+
}
181+
return ms
182+
}

slogzapr.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
//go:build go1.21
2+
// +build go1.21
3+
4+
/*
5+
Copyright 2019 The logr Authors.
6+
7+
Licensed under the Apache License, Version 2.0 (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing, software
14+
distributed under the License is distributed on an "AS IS" BASIS,
15+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
See the License for the specific language governing permissions and
17+
limitations under the License.
18+
*/
19+
20+
package zapr
21+
22+
import (
23+
"context"
24+
"log/slog"
25+
"runtime"
26+
27+
"github.com/go-logr/logr/slogr"
28+
"go.uber.org/zap"
29+
"go.uber.org/zap/zapcore"
30+
)
31+
32+
var _ slogr.SlogSink = &zapLogger{}
33+
34+
func (zl *zapLogger) Handle(ctx context.Context, record slog.Record) error {
35+
zapLevel := zap.InfoLevel
36+
intLevel := 0
37+
isError := false
38+
switch {
39+
case record.Level >= slog.LevelError:
40+
zapLevel = zap.ErrorLevel
41+
isError = true
42+
case record.Level >= slog.LevelWarn:
43+
zapLevel = zap.WarnLevel
44+
case record.Level >= 0:
45+
// Already set above -> info.
46+
default:
47+
zapLevel = zapcore.Level(record.Level)
48+
intLevel = int(-zapLevel)
49+
}
50+
51+
if checkedEntry := zl.l.Check(zapLevel, record.Message); checkedEntry != nil {
52+
checkedEntry.Time = record.Time
53+
checkedEntry.Caller = pcToCallerEntry(record.PC)
54+
var fieldsBuffer [2]zap.Field
55+
fields := fieldsBuffer[:0]
56+
if !isError && zl.numericLevelKey != "" {
57+
// Record verbosity for info entries.
58+
fields = append(fields, zap.Int(zl.numericLevelKey, intLevel))
59+
}
60+
// Inline all attributes.
61+
fields = append(fields, zap.Inline(zapcore.ObjectMarshalerFunc(func(enc zapcore.ObjectEncoder) error {
62+
record.Attrs(func(attr slog.Attr) bool {
63+
encodeSlog(enc, attr)
64+
return true
65+
})
66+
return nil
67+
})))
68+
checkedEntry.Write(fields...)
69+
}
70+
return nil
71+
}
72+
73+
func encodeSlog(enc zapcore.ObjectEncoder, attr slog.Attr) {
74+
if attr.Equal(slog.Attr{}) {
75+
// Ignore empty attribute.
76+
return
77+
}
78+
79+
// Check in order of expected frequency, most common ones first.
80+
//
81+
// Usage statistics for parameters from Kubernetes 152876a3e,
82+
// calculated with k/k/test/integration/logs/benchmark:
83+
//
84+
// kube-controller-manager -v10:
85+
// strings: 10043 (85%)
86+
// with API objects: 2 (0% of all arguments)
87+
// types and their number of usage: NodeStatus:2
88+
// numbers: 792 (6%)
89+
// ObjectRef: 292 (2%)
90+
// others: 595 (5%)
91+
//
92+
// kube-scheduler -v10:
93+
// strings: 1325 (40%)
94+
// with API objects: 109 (3% of all arguments)
95+
// types and their number of usage: PersistentVolume:50 PersistentVolumeClaim:59
96+
// numbers: 473 (14%)
97+
// ObjectRef: 1305 (39%)
98+
// others: 176 (5%)
99+
100+
kind := attr.Value.Kind()
101+
switch kind {
102+
case slog.KindString:
103+
enc.AddString(attr.Key, attr.Value.String())
104+
case slog.KindLogValuer:
105+
// This includes klog.KObj.
106+
encodeSlog(enc, slog.Attr{
107+
Key: attr.Key,
108+
Value: attr.Value.Resolve(),
109+
})
110+
case slog.KindInt64:
111+
enc.AddInt64(attr.Key, attr.Value.Int64())
112+
case slog.KindUint64:
113+
enc.AddUint64(attr.Key, attr.Value.Uint64())
114+
case slog.KindFloat64:
115+
enc.AddFloat64(attr.Key, attr.Value.Float64())
116+
case slog.KindBool:
117+
enc.AddBool(attr.Key, attr.Value.Bool())
118+
case slog.KindDuration:
119+
enc.AddDuration(attr.Key, attr.Value.Duration())
120+
case slog.KindTime:
121+
enc.AddTime(attr.Key, attr.Value.Time())
122+
case slog.KindGroup:
123+
attrs := attr.Value.Group()
124+
if attr.Key == "" {
125+
// Inline group.
126+
for _, attr := range attrs {
127+
encodeSlog(enc, attr)
128+
}
129+
return
130+
}
131+
if len(attrs) == 0 {
132+
// Ignore empty group.
133+
return
134+
}
135+
enc.AddObject(attr.Key, marshalAttrs(attrs))
136+
default:
137+
// We have to go through reflection in zap.Any to get support
138+
// for e.g. fmt.Stringer.
139+
zap.Any(attr.Key, attr.Value.Any()).AddTo(enc)
140+
}
141+
}
142+
143+
type marshalAttrs []slog.Attr
144+
145+
func (attrs marshalAttrs) MarshalLogObject(enc zapcore.ObjectEncoder) error {
146+
for _, attr := range attrs {
147+
encodeSlog(enc, attr)
148+
}
149+
return nil
150+
}
151+
152+
var _ zapcore.ObjectMarshaler = marshalAttrs(nil)
153+
154+
func pcToCallerEntry(pc uintptr) zapcore.EntryCaller {
155+
if pc == 0 {
156+
return zapcore.EntryCaller{}
157+
}
158+
// Same as https://cs.opensource.google/go/x/exp/+/642cacee:slog/record.go;drc=642cacee5cc05231f45555a333d07f1005ffc287;l=70
159+
fs := runtime.CallersFrames([]uintptr{pc})
160+
f, _ := fs.Next()
161+
if f.File == "" {
162+
return zapcore.EntryCaller{}
163+
}
164+
return zapcore.EntryCaller{
165+
Defined: true,
166+
PC: pc,
167+
File: f.File,
168+
Line: f.Line,
169+
Function: f.Function,
170+
}
171+
}
172+
173+
func (zl *zapLogger) WithAttrs(attrs []slog.Attr) slogr.SlogSink {
174+
newLogger := *zl
175+
newLogger.l = newLogger.l.With(zap.Inline(marshalAttrs(attrs)))
176+
return &newLogger
177+
}
178+
179+
func (zl *zapLogger) WithGroup(name string) slogr.SlogSink {
180+
newLogger := *zl
181+
newLogger.l = newLogger.l.With(zap.Namespace(name))
182+
return &newLogger
183+
}

0 commit comments

Comments
 (0)