Skip to content

Commit

Permalink
[Feature] Add experiment zapslog package to integrate with slog (#1246
Browse files Browse the repository at this point in the history
)

Ref #1179

This PR provides a implementation of `slog.Handler` in a separate
experiment `exp/zapslog` module as suggested by @abhinav.
  • Loading branch information
knight42 committed Mar 17, 2023
1 parent 54862f5 commit f2f1f41
Show file tree
Hide file tree
Showing 7 changed files with 385 additions and 1 deletion.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ BENCH_FLAGS ?= -cpuprofile=cpu.pprof -memprofile=mem.pprof -benchmem
# Directories containing independent Go modules.
#
# We track coverage only for the main module.
MODULE_DIRS = . ./benchmarks ./zapgrpc/internal/test
MODULE_DIRS = . ./exp ./benchmarks ./zapgrpc/internal/test

# Many Go tools take file globs or directories as arguments instead of packages.
GO_FILES := $(shell \
Expand Down
17 changes: 17 additions & 0 deletions exp/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module go.uber.org/zap/exp

go 1.19

require (
github.com/stretchr/testify v1.8.1
go.uber.org/zap v1.24.0
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
28 changes: 28 additions & 0 deletions exp/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
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/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
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/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/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
27 changes: 27 additions & 0 deletions exp/zapslog/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) 2023 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

// Package zapslog provides an implementation of slog.Handler which writes to
// the supplied zapcore.Core.
//
// Since the slog proposal has not been officially accepted by the creation of this package,
// we do not want Zap's standard packages to take a dependency on `golang.org/x/exp/slog`.
// Instead, we provide this separate module as a way for users to integrate Zap with slog.
package zapslog // import "go.uber.org/zap/exp/zapslog"
77 changes: 77 additions & 0 deletions exp/zapslog/example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) 2023 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

package zapslog_test

import (
"context"
"net"
"time"

"go.uber.org/zap"
"go.uber.org/zap/exp/zapslog"
"golang.org/x/exp/slog"
)

type Password string

func (p Password) LogValue() slog.Value {
return slog.StringValue("REDACTED")
}

func Example_slog() {
logger := zap.NewExample(zap.IncreaseLevel(zap.InfoLevel))
defer logger.Sync()

sl := slog.New(zapslog.NewHandler(logger.Core()))
ctx := context.Background()

sl.Info("user", "name", "Al", "secret", Password("secret"))
sl.Error("oops", "err", net.ErrClosed, "status", 500)
sl.LogAttrs(
ctx,
slog.LevelError,
"oops",
slog.Any("err", net.ErrClosed),
slog.Int("status", 500),
)
sl.Info("message",
slog.Group("group",
slog.Float64("pi", 3.14),
slog.Duration("1min", time.Minute),
),
)
sl.WithGroup("s").LogAttrs(
ctx,
slog.LevelWarn,
"warn msg", // message
slog.Uint64("u", 1),
slog.Any("m", map[string]any{
"foo": "bar",
}))
sl.LogAttrs(ctx, slog.LevelDebug, "not show up")

// Output:
// {"level":"info","msg":"user","name":"Al","secret":"REDACTED"}
// {"level":"error","msg":"oops","err":"use of closed network connection","status":500}
// {"level":"error","msg":"oops","err":"use of closed network connection","status":500}
// {"level":"info","msg":"message","group":{"pi":3.14,"1min":"1m0s"}}
// {"level":"warn","msg":"warn msg","s":{"u":1,"m":{"foo":"bar"}}}
}
187 changes: 187 additions & 0 deletions exp/zapslog/slog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// Copyright (c) 2023 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

package zapslog

import (
"context"
"runtime"

"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"golang.org/x/exp/slog"
)

// Handler implements the slog.Handler by writing to a zap Core.
type Handler struct {
core zapcore.Core
name string // logger name
addSource bool
}

// HandlerOptions are options for a Zap-based [slog.Handler].
type HandlerOptions struct {
// LoggerName is used for log entries received from slog.
//
// Defaults to empty.
LoggerName string

// AddSource configures the handler to annotate each message with the filename,
// line number, and function name.
// AddSource is false by default to skip the cost of computing
// this information.
AddSource bool
}

// New builds a [Handler] that writes to the supplied [zapcore.Core].
// This handler may be supplied to [slog.New] to create a new [slog.Logger].
func (opts HandlerOptions) New(core zapcore.Core) *Handler {
return &Handler{
core: core,
name: opts.LoggerName,
addSource: opts.AddSource,
}
}

// NewHandler builds a [Handler] that writes to the supplied [zapcore.Core]
// with the default options.
func NewHandler(core zapcore.Core) *Handler {
var opts HandlerOptions
return opts.New(core)
}

var _ slog.Handler = (*Handler)(nil)

// groupObject holds all the Attrs saved in a slog.GroupValue.
type groupObject []slog.Attr

func (gs groupObject) MarshalLogObject(enc zapcore.ObjectEncoder) error {
for _, attr := range gs {
convertAttrToField(attr).AddTo(enc)
}
return nil
}

func convertAttrToField(attr slog.Attr) zapcore.Field {
switch attr.Value.Kind() {
case slog.KindBool:
return zap.Bool(attr.Key, attr.Value.Bool())
case slog.KindDuration:
return zap.Duration(attr.Key, attr.Value.Duration())
case slog.KindFloat64:
return zap.Float64(attr.Key, attr.Value.Float64())
case slog.KindInt64:
return zap.Int64(attr.Key, attr.Value.Int64())
case slog.KindString:
return zap.String(attr.Key, attr.Value.String())
case slog.KindTime:
return zap.Time(attr.Key, attr.Value.Time())
case slog.KindUint64:
return zap.Uint64(attr.Key, attr.Value.Uint64())
case slog.KindGroup:
return zap.Object(attr.Key, groupObject(attr.Value.Group()))
case slog.KindLogValuer:
return convertAttrToField(slog.Attr{
Key: attr.Key,
// TODO: resolve the value in a lazy way
Value: attr.Value.Resolve(),
})
default:
return zap.Any(attr.Key, attr.Value.Any())
}
}

// convertSlogLevel maps slog Levels to zap Levels.
// Note that there is some room between slog levels while zap levels are continuous, so we can't 1:1 map them.
// See also https://go.googlesource.com/proposal/+/master/design/56345-structured-logging.md?pli=1#levels
func convertSlogLevel(l slog.Level) zapcore.Level {
switch {
case l >= slog.LevelError:
return zapcore.ErrorLevel
case l >= slog.LevelWarn:
return zapcore.WarnLevel
case l >= slog.LevelInfo:
return zapcore.InfoLevel
default:
return zapcore.DebugLevel
}
}

// Enabled reports whether the handler handles records at the given level.
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
return h.core.Enabled(convertSlogLevel(level))
}

// Handle handles the Record.
func (h *Handler) Handle(ctx context.Context, record slog.Record) error {
ent := zapcore.Entry{
Level: convertSlogLevel(record.Level),
Time: record.Time,
Message: record.Message,
LoggerName: h.name,
// TODO: do we need to set the following fields?
// Stack:
}
ce := h.core.Check(ent, nil)
if ce == nil {
return nil
}

if h.addSource {
frame, _ := runtime.CallersFrames([]uintptr{record.PC}).Next()
ce.Caller = zapcore.EntryCaller{
Defined: true,
PC: frame.PC,
File: frame.File,
Line: frame.Line,
Function: frame.Function,
}
}

fields := make([]zapcore.Field, 0, record.NumAttrs())
record.Attrs(func(attr slog.Attr) {
fields = append(fields, convertAttrToField(attr))
})
ce.Write(fields...)
return nil
}

// WithAttrs returns a new Handler whose attributes consist of
// both the receiver's attributes and the arguments.
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
fields := make([]zapcore.Field, len(attrs))
for i, attr := range attrs {
fields[i] = convertAttrToField(attr)
}
return h.withFields(fields...)
}

// WithGroup returns a new Handler with the given group appended to
// the receiver's existing groups.
func (h *Handler) WithGroup(group string) slog.Handler {
return h.withFields(zap.Namespace(group))
}

// withFields returns a cloned Handler with the given fields.
func (h *Handler) withFields(fields ...zapcore.Field) *Handler {
cloned := *h
cloned.core = h.core.With(fields)
return &cloned
}
48 changes: 48 additions & 0 deletions exp/zapslog/slog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (c) 2023 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

package zapslog

import (
"testing"

"github.com/stretchr/testify/require"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"golang.org/x/exp/slog"
)

func TestAddSource(t *testing.T) {
r := require.New(t)
fac, logs := observer.New(zapcore.DebugLevel)
sl := slog.New(HandlerOptions{
AddSource: true,
}.New(fac))
sl.Info("msg")

r.Len(logs.AllUntimed(), 1, "Expected exactly one entry to be logged")
entry := logs.AllUntimed()[0]
r.Equal("msg", entry.Entry.Message, "Unexpected message")
r.Regexp(
`/slog_test.go:\d+$`,
entry.Caller.String(),
"Unexpected caller annotation.",
)
}

0 comments on commit f2f1f41

Please sign in to comment.