Skip to content

Commit

Permalink
support slog
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
pohly committed Aug 25, 2023
1 parent b34ac77 commit a3a9385
Show file tree
Hide file tree
Showing 10 changed files with 679 additions and 32 deletions.
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ require (
github.com/stretchr/testify v1.8.0
go.uber.org/zap v1.24.0
)

replace github.com/go-logr/logr => github.com/pohly/logr v1.0.1-0.20230825095352-71b33dce969d
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZx
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
Expand All @@ -15,6 +13,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pohly/logr v1.0.1-0.20230825095352-71b33dce969d h1:62UYJwmNslNP9Cz19zBYqNJ+t0RyMFewuqGPOR0XdY0=
github.com/pohly/logr v1.0.1-0.20230825095352-71b33dce969d/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
Expand Down
182 changes: 182 additions & 0 deletions slog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
//go:build go1.21
// +build go1.21

/*
Copyright 2023 The logr Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package zapr_test

import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
"testing/slogtest"

"github.com/go-logr/logr/slogr"
"github.com/go-logr/zapr"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)

func TestSlogHandler(t *testing.T) {
var buffer bytes.Buffer
encoder := zapcore.NewJSONEncoder(zapcore.EncoderConfig{
MessageKey: slog.MessageKey,
TimeKey: slog.TimeKey,
LevelKey: slog.LevelKey,
EncodeLevel: func(level zapcore.Level, encoder zapcore.PrimitiveArrayEncoder) {
encoder.AppendInt(int(level))
},
})
core := zapcore.NewCore(encoder, zapcore.AddSync(&buffer), zapcore.Level(0))
zl := zap.New(core)
logger := zapr.NewLogger(zl)
handler := slogr.NewSlogHandler(logger)

err := slogtest.TestHandler(handler, func() []map[string]any {
zl.Sync()
return parseOutput(t, buffer.Bytes())
})
t.Logf("Log output:\n%s\nAs JSON:\n%v\n", buffer.String(), parseOutput(t, buffer.Bytes()))
// Correlating failures with individual test cases is hard with the current API.
// See https://github.com/golang/go/issues/61758
t.Logf("Output:\n%s", buffer.String())
if err != nil {
if err, ok := err.(interface {
Unwrap() []error
}); ok {
for _, err := range err.Unwrap() {
if !containsOne(err.Error(),
"a Handler should ignore a zero Record.Time", // zapr always writes a time field.
"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
) {
t.Errorf("Unexpected error: %v", err)
}
}
} else {
// Shouldn't be reached, errors from errors.Join can be split up.
t.Errorf("Unexpected errors:\n%v", err)
}
}
}

func containsOne(hay string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(hay, needle) {
return true
}
}
return false
}

// TestSlogCases covers some gaps in the coverage we get from
// slogtest.TestHandler (empty and invalud PC, see
// https://github.com/golang/go/issues/62280) and verbosity handling in
// combination with V().
func TestSlogCases(t *testing.T) {
for name, tc := range map[string]struct {
record slog.Record
v int
expected string
}{
"empty": {
expected: `{"msg":"", "level":"info", "v":0}`,
},
"invalid-pc": {
record: slog.Record{PC: 1},
expected: `{"msg":"", "level":"info", "v":0}`,
},
"debug": {
record: slog.Record{Level: slog.LevelDebug},
expected: `{"msg":"", "level":"Level(-4)", "v":4}`,
},
"warn": {
record: slog.Record{Level: slog.LevelWarn},
expected: `{"msg":"", "level":"warn", "v":0}`,
},
"error": {
record: slog.Record{Level: slog.LevelError},
expected: `{"msg":"", "level":"error"}`,
},
"debug-v1": {
v: 1,
record: slog.Record{Level: slog.LevelDebug},
expected: `{"msg":"", "level":"Level(-5)", "v":5}`,
},
"warn-v1": {
v: 1,
record: slog.Record{Level: slog.LevelWarn},
expected: `{"msg":"", "level":"info", "v":0}`,
},
"error-v1": {
v: 1,
record: slog.Record{Level: slog.LevelError},
expected: `{"msg":"", "level":"error"}`,
},
"debug-v4": {
v: 4,
record: slog.Record{Level: slog.LevelDebug},
expected: `{"msg":"", "level":"Level(-8)", "v":8}`,
},
"warn-v4": {
v: 4,
record: slog.Record{Level: slog.LevelWarn},
expected: `{"msg":"", "level":"info", "v":0}`,
},
"error-v4": {
v: 4,
record: slog.Record{Level: slog.LevelError},
expected: `{"msg":"", "level":"error"}`,
},
} {
t.Run(name, func(t *testing.T) {
var buffer bytes.Buffer
encoder := zapcore.NewJSONEncoder(zapcore.EncoderConfig{
MessageKey: slog.MessageKey,
LevelKey: slog.LevelKey,
EncodeLevel: func(level zapcore.Level, encoder zapcore.PrimitiveArrayEncoder) {
encoder.AppendString(level.String())
},
})
core := zapcore.NewCore(encoder, zapcore.AddSync(&buffer), zapcore.Level(-10))
zl := zap.New(core)
logger := zapr.NewLoggerWithOptions(zl, zapr.LogInfoLevel("v"))
handler := slogr.NewSlogHandler(logger.V(tc.v))
require.NoError(t, handler.Handle(context.Background(), tc.record))
zl.Sync()
require.JSONEq(t, tc.expected, buffer.String())
})
}
}

func parseOutput(t *testing.T, output []byte) []map[string]any {
var ms []map[string]any
for _, line := range bytes.Split(output, []byte{'\n'}) {
if len(line) == 0 {
continue
}
var m map[string]any
if err := json.Unmarshal(line, &m); err != nil {
t.Fatal(err)
}
ms = append(ms, m)
}
return ms
}
183 changes: 183 additions & 0 deletions slogzapr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//go:build go1.21
// +build go1.21

/*
Copyright 2019 The logr Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package zapr

import (
"context"
"log/slog"
"runtime"

"github.com/go-logr/logr/slogr"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)

var _ slogr.SlogSink = &zapLogger{}

func (zl *zapLogger) Handle(ctx context.Context, record slog.Record) error {
zapLevel := zap.InfoLevel
intLevel := 0
isError := false
switch {
case record.Level >= slog.LevelError:
zapLevel = zap.ErrorLevel
isError = true
case record.Level >= slog.LevelWarn:
zapLevel = zap.WarnLevel
case record.Level >= 0:
// Already set above -> info.
default:
zapLevel = zapcore.Level(record.Level)
intLevel = int(-zapLevel)
}

if checkedEntry := zl.l.Check(zapLevel, record.Message); checkedEntry != nil {
checkedEntry.Time = record.Time
checkedEntry.Caller = pcToCallerEntry(record.PC)
var fieldsBuffer [2]zap.Field
fields := fieldsBuffer[:0]
if !isError && zl.numericLevelKey != "" {
// Record verbosity for info entries.
fields = append(fields, zap.Int(zl.numericLevelKey, intLevel))
}
// Inline all attributes.
fields = append(fields, zap.Inline(zapcore.ObjectMarshalerFunc(func(enc zapcore.ObjectEncoder) error {
record.Attrs(func(attr slog.Attr) bool {
encodeSlog(enc, attr)
return true
})
return nil
})))
checkedEntry.Write(fields...)
}
return nil
}

func encodeSlog(enc zapcore.ObjectEncoder, attr slog.Attr) {
if attr.Equal(slog.Attr{}) {
// Ignore empty attribute.
return
}

// Check in order of expected frequency, most common ones first.
//
// Usage statistics for parameters from Kubernetes 152876a3e,
// calculated with k/k/test/integration/logs/benchmark:
//
// kube-controller-manager -v10:
// strings: 10043 (85%)
// with API objects: 2 (0% of all arguments)
// types and their number of usage: NodeStatus:2
// numbers: 792 (6%)
// ObjectRef: 292 (2%)
// others: 595 (5%)
//
// kube-scheduler -v10:
// strings: 1325 (40%)
// with API objects: 109 (3% of all arguments)
// types and their number of usage: PersistentVolume:50 PersistentVolumeClaim:59
// numbers: 473 (14%)
// ObjectRef: 1305 (39%)
// others: 176 (5%)

kind := attr.Value.Kind()
switch kind {
case slog.KindString:
enc.AddString(attr.Key, attr.Value.String())
case slog.KindLogValuer:
// This includes klog.KObj.
encodeSlog(enc, slog.Attr{
Key: attr.Key,
Value: attr.Value.Resolve(),
})
case slog.KindInt64:
enc.AddInt64(attr.Key, attr.Value.Int64())
case slog.KindUint64:
enc.AddUint64(attr.Key, attr.Value.Uint64())
case slog.KindFloat64:
enc.AddFloat64(attr.Key, attr.Value.Float64())
case slog.KindBool:
enc.AddBool(attr.Key, attr.Value.Bool())
case slog.KindDuration:
enc.AddDuration(attr.Key, attr.Value.Duration())
case slog.KindTime:
enc.AddTime(attr.Key, attr.Value.Time())
case slog.KindGroup:
attrs := attr.Value.Group()
if attr.Key == "" {
// Inline group.
for _, attr := range attrs {
encodeSlog(enc, attr)
}
return
}
if len(attrs) == 0 {
// Ignore empty group.
return
}
enc.AddObject(attr.Key, marshalAttrs(attrs))
default:
// We have to go through reflection in zap.Any to get support
// for e.g. fmt.Stringer.
zap.Any(attr.Key, attr.Value.Any()).AddTo(enc)
}
}

type marshalAttrs []slog.Attr

func (attrs marshalAttrs) MarshalLogObject(enc zapcore.ObjectEncoder) error {
for _, attr := range attrs {
encodeSlog(enc, attr)
}
return nil
}

var _ zapcore.ObjectMarshaler = marshalAttrs(nil)

func pcToCallerEntry(pc uintptr) zapcore.EntryCaller {
if pc == 0 {
return zapcore.EntryCaller{}
}
// Same as https://cs.opensource.google/go/x/exp/+/642cacee:slog/record.go;drc=642cacee5cc05231f45555a333d07f1005ffc287;l=70
fs := runtime.CallersFrames([]uintptr{pc})
f, _ := fs.Next()
if f.File == "" {
return zapcore.EntryCaller{}
}
return zapcore.EntryCaller{
Defined: true,
PC: pc,
File: f.File,
Line: f.Line,
Function: f.Function,
}
}

func (zl *zapLogger) WithAttrs(attrs []slog.Attr) slogr.SlogSink {
newLogger := *zl
newLogger.l = newLogger.l.With(zap.Inline(marshalAttrs(attrs)))
return &newLogger
}

func (zl *zapLogger) WithGroup(name string) slogr.SlogSink {
newLogger := *zl
newLogger.l = newLogger.l.With(zap.Namespace(name))
return &newLogger
}

0 comments on commit a3a9385

Please sign in to comment.