From 384d1900c27c25e07c74a02ef612ad3df8ca3e57 Mon Sep 17 00:00:00 2001 From: "Alan D. Cabrera" Date: Wed, 3 Apr 2024 13:03:34 -0700 Subject: [PATCH] Implementation --- attr.go | 166 +++++++++++++++++++++++++++++++++ go.mod | 6 +- go.sum | 12 ++- handler.go | 195 ++++++++++++++++++++++++++++++++++++++ handler_test.go | 121 ++++++++++++++++++++++++ internal/local/marker.go | 19 ++++ logger.go | 39 ++++++++ option.go | 197 +++++++++++++++++++++++++++++++++++++++ option_test.go | 81 ++++++++++++++++ otel/trace.go | 54 +++++++++++ 10 files changed, 886 insertions(+), 4 deletions(-) create mode 100644 attr.go create mode 100644 handler.go create mode 100644 handler_test.go create mode 100644 internal/local/marker.go create mode 100644 logger.go create mode 100644 option.go create mode 100644 option_test.go create mode 100644 otel/trace.go diff --git a/attr.go b/attr.go new file mode 100644 index 0000000..8f8b551 --- /dev/null +++ b/attr.go @@ -0,0 +1,166 @@ +// Copyright 2024 The original author or 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 gslog + +import ( + "bytes" + "encoding/json" + "log/slog" + "sync" + "time" + + structpb "github.com/golang/protobuf/ptypes/struct" + spb "google.golang.org/protobuf/types/known/structpb" +) + +var ( + noopAttrMapper AttrMapper = func(_ []string, a slog.Attr) slog.Attr { + return a + } + + timePool = sync.Pool{ + New: func() any { + b := make([]byte, 0, len(time.RFC3339Nano)) + return &b + }, + } +) + +// AttrMapper is called to rewrite each non-group attribute before it is logged. +// The attribute's value has been resolved (see [Value.Resolve]). +// If replaceAttr returns a zero Attr, the attribute is discarded. +// +// The built-in attributes with keys "time", "level", "source", and "msg" +// are passed to this function, except that time is omitted +// if zero, and source is omitted if addSource is false. +// +// The first argument is a list of currently open groups that contain the +// Attr. It must not be retained or modified. replaceAttr is never called +// for Group attributes, only their contents. For example, the attribute +// list +// +// Int("a", 1), Group("g", Int("b", 2)), Int("c", 3) +// +// results in consecutive calls to replaceAttr with the following arguments: +// +// nil, Int("a", 1) +// []string{"g"}, Int("b", 2) +// nil, Int("c", 3) +// +// AttrMapper can be used to change the default keys of the built-in +// attributes, convert types (for example, to replace a `time.Time` with the +// integer seconds since the Unix epoch), sanitize personal information, or +// remove attributes from the output. +type AttrMapper func(groups []string, a slog.Attr) slog.Attr + +func decorateWith(p *structpb.Struct, a slog.Attr) { + a.Value.Resolve() + val, ok := valToStruct(a.Value) + if !ok { + return + } + p.Fields[a.Key] = val +} + +func valToStruct(v slog.Value) (val *structpb.Value, ok bool) { + switch v.Kind() { + case slog.KindString: + return &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: v.String()}}, true + case slog.KindInt64: + return &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: float64(v.Int64())}}, true + case slog.KindUint64: + return &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: float64(v.Uint64())}}, true + case slog.KindFloat64: + return &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: v.Float64()}}, true + case slog.KindBool: + return &structpb.Value{Kind: &structpb.Value_BoolValue{BoolValue: v.Bool()}}, true + case slog.KindDuration: + return &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: float64(v.Duration())}}, true + case slog.KindTime: + ptr := timePool.Get().(*[]byte) + buf := *ptr + buf = buf[0:0] + defer func() { + *ptr = buf + timePool.Put(ptr) + }() + buf = append(buf, byte('"')) + buf = v.Time().AppendFormat(buf, time.RFC3339Nano) + buf = append(buf, byte('"')) + return &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: string(buf)}}, true + case slog.KindGroup: + p2 := &structpb.Struct{Fields: make(map[string]*spb.Value)} + for _, b := range v.Group() { + decorateWith(p2, b) + } + return &structpb.Value{Kind: &structpb.Value_StructValue{StructValue: p2}}, true + case slog.KindAny: + a := v.Any() + + // if value is an error, but not a JSON marshaller, return error + _, jm := a.(json.Marshaler) + if err, ok := a.(error); ok && !jm { + return &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: err.Error()}}, true + } + + // value may be simply mappable to a structpb.Value. + if nv, err := spb.NewValue(a); err == nil { + return nv, true + } + + // try converting to a JSON object + return asJson(a) + default: + return nil, false + } +} + +func fromPath(p *structpb.Struct, path []string) *structpb.Struct { + for _, k := range path { + p = p.Fields[k].GetStructValue() + } + if p.Fields == nil { + p.Fields = make(map[string]*structpb.Value) + } + return p +} + +func asJson(a any) (*structpb.Value, bool) { + a, err := toJson(a) + if err != nil { + return nil, false + } + + if nv, err := spb.NewValue(a); err == nil { + return nv, true + } + + return nil, false +} + +func toJson(a any) (any, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + if err := enc.Encode(a); err != nil { + return nil, err + } + + var result any + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/go.mod b/go.mod index d71b864..6a8f18c 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,12 @@ go 1.22 require ( cloud.google.com/go/logging v1.9.0 + github.com/golang/protobuf v1.5.3 github.com/onsi/ginkgo/v2 v2.17.1 github.com/onsi/gomega v1.32.0 github.com/stretchr/testify v1.9.0 + go.opentelemetry.io/otel/trace v1.24.0 + google.golang.org/protobuf v1.33.0 ) require ( @@ -18,7 +21,6 @@ require ( github.com/go-logr/logr v1.4.1 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect github.com/google/s2a-go v0.1.7 // indirect @@ -26,6 +28,7 @@ require ( github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect golang.org/x/crypto v0.18.0 // indirect golang.org/x/net v0.20.0 // indirect golang.org/x/oauth2 v0.13.0 // indirect @@ -39,6 +42,5 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect google.golang.org/grpc v1.59.0 // indirect - google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b28a202..8cfb799 100644 --- a/go.sum +++ b/go.sum @@ -44,14 +44,16 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= @@ -82,6 +84,10 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= @@ -158,6 +164,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..f925f84 --- /dev/null +++ b/handler.go @@ -0,0 +1,195 @@ +// Copyright 2024 The original author or 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 gslog + +import ( + "context" + "fmt" + "log/slog" + "os" + "runtime" + + "cloud.google.com/go/logging" + logpb "cloud.google.com/go/logging/apiv2/loggingpb" + structpb "github.com/golang/protobuf/ptypes/struct" + "google.golang.org/protobuf/proto" +) + +const ( + fieldMessage = "message" +) + +// GcpHandler is a Google Cloud Logging backed slog handler. +type GcpHandler struct { + // *logging.Logger, except for testing + log logger + level slog.Leveler + + // addSource causes the handler to compute the source code position + // of the log statement and add a SourceKey attribute to the output. + addSource bool + entryAugmentors []AugmentEntryFn + replaceAttr AttrMapper + + payload *structpb.Struct + groups []string +} + +var _ slog.Handler = &GcpHandler{} + +// NewGcpHandler creates a Google Cloud Logging backed log.Logger. +func NewGcpHandler(logger *logging.Logger, opts ...Option) (*GcpHandler, error) { + if logger == nil { + panic("client is nil") + } + o, err := applyOptions(opts...) + if err != nil { + return nil, err + } + + return newGcpLoggerWithOptions(logger, o), nil +} + +func NewGcpLoggerWithOptions(logger logger, opts ...Option) *GcpHandler { + if logger == nil { + panic("client is nil") + } + o, err := applyOptions(opts...) + if err != nil { + panic(err) + } + + return newGcpLoggerWithOptions(logger, o) +} + +func newGcpLoggerWithOptions(logger logger, o *Options) *GcpHandler { + h := &GcpHandler{ + log: logger, + level: o.level, + + addSource: o.addSource, + entryAugmentors: o.EntryAugmentors, + replaceAttr: o.replaceAttr, + + payload: &structpb.Struct{Fields: make(map[string]*structpb.Value)}, + } + + return h +} + +func (h *GcpHandler) Enabled(_ context.Context, level slog.Level) bool { + return h.level.Level() <= level +} + +func (h *GcpHandler) Handle(ctx context.Context, record slog.Record) error { + payload2 := proto.Clone(h.payload).(*structpb.Struct) + current := fromPath(payload2, h.groups) + + record.Attrs(func(attr slog.Attr) bool { + decorateWith(current, attr) + return true + }) + + if payload2.Fields == nil { + payload2.Fields = make(map[string]*structpb.Value) + } + payload2.Fields[fieldMessage] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: record.Message}} + + var e logging.Entry + + e.Payload = payload2 + e.Timestamp = record.Time.UTC() + e.Severity = levelToSeverity(record.Level) + e.Labels = ExtractLabels(ctx) + + if h.addSource { + addSourceLocation(&e, &record) + } + + for _, b := range h.entryAugmentors { + b(ctx, &e) + } + + if e.Severity >= logging.Critical { + err := h.log.LogSync(ctx, e) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error logging: %s\n%s", record.Message, err) + } + } else { + h.log.Log(e) + } + + return nil +} + +func (h *GcpHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + var h2 = h.clone() + + current := fromPath(h2.payload, h2.groups) + + for _, a := range attrs { + decorateWith(current, a) + } + + return h2 +} + +func (h *GcpHandler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + var h2 = h.clone() + + h2.payload = proto.Clone(h.payload).(*structpb.Struct) + + current := fromPath(h2.payload, h2.groups) + + current.Fields[name] = &structpb.Value{ + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: make(map[string]*structpb.Value), + }, + }, + } + + h2.groups = append(h.groups, name) + + return h2 +} + +func (h *GcpHandler) clone() *GcpHandler { + return &GcpHandler{ + log: h.log, + level: h.level, + + addSource: h.addSource, + entryAugmentors: h.entryAugmentors, + replaceAttr: h.replaceAttr, + + payload: proto.Clone(h.payload).(*structpb.Struct), + groups: h.groups, + } +} + +func addSourceLocation(e *logging.Entry, r *slog.Record) { + fs := runtime.CallersFrames([]uintptr{r.PC}) + f, _ := fs.Next() + + e.SourceLocation = &logpb.LogEntrySourceLocation{ + File: f.File, + Line: int64(f.Line), + Function: f.Function, + } +} diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..7cb2ca2 --- /dev/null +++ b/handler_test.go @@ -0,0 +1,121 @@ +// Copyright 2024 The original author or 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 gslog_test + +import ( + "context" + "log/slog" + "strconv" + "testing" + "time" + + "cloud.google.com/go/logging" + structpb "github.com/golang/protobuf/ptypes/struct" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" + + "m4o.io/gslog" +) + +type Manager struct { +} + +type Password string + +func (p Password) MarshalJSON() ([]byte, error) { + return []byte(strconv.Quote("")), nil +} + +type User struct { + ID string `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Password Password `json:"password"` + Age uint8 `json:"age"` + Height float32 `json:"height"` + Engineer bool `json:"engineer"` + Manager *Manager `json:"manager"` +} + +func TestProtoClone(t *testing.T) { + payload := &structpb.Struct{ + Fields: make(map[string]*structpb.Value), + } + payload.Fields["msg"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "How now brown cow"}} + + cloned := proto.Clone(payload).(*structpb.Struct) + assert.Equal(t, payload, cloned) +} + +var _ = Describe("GCP slog handler", func() { + var old *slog.Logger + var h slog.Handler + + BeforeEach(func() { + old = slog.Default() + + client, err := logging.NewClient(context.Background(), "projects/keebo-integration") + Ω(err).ShouldNot(HaveOccurred()) + + l := client.Logger("cabrera-test") + l.Log(logging.Entry{ + Payload: `{"message": "How now brown cow", "a": "b"}`, + Timestamp: time.Now().UTC(), + Severity: logging.Warning, + Labels: map[string]string{"foo": "bar"}, + }) + + h, err = gslog.NewGcpHandler( + l, + gslog.WithLogLevel(slog.LevelDebug), + gslog.WithSourceAdded(), + ) + Ω(err).ShouldNot(HaveOccurred()) + + slog.SetDefault(slog.New(h)) + + DeferCleanup(func() { + slog.SetDefault(old) + + err = client.Close() + Ω(err).ShouldNot(HaveOccurred()) + }) + }) + + It("should log", func() { + + l := slog.New(h) + l = l.WithGroup("group").With("a", "b") + + l.Info("How now", "brown", 32.1) + l.Info("How now", "purple", true) + + u := &User{ + ID: "user-12234", + FirstName: "Jan", + LastName: "Doe", + Email: "jan@example.com", + Password: "pass-12334", + Age: 32, + Height: 5.91, + Engineer: true, + } + + l.Info("How now", "user", u) + }) +}) diff --git a/internal/local/marker.go b/internal/local/marker.go new file mode 100644 index 0000000..a116e8d --- /dev/null +++ b/internal/local/marker.go @@ -0,0 +1,19 @@ +// Copyright 2024 The original author or 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 local + +type InternalMarker interface { + InternalOnly() +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..38ee6b6 --- /dev/null +++ b/logger.go @@ -0,0 +1,39 @@ +// Copyright 2024 The original author or 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 gslog + +import ( + "context" + + "cloud.google.com/go/logging" +) + +// logger is wraps the set of methods that are used when interacting with a +// logging.Logger. This interface facilitates stubbing out calls to the logger +// for the purposes of testing and benchmarking. +type logger interface { + Log(e logging.Entry) + LogSync(ctx context.Context, e logging.Entry) error +} + +type discard struct{} + +func (d discard) Log(_ logging.Entry) {} + +func (d discard) LogSync(_ context.Context, _ logging.Entry) error { + return nil +} + +var Discard logger = discard{} diff --git a/option.go b/option.go new file mode 100644 index 0000000..cd016f7 --- /dev/null +++ b/option.go @@ -0,0 +1,197 @@ +// Copyright 2024 The original author or 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 gslog + +import ( + "context" + "fmt" + "log/slog" + "math" + "os" + "strconv" + + "cloud.google.com/go/logging" + + "m4o.io/gslog/internal/local" +) + +const ( + envVarLogLevelKey = "GSLOG_LOG_LEVEL" + + LevelUnknown slog.Level = math.MaxInt +) + +var ( + errNoLogLevelSet = fmt.Errorf("no level set for logging") +) + +type AugmentEntryFn func(ctx context.Context, e *logging.Entry) + +// OptionFunc is function type that is passed to the Logger initialization function. +type OptionFunc func(*Options) error + +func (o OptionFunc) Process(options *Options) error { + return o(options) +} + +func (o OptionFunc) InternalOnly() { +} + +type Option interface { + local.InternalMarker + Process(options *Options) error +} + +// Options holds information needed to construct an instance of GcpHandler. +type Options struct { + explicitLogLevel slog.Level + envVarLogLevel slog.Level + defaultLogLevel slog.Level + + EntryAugmentors []AugmentEntryFn + + // addSource causes the handler to compute the source code position + // of the log statement and add a SourceKey attribute to the output. + addSource bool + + // level reports the minimum record level that will be logged. + // The handler discards records with lower levels. + // If level is nil, the handler assumes LevelInfo. + // The handler calls level.level for each record processed; + // to adjust the minimum level dynamically, use a LevelVar. + level slog.Leveler + + // replaceAttr is called to rewrite each non-group attribute before it is logged. + // The attribute's value has been resolved (see [Value.Resolve]). + // If replaceAttr returns a zero Attr, the attribute is discarded. + // + // The built-in attributes with keys "time", "level", "source", and "msg" + // are passed to this function, except that time is omitted + // if zero, and source is omitted if addSource is false. + // + // The first argument is a list of currently open groups that contain the + // Attr. It must not be retained or modified. replaceAttr is never called + // for Group attributes, only their contents. For example, the attribute + // list + // + // Int("a", 1), Group("g", Int("b", 2)), Int("c", 3) + // + // results in consecutive calls to replaceAttr with the following arguments: + // + // nil, Int("a", 1) + // []string{"g"}, Int("b", 2) + // nil, Int("c", 3) + // + // replaceAttr can be used to change the default keys of the built-in + // attributes, convert types (for example, to replace a `time.Time` with the + // integer seconds since the Unix epoch), sanitize personal information, or + // remove attributes from the output. + replaceAttr AttrMapper +} + +// WithLogLevel returns an option that specifies the log level for logging. +// Explicitly setting the log level here takes precedence over the other +// options. +func WithLogLevel(logLevel slog.Level) Option { + return OptionFunc(func(o *Options) error { + o.explicitLogLevel = logLevel + return nil + }) +} + +// WithLogLevelFromEnvVar returns an option that specifies the log level +// for logging comes from tne environmental variable KEEBO_LOG_LEVEL. +func WithLogLevelFromEnvVar() Option { + return OptionFunc(func(o *Options) error { + s, ok := os.LookupEnv(envVarLogLevelKey) + if !ok { + return nil + } + i, err := strconv.Atoi(s) + if err == nil { + o.envVarLogLevel = slog.Level(i) + return nil + } + + switch s { + case "DEBUG": + o.envVarLogLevel = slog.LevelDebug + case "INFO": + o.envVarLogLevel = slog.LevelInfo + case "WARN": + o.envVarLogLevel = slog.LevelWarn + case "ERROR": + o.envVarLogLevel = slog.LevelError + default: + } + + return nil + }) +} + +// WithDefaultLogLevel returns an option that specifies the default log +// level for logging. +func WithDefaultLogLevel(defaultLogLevel slog.Level) Option { + return OptionFunc(func(o *Options) error { + o.defaultLogLevel = defaultLogLevel + return nil + }) +} + +// WithSourceAdded returns an option that causes the handler to compute the +// source code position of the log statement and add a slog.SourceKey attribute +// to the output. +func WithSourceAdded() Option { + return OptionFunc(func(o *Options) error { + o.addSource = true + return nil + }) +} + +// WithReplaceAttr returns an option that specifies an attribute mapper used to +// rewrite each non-group attribute before it is logged. +func WithReplaceAttr(replaceAttr AttrMapper) Option { + return OptionFunc(func(o *Options) error { + o.replaceAttr = replaceAttr + return nil + }) +} + +func applyOptions(opts ...Option) (*Options, error) { + o := &Options{ + envVarLogLevel: LevelUnknown, + explicitLogLevel: LevelUnknown, + defaultLogLevel: LevelUnknown, + replaceAttr: noopAttrMapper, + } + for _, opt := range opts { + if err := opt.Process(o); err != nil { + return nil, err + } + } + + o.level = o.defaultLogLevel + if o.envVarLogLevel != LevelUnknown { + o.level = o.envVarLogLevel + } + if o.explicitLogLevel != LevelUnknown { + o.level = o.explicitLogLevel + } + if o.level == LevelUnknown { + return nil, errNoLogLevelSet + } + + return o, nil +} diff --git a/option_test.go b/option_test.go new file mode 100644 index 0000000..4e083f0 --- /dev/null +++ b/option_test.go @@ -0,0 +1,81 @@ +// Copyright 2024 The original author or 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 gslog + +import ( + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + naString = "" +) + +func TestLogLevel(t *testing.T) { + tests := map[string]struct { + explicitLogLevel slog.Level + defaultLogLevel slog.Level + envVar bool + envVarKey string + envVarValue string + err error + expected slog.Level + }{ + "do nothing": {LevelUnknown, LevelUnknown, false, naString, naString, errNoLogLevelSet, LevelUnknown}, + "default": {LevelUnknown, slog.LevelInfo, false, naString, naString, nil, slog.LevelInfo}, + "default missing env var": {LevelUnknown, slog.LevelInfo, true, naString, naString, nil, slog.LevelInfo}, + "explicit": {slog.LevelInfo, LevelUnknown, false, naString, naString, nil, slog.LevelInfo}, + "explicit overrides env var": {slog.LevelInfo, LevelUnknown, true, envVarLogLevelKey, "INFO", nil, slog.LevelInfo}, + "explicit overrides default": {slog.LevelInfo, slog.LevelDebug, false, naString, naString, nil, slog.LevelInfo}, + "explicit overrides all": {slog.LevelInfo, slog.LevelDebug, true, envVarLogLevelKey, "ERROR", nil, slog.LevelInfo}, + "env var": {LevelUnknown, LevelUnknown, true, envVarLogLevelKey, "INFO", nil, slog.LevelInfo}, + "env var missing": {LevelUnknown, LevelUnknown, true, naString, naString, errNoLogLevelSet, LevelUnknown}, + "env var overrides default": {LevelUnknown, slog.LevelDebug, true, envVarLogLevelKey, "INFO", nil, slog.LevelInfo}, + "env var high custom level": {LevelUnknown, LevelUnknown, true, envVarLogLevelKey, "32", nil, slog.Level(32)}, + "env var low custom level": {LevelUnknown, LevelUnknown, true, envVarLogLevelKey, "-8", nil, slog.Level(-8)}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + opts := []Option{} + if tc.explicitLogLevel != LevelUnknown { + opts = append(opts, WithLogLevel(tc.explicitLogLevel)) + } + if tc.defaultLogLevel != LevelUnknown { + opts = append(opts, WithDefaultLogLevel(tc.defaultLogLevel)) + } + if tc.envVar { + if tc.envVarKey != "" { + assert.NoError(t, os.Setenv(tc.envVarKey, tc.envVarValue)) + defer func() { + assert.NoError(t, os.Unsetenv(envVarLogLevelKey)) + }() + } + opts = append(opts, WithLogLevelFromEnvVar()) + } + + o, err := applyOptions(opts...) + if tc.err != nil { + assert.Equal(t, tc.err, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, o.level) + } + }) + } +} diff --git a/otel/trace.go b/otel/trace.go new file mode 100644 index 0000000..131234a --- /dev/null +++ b/otel/trace.go @@ -0,0 +1,54 @@ +// Copyright 2024 The original author or 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 otel contains options for including OpenTelemetry tracing in logging +records. + +Placing the options in a separate package minimizes the dependencies pulled in +by those who do not need OpenTelemetry tracing. +*/ +package otel + +import ( + "context" + + "cloud.google.com/go/logging" + "go.opentelemetry.io/otel/trace" + + "m4o.io/gslog" +) + +// WithOtelTracing returns a gslog.Option that directs that the slog.Handler +// to include OpenTelemetry tracing. +func WithOtelTracing() gslog.Option { + return gslog.OptionFunc(func(options *gslog.Options) error { + options.EntryAugmentors = append(options.EntryAugmentors, addTrace) + return nil + }) +} + +func addTrace(ctx context.Context, e *logging.Entry) { + span := trace.SpanContextFromContext(ctx) + + if span.HasTraceID() { + e.Trace = span.TraceID().String() + } + if span.HasSpanID() { + e.SpanID = span.SpanID().String() + } + if span.IsSampled() { + e.TraceSampled = true + } +}