diff --git a/README.md b/README.md index 24c0ae30..737f7786 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,16 @@ witchcraft-go-logging [![](https://godoc.org/github.com/palantir/witchcraft-go-logging?status.svg)](http://godoc.org/github.com/palantir/witchcraft-go-logging) `witchcraft-go-logging` is a Go implementation of the Witchcraft logging specification. It provides an API that can be -used for logging and some default implementations of the logging API using different existing popular Go logging -libraries. `witchcraft-go-logging` includes implementations that use [zap](https://github.com/uber-go/zap), -[zerolog](https://github.com/rs/zerolog) and [glog](https://github.com/golang/glog). +used for logging along with several implementations and adapters. + +**Implementations** wrap existing Go logging libraries in order to implement the wlog interface. We currently provide +- [zap](https://github.com/uber-go/zap) via [wlog-zap](wlog-zap) +- [zerolog](https://github.com/rs/zerolog) via [wlog-zerolog](wlog-zerolog) +- [glog](https://github.com/golang/glog) via [wlog-glog](wlog-glog) +- [wlog-tmpl](wlog-tmpl) for rendering structured logging using human-friendly templates. + +**Adapters** wrap the witchcraft-go-logging logger implementations (svc1log, ev2log, req2log, etc) to allow interoperability with other Go logging interfaces. We currently provide +- [svc1zap](adapters/svc1zap) wraps a svc1log.Logger to provide a [zap](https://github.com/uber-go/zap) Logger. Architecture ------------ diff --git a/adapters/svc1zap/svc1zap.go b/adapters/svc1zap/svc1zap.go new file mode 100644 index 00000000..2c650a70 --- /dev/null +++ b/adapters/svc1zap/svc1zap.go @@ -0,0 +1,147 @@ +// Copyright (c) 2022 Palantir Technologies. All rights reserved. +// +// 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 svc1zap + +import ( + "strings" + + "github.com/palantir/witchcraft-go-logging/internal/gopath" + "github.com/palantir/witchcraft-go-logging/wlog" + "github.com/palantir/witchcraft-go-logging/wlog/svclog/svc1log" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type svc1zapCore struct { + log svc1log.Logger + originFromCallLine bool + newParamFunc func(key string, value interface{}) svc1log.Param +} + +// New returns a zap logger that delegates to the provided svc1log logger. +// The enabled/disabled log level configuration on the returned zap logger is ignored in favor of the svc1log configuration. +func New(logger svc1log.Logger, opts ...Option) *zap.Logger { + core := NewCore(logger, opts...) + z := zap.New(core) + if core.(*svc1zapCore).originFromCallLine { + z.WithOptions(zap.AddCaller()) + } + return z +} + +func NewCore(logger svc1log.Logger, opts ...Option) zapcore.Core { + core := &svc1zapCore{log: logger} + for _, opt := range opts { + opt(core) + } + return core +} + +type Option func(*svc1zapCore) + +// WithOriginFromZapCaller enables zap.AddCaller() and uses the caller file and line to construct the origin value. +// Similar to svc1log.OriginFromCallLine(). +func WithOriginFromZapCaller() Option { + return func(core *svc1zapCore) { core.originFromCallLine = true } +} + +// WithNewParamFunc provides a function for constructing svc1log.Param values from zap fields. +// Use this option to control parameter safety. By default, all fields are converted to unsafe params. +// If newParam returns nil, the field is skipped. +func WithNewParamFunc(newParam func(key string, value interface{}) svc1log.Param) Option { + return func(core *svc1zapCore) { core.newParamFunc = newParam } +} + +func (c svc1zapCore) Enabled(level zapcore.Level) bool { + if checker, ok := c.log.(wlog.LevelChecker); ok { + switch level { + case zapcore.DebugLevel: + return checker.Enabled(wlog.DebugLevel) + case zapcore.InfoLevel: + return checker.Enabled(wlog.InfoLevel) + case zapcore.WarnLevel: + return checker.Enabled(wlog.WarnLevel) + default: + return checker.Enabled(wlog.ErrorLevel) + } + } + return true +} + +func (c svc1zapCore) With(fields []zapcore.Field) zapcore.Core { + return svc1zapCore{log: svc1log.WithParams(c.log, c.fieldsToWlogParams(fields)...)} +} + +func (c svc1zapCore) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + return ce.AddCore(entry, c) +} + +func (c svc1zapCore) Write(entry zapcore.Entry, fields []zapcore.Field) error { + message := formatMessage(entry) + params := c.fieldsToWlogParams(fields) + if c.originFromCallLine && entry.Caller.Defined { + params = append(params, svc1log.Origin(gopath.TrimPrefix(entry.Caller.FullPath()))) + } + switch entry.Level { + case zapcore.DebugLevel: + c.log.Debug(message, params...) + case zapcore.InfoLevel: + c.log.Info(message, params...) + case zapcore.WarnLevel: + c.log.Warn(message, params...) + default: + c.log.Error(message, params...) + } + return nil +} + +func (c svc1zapCore) Sync() error { return nil } + +func (c svc1zapCore) fieldsToWlogParams(fields []zapcore.Field) []svc1log.Param { + var params []svc1log.Param + for key, value := range fieldsToMap(fields) { + if c.newParamFunc != nil { + if p := c.newParamFunc(key, value); p != nil { + params = append(params, p) + } + } else { + params = append(params, svc1log.UnsafeParam(key, value)) + } + } + return params +} + +func formatMessage(entry zapcore.Entry) string { + if entry.LoggerName == "" { + return entry.Message + } + sb := strings.Builder{} + sb.Grow(len(entry.LoggerName) + 2 + len(entry.Message)) + sb.WriteString(entry.LoggerName) + sb.WriteString(": ") + sb.WriteString(entry.Message) + return sb.String() +} + +func fieldsToMap(fields []zapcore.Field) map[string]interface{} { + params := zapcore.NewMapObjectEncoder() + for _, field := range fields { + if field.Key == "token" { + continue // who logs a token...? + } + field.AddTo(params) + } + return params.Fields +} diff --git a/adapters/svc1zap/svc1zap_test.go b/adapters/svc1zap/svc1zap_test.go new file mode 100644 index 00000000..3473fb2f --- /dev/null +++ b/adapters/svc1zap/svc1zap_test.go @@ -0,0 +1,116 @@ +// Copyright (c) 2022 Palantir Technologies. All rights reserved. +// +// 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 svc1zap + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/palantir/pkg/objmatcher" + "github.com/palantir/witchcraft-go-logging/wlog" + "github.com/palantir/witchcraft-go-logging/wlog/svclog/svc1log" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + + // Use zap as logger implementation + _ "github.com/palantir/witchcraft-go-logging/wlog-zap" +) + +func TestSvc1ZapWrapper(t *testing.T) { + + prefixParamFunc := func(key string, value interface{}) svc1log.Param { + if strings.HasPrefix(key, "safe") { + return svc1log.SafeParam(key, value) + } + if !strings.HasPrefix(key, "forbidden") { + return svc1log.UnsafeParam(key, value) + } + return nil + } + + t.Run("defaults to all unsafe params", func(t *testing.T) { + buf := new(bytes.Buffer) + logger := svc1log.New(buf, wlog.DebugLevel) + logr1 := New(logger) + logr1.Info("logr 1", zap.String("safeString", "string"), zap.String("forbiddenToken", "token"), zap.Int("unsafeInt", 42)) + assertLogLine(t, buf.Bytes(), objmatcher.MapMatcher{ + "level": objmatcher.NewEqualsMatcher("INFO"), + "time": objmatcher.NewRegExpMatcher(".+"), + "message": objmatcher.NewEqualsMatcher("logr 1"), + "type": objmatcher.NewEqualsMatcher(svc1log.TypeValue), + "unsafeParams": objmatcher.MapMatcher{ + "forbiddenToken": objmatcher.NewEqualsMatcher("token"), + "safeString": objmatcher.NewEqualsMatcher("string"), + "unsafeInt": objmatcher.NewEqualsMatcher(float64(42)), + }, + }) + }) + + t.Run("caller origin and custom params", func(t *testing.T) { + buf := new(bytes.Buffer) + logger := svc1log.New(buf, wlog.DebugLevel) + logr2 := New(logger, WithOriginFromZapCaller(), WithNewParamFunc(prefixParamFunc)).WithOptions(zap.AddCaller()) + logr2.Info("logr 2", zap.String("safeString", "string"), zap.String("forbiddenToken", "token"), zap.Int("unsafeInt", 42)) + assertLogLine(t, buf.Bytes(), objmatcher.MapMatcher{ + "level": objmatcher.NewEqualsMatcher("INFO"), + "time": objmatcher.NewRegExpMatcher(".+"), + "message": objmatcher.NewEqualsMatcher("logr 2"), + "type": objmatcher.NewEqualsMatcher(svc1log.TypeValue), + "origin": objmatcher.NewRegExpMatcher("^github.com/palantir/witchcraft-go-logging/adapters/svc1zap/svc1zap_test.go:\\d+"), + "params": objmatcher.MapMatcher{ + "safeString": objmatcher.NewEqualsMatcher("string"), + }, + "unsafeParams": objmatcher.MapMatcher{ + "unsafeInt": objmatcher.NewEqualsMatcher(float64(42)), + }, + }) + }) + + t.Run("logger with attached params", func(t *testing.T) { + buf := new(bytes.Buffer) + logger := svc1log.New(buf, wlog.DebugLevel) + logr3 := New(logger).Named("logr3").With(zap.String("name", "logr3")) + logr3.Error("logr 3", zap.String("safeString", "string"), zap.String("forbiddenToken", "token"), zap.Int("unsafeInt", 42)) + assertLogLine(t, buf.Bytes(), objmatcher.MapMatcher{ + "level": objmatcher.NewEqualsMatcher("ERROR"), + "time": objmatcher.NewRegExpMatcher(".+"), + "message": objmatcher.NewEqualsMatcher("logr3: logr 3"), + "type": objmatcher.NewEqualsMatcher(svc1log.TypeValue), + "unsafeParams": objmatcher.MapMatcher{ + "forbiddenToken": objmatcher.NewEqualsMatcher("token"), + "name": objmatcher.NewEqualsMatcher("logr3"), + "safeString": objmatcher.NewEqualsMatcher("string"), + "unsafeInt": objmatcher.NewEqualsMatcher(float64(42)), + }, + }) + }) + + t.Run("logger with disabled level", func(t *testing.T) { + buf := new(bytes.Buffer) + logger := svc1log.New(buf, wlog.InfoLevel) + logr4 := New(logger) + logr4.Debug("logr 4") + assert.Empty(t, buf.String()) + }) +} + +func assertLogLine(t *testing.T, logLine []byte, matcher objmatcher.MapMatcher) { + logEntry := map[string]interface{}{} + err := json.Unmarshal(logLine, &logEntry) + assert.NoError(t, err) + assert.NoError(t, matcher.Matches(logEntry)) +} diff --git a/changelog/@unreleased/pr-198.v2.yml b/changelog/@unreleased/pr-198.v2.yml new file mode 100644 index 00000000..653ed432 --- /dev/null +++ b/changelog/@unreleased/pr-198.v2.yml @@ -0,0 +1,5 @@ +type: feature +feature: + description: Add `adapters/svc1zap` for code that accepts `*zap.Logger` + links: + - https://github.com/palantir/witchcraft-go-logging/pull/198