diff --git a/v2/internallog/grpclog/grpclog.go b/v2/internallog/grpclog/grpclog.go new file mode 100644 index 00000000..bf1d864b --- /dev/null +++ b/v2/internallog/grpclog/grpclog.go @@ -0,0 +1,88 @@ +// Copyright 2024, Google Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// Package grpclog in intended for internal use by generated clients only. +package grpclog + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +// ProtoMessageRequest returns a lazily evaluated [slog.LogValuer] for +// the provided message. The context is used to extract outgoing headers. +func ProtoMessageRequest(ctx context.Context, msg proto.Message) slog.LogValuer { + return &protoMessage{ctx: ctx, msg: msg} +} + +// ProtoMessageResponse returns a lazily evaluated [slog.LogValuer] for +// the provided message. +func ProtoMessageResponse(msg proto.Message) slog.LogValuer { + return &protoMessage{msg: msg} +} + +type protoMessage struct { + ctx context.Context + msg proto.Message +} + +func (m *protoMessage) LogValue() slog.Value { + if m == nil || m.msg == nil { + return slog.Value{} + } + + var groupValueAttrs []slog.Attr + + if m.ctx != nil { + var headerAttr []slog.Attr + if m, ok := metadata.FromOutgoingContext(m.ctx); ok { + for k, v := range m { + headerAttr = append(headerAttr, slog.String(k, strings.Join(v, ","))) + } + } + if len(headerAttr) > 0 { + groupValueAttrs = append(groupValueAttrs, slog.Any("headers", headerAttr)) + } + } + mo := protojson.MarshalOptions{AllowPartial: true, UseEnumNumbers: true} + if b, err := mo.Marshal(m.msg); err == nil { + var m map[string]any + if err := json.Unmarshal(b, &m); err == nil { + groupValueAttrs = append(groupValueAttrs, slog.Any("payload", m)) + } + } + + return slog.GroupValue(groupValueAttrs...) +} diff --git a/v2/internallog/grpclog/grpclog_test.go b/v2/internallog/grpclog/grpclog_test.go new file mode 100644 index 00000000..212a882c --- /dev/null +++ b/v2/internallog/grpclog/grpclog_test.go @@ -0,0 +1,90 @@ +// Copyright 2024, Google Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// - Redistributions of source code must retain the above copyright +// +// notice, this list of conditions and the following disclaimer. +// - Redistributions in binary form must reproduce the above +// +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// - Neither the name of Google Inc. nor the names of its +// +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package grpclog + +import ( + "context" + "flag" + "log/slog" + "os" + "testing" + + "github.com/googleapis/gax-go/v2/internallog/internal" + "github.com/googleapis/gax-go/v2/internallog/internal/bookpb" + "github.com/googleapis/gax-go/v2/internallog/internal/logtest" + "google.golang.org/grpc/metadata" +) + +// To update conformance tests in this package run `go test -update_golden` +func TestMain(m *testing.M) { + flag.Parse() + os.Exit(m.Run()) +} + +func TestLog_protoMessageRequest(t *testing.T) { + golden := "request.log" + t.Setenv(internal.LoggingLevelEnvVar, "debug") + logger, f := setupLogger(t, golden) + ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("foo", "bar")) + book := &bookpb.Book{ + Title: "The book", + Author: "The author", + } + logger.DebugContext(ctx, "msg", "request", ProtoMessageRequest(ctx, book)) + f.Close() + logtest.DiffTest(t, f.Name(), golden) +} + +func TestLog_protoMessageResponse(t *testing.T) { + golden := "response.log" + t.Setenv(internal.LoggingLevelEnvVar, "debug") + logger, f := setupLogger(t, golden) + ctx := context.Background() + book := &bookpb.Book{ + Title: "The book", + Author: "The author", + } + logger.DebugContext(ctx, "msg", "response", ProtoMessageResponse(book)) + f.Close() + logtest.DiffTest(t, f.Name(), golden) +} + +func setupLogger(t *testing.T, golden string) (*slog.Logger, *os.File) { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), golden) + if err != nil { + t.Fatal(err) + } + logger := internal.NewLoggerWithWriter(f) + return logger, f +} diff --git a/v2/internallog/grpclog/testdata/request.log b/v2/internallog/grpclog/testdata/request.log new file mode 100755 index 00000000..b28921e2 --- /dev/null +++ b/v2/internallog/grpclog/testdata/request.log @@ -0,0 +1 @@ +{"message":"msg","request":{"headers":{"foo":"bar"},"payload":{"author":"The author","title":"The book"}},"severity":"DEBUG"} diff --git a/v2/internallog/grpclog/testdata/response.log b/v2/internallog/grpclog/testdata/response.log new file mode 100755 index 00000000..1067f751 --- /dev/null +++ b/v2/internallog/grpclog/testdata/response.log @@ -0,0 +1 @@ +{"message":"msg","response":{"payload":{"author":"The author","title":"The book"}},"severity":"DEBUG"} diff --git a/v2/internallog/internal/bookpb/book.pb.go b/v2/internallog/internal/bookpb/book.pb.go new file mode 100644 index 00000000..098205c4 --- /dev/null +++ b/v2/internallog/internal/bookpb/book.pb.go @@ -0,0 +1,186 @@ +// Copyright 2024, Google Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.33.0 +// protoc v5.28.2 +// source: book.proto + +package bookpb + +import ( + reflect "reflect" + sync "sync" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// A single book in the library. +type Book struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The title of the book. + Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` + // The name of the book author. + Author string `protobuf:"bytes,2,opt,name=author,proto3" json:"author,omitempty"` +} + +func (x *Book) Reset() { + *x = Book{} + if protoimpl.UnsafeEnabled { + mi := &file_book_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Book) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Book) ProtoMessage() {} + +func (x *Book) ProtoReflect() protoreflect.Message { + mi := &file_book_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Book.ProtoReflect.Descriptor instead. +func (*Book) Descriptor() ([]byte, []int) { + return file_book_proto_rawDescGZIP(), []int{0} +} + +func (x *Book) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *Book) GetAuthor() string { + if x != nil { + return x.Author + } + return "" +} + +var File_book_proto protoreflect.FileDescriptor + +var file_book_proto_rawDesc = []byte{ + 0x0a, 0x0a, 0x62, 0x6f, 0x6f, 0x6b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x62, 0x6f, + 0x6f, 0x6b, 0x22, 0x34, 0x0a, 0x04, 0x42, 0x6f, 0x6f, 0x6b, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, + 0x74, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, + 0x12, 0x16, 0x0a, 0x06, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x42, 0x3d, 0x5a, 0x3b, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x61, 0x70, 0x69, + 0x73, 0x2f, 0x67, 0x61, 0x78, 0x2d, 0x67, 0x6f, 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6c, 0x6f, 0x67, + 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x6f, 0x6f, 0x6b, 0x70, 0x62, + 0x3b, 0x62, 0x6f, 0x6f, 0x6b, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_book_proto_rawDescOnce sync.Once + file_book_proto_rawDescData = file_book_proto_rawDesc +) + +func file_book_proto_rawDescGZIP() []byte { + file_book_proto_rawDescOnce.Do(func() { + file_book_proto_rawDescData = protoimpl.X.CompressGZIP(file_book_proto_rawDescData) + }) + return file_book_proto_rawDescData +} + +var file_book_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_book_proto_goTypes = []interface{}{ + (*Book)(nil), // 0: book.Book +} +var file_book_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_book_proto_init() } +func file_book_proto_init() { + if File_book_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_book_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Book); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_book_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_book_proto_goTypes, + DependencyIndexes: file_book_proto_depIdxs, + MessageInfos: file_book_proto_msgTypes, + }.Build() + File_book_proto = out.File + file_book_proto_rawDesc = nil + file_book_proto_goTypes = nil + file_book_proto_depIdxs = nil +} diff --git a/v2/internallog/internal/bookpb/book.proto b/v2/internallog/internal/bookpb/book.proto new file mode 100644 index 00000000..672e4edb --- /dev/null +++ b/v2/internallog/internal/bookpb/book.proto @@ -0,0 +1,43 @@ +// Copyright 2024, Google Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package book; + +option go_package = "github.com/googleapis/gax-go/v2/internallog/internal/bookpb;bookpb"; + +// A single book in the library. +message Book { + // The title of the book. + string title = 1; + + // The name of the book author. + string author = 2; +} diff --git a/v2/internallog/internal/internal.go b/v2/internallog/internal/internal.go new file mode 100644 index 00000000..19f4be35 --- /dev/null +++ b/v2/internallog/internal/internal.go @@ -0,0 +1,134 @@ +// Copyright 2024, Google Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// Package internal provides some common logic and types to other logging +// sub-packages. +package internal + +import ( + "context" + "io" + "log/slog" + "os" + "strings" + "time" +) + +const ( + // LoggingLevelEnvVar is the environment variable used to enable logging + // at a particular level. + LoggingLevelEnvVar = "GOOGLE_SDK_GO_LOGGING_LEVEL" + + googLvlKey = "severity" + googMsgKey = "message" + googSourceKey = "sourceLocation" + googTimeKey = "timestamp" +) + +// NewLoggerWithWriter is exposed for testing. +func NewLoggerWithWriter(w io.Writer) *slog.Logger { + lvl, loggingEnabled := checkLoggingLevel() + if !loggingEnabled { + return slog.New(noOpHandler{}) + } + return slog.New(newGCPSlogHandler(lvl, w)) +} + +// checkLoggingLevel returned the configured logging level and whether or not +// logging is enabled. +func checkLoggingLevel() (slog.Leveler, bool) { + sLevel := strings.ToLower(os.Getenv(LoggingLevelEnvVar)) + var level slog.Level + switch sLevel { + case "debug": + level = slog.LevelDebug + case "info": + level = slog.LevelInfo + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + return nil, false + } + return level, true +} + +// newGCPSlogHandler returns a Handler that is configured to output in a JSON +// format with well-known keys. For more information on this format see +// https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields. +func newGCPSlogHandler(lvl slog.Leveler, w io.Writer) slog.Handler { + return slog.NewJSONHandler(w, &slog.HandlerOptions{ + Level: lvl, + ReplaceAttr: replaceAttr, + }) +} + +// replaceAttr remaps default Go logging keys to match what is expected in +// cloud logging. +func replaceAttr(groups []string, a slog.Attr) slog.Attr { + if groups == nil { + if a.Key == slog.LevelKey { + a.Key = googLvlKey + return a + } else if a.Key == slog.MessageKey { + a.Key = googMsgKey + return a + } else if a.Key == slog.SourceKey { + a.Key = googSourceKey + return a + } else if a.Key == slog.TimeKey { + a.Key = googTimeKey + if a.Value.Kind() == slog.KindTime { + a.Value = slog.StringValue(a.Value.Time().Format(time.RFC3339)) + } + return a + } + } + return a +} + +// The handler returned if logging is not enabled. +type noOpHandler struct{} + +func (h noOpHandler) Enabled(_ context.Context, _ slog.Level) bool { + return false +} + +func (h noOpHandler) Handle(_ context.Context, _ slog.Record) error { + return nil +} + +func (h noOpHandler) WithAttrs(_ []slog.Attr) slog.Handler { + return h +} + +func (h noOpHandler) WithGroup(_ string) slog.Handler { + return h +} diff --git a/v2/internallog/internal/logtest/logtest.go b/v2/internallog/internal/logtest/logtest.go new file mode 100644 index 00000000..976534fa --- /dev/null +++ b/v2/internallog/internal/logtest/logtest.go @@ -0,0 +1,104 @@ +// Copyright 2024, Google Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// Package logtest is a helper for validating logging tests. +// +// To update conformance tests in this package run `go test -update_golden` +package logtest + +import ( + "bytes" + "encoding/json" + "flag" + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" +) + +var updateGolden = flag.Bool("update-golden", false, "update golden files") + +// DiffTest is a test helper, testing got against contents of a goldenFile. +func DiffTest(t *testing.T, tempFile, goldenFile string) { + rawGot, err := os.ReadFile(tempFile) + if err != nil { + t.Fatal(err) + } + t.Helper() + if *updateGolden { + got := removeLogVariance(t, rawGot) + if err := os.WriteFile(filepath.Join("testdata", goldenFile), got, os.ModePerm); err != nil { + t.Fatal(err) + } + return + } + + want, err := os.ReadFile(filepath.Join("testdata", goldenFile)) + if err != nil { + t.Fatal(err) + } + got := removeLogVariance(t, rawGot) + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch(-want, +got): %s", diff) + } +} + +// removeLogVariance removes parts of log lines that may differ between runs +// and/or machines. +func removeLogVariance(t *testing.T, in []byte) []byte { + if len(in) == 0 { + return in + } + bs := bytes.Split(in, []byte("\n")) + for i, b := range bs { + if len(b) == 0 { + continue + } + m := map[string]any{} + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + delete(m, "timestamp") + if sl, ok := m["sourceLocation"].(map[string]any); ok { + delete(sl, "file") + // So that if test cases move around in this file they don't cause + // failures + delete(sl, "line") + } + b2, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + t.Logf("%s", b2) + bs[i] = b2 + } + return bytes.Join(bs, []byte("\n")) +} diff --git a/v2/internallog/internallog.go b/v2/internallog/internallog.go new file mode 100644 index 00000000..91b648a6 --- /dev/null +++ b/v2/internallog/internallog.go @@ -0,0 +1,154 @@ +// Copyright 2024, Google Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// Package internallog in intended for internal use by generated clients only. +package internallog + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + + "github.com/googleapis/gax-go/v2/internallog/internal" +) + +// New returns a new [slog.Logger] default logger, or the provided logger if +// non-nil. The returned logger will be a no-op logger unless the environment +// variable GOOGLE_SDK_DEBUG_LOGGING is set. +func New(l *slog.Logger) *slog.Logger { + if l != nil { + return l + } + return internal.NewLoggerWithWriter(os.Stderr) +} + +// HTTPRequest returns a lazily evaluated [slog.LogValuer] for a +// [http.Request] and the associated body. +func HTTPRequest(req *http.Request, body []byte) slog.LogValuer { + return &request{ + req: req, + payload: body, + } +} + +type request struct { + req *http.Request + payload []byte +} + +func (r *request) LogValue() slog.Value { + if r == nil || r.req == nil { + return slog.Value{} + } + var groupValueAttrs []slog.Attr + groupValueAttrs = append(groupValueAttrs, slog.String("method", r.req.Method)) + groupValueAttrs = append(groupValueAttrs, slog.String("url", r.req.URL.String())) + + var headerAttr []slog.Attr + for k, val := range r.req.Header { + headerAttr = append(headerAttr, slog.String(k, strings.Join(val, ","))) + } + if len(headerAttr) > 0 { + groupValueAttrs = append(groupValueAttrs, slog.Any("headers", headerAttr)) + } + + if len(r.payload) > 0 { + if attr, ok := processPayload(r.payload); ok { + groupValueAttrs = append(groupValueAttrs, attr) + } + } + return slog.GroupValue(groupValueAttrs...) +} + +// HTTPResponse returns a lazily evaluated [slog.LogValuer] for a +// [http.Response] and the associated body. +func HTTPResponse(resp *http.Response, body []byte) slog.LogValuer { + return &response{ + resp: resp, + payload: body, + } +} + +type response struct { + resp *http.Response + payload []byte +} + +func (r *response) LogValue() slog.Value { + if r == nil { + return slog.Value{} + } + var groupValueAttrs []slog.Attr + groupValueAttrs = append(groupValueAttrs, slog.String("status", fmt.Sprint(r.resp.StatusCode))) + + var headerAttr []slog.Attr + for k, val := range r.resp.Header { + headerAttr = append(headerAttr, slog.String(k, strings.Join(val, ","))) + } + if len(headerAttr) > 0 { + groupValueAttrs = append(groupValueAttrs, slog.Any("headers", headerAttr)) + } + + if len(r.payload) > 0 { + if attr, ok := processPayload(r.payload); ok { + groupValueAttrs = append(groupValueAttrs, attr) + } + } + return slog.GroupValue(groupValueAttrs...) +} + +func processPayload(payload []byte) (slog.Attr, bool) { + peekChar := payload[0] + if peekChar == '{' { + // JSON object + var m map[string]any + if err := json.Unmarshal(payload, &m); err == nil { + return slog.Any("payload", m), true + } + } else if peekChar == '[' { + // JSON array + var m []any + if err := json.Unmarshal(payload, &m); err == nil { + return slog.Any("payload", m), true + } + } else { + // Everything else + buf := &bytes.Buffer{} + if err := json.Compact(buf, payload); err != nil { + // Write raw payload incase of error + buf.Write(payload) + } + return slog.String("payload", buf.String()), true + } + return slog.Attr{}, false +} diff --git a/v2/internallog/internallog_test.go b/v2/internallog/internallog_test.go new file mode 100644 index 00000000..03cc78c1 --- /dev/null +++ b/v2/internallog/internallog_test.go @@ -0,0 +1,187 @@ +// Copyright 2024, Google Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package internallog + +import ( + "bytes" + "context" + "flag" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "strings" + "testing" + + "github.com/googleapis/gax-go/v2/internallog/internal" + "github.com/googleapis/gax-go/v2/internallog/internal/logtest" +) + +// To update conformance tests in this package run `go test -update_golden` +func TestMain(m *testing.M) { + flag.Parse() + os.Exit(m.Run()) +} + +func TestLog_off(t *testing.T) { + golden := "off.log" + logger, f := setupLogger(t, golden) + logger.Error("one") + logger.Info("two") + logger.Warn("three") + logger.Debug("four") + f.Close() + logtest.DiffTest(t, f.Name(), golden) +} + +func TestLog_envarError(t *testing.T) { + golden := "envar-error.log" + t.Setenv(internal.LoggingLevelEnvVar, "eRrOr") + logger, f := setupLogger(t, golden) + logger.Error("one") + logger.Info("two") + logger.Warn("three") + logger.Debug("four") + f.Close() + logtest.DiffTest(t, f.Name(), golden) +} + +func TestLog_envarInfo(t *testing.T) { + golden := "envar-info.log" + t.Setenv(internal.LoggingLevelEnvVar, "info") + logger, f := setupLogger(t, golden) + logger.Error("one") + logger.Info("two") + logger.Warn("three") + logger.Debug("four") + f.Close() + logtest.DiffTest(t, f.Name(), golden) +} + +func TestLog_envarWarn(t *testing.T) { + golden := "envar-warn.log" + t.Setenv(internal.LoggingLevelEnvVar, "warn") + logger, f := setupLogger(t, golden) + logger.Error("one") + logger.Info("two") + logger.Warn("three") + logger.Debug("four") + f.Close() + logtest.DiffTest(t, f.Name(), golden) +} + +func TestLog_envarDebug(t *testing.T) { + golden := "envar-debug.log" + t.Setenv(internal.LoggingLevelEnvVar, "debug") + logger, f := setupLogger(t, golden) + logger.Error("one") + logger.Info("two") + logger.Warn("three") + logger.Debug("four") + f.Close() + logtest.DiffTest(t, f.Name(), golden) +} + +func TestLog_HTTPRequest(t *testing.T) { + golden := "httpRequest.log" + t.Setenv(internal.LoggingLevelEnvVar, "debug") + logger, f := setupLogger(t, golden) + ctx := context.Background() + body := []byte(`{"secret":"shh, it's a secret"}`) + request, err := http.NewRequest(http.MethodPost, "https://example.com", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Add("foo", "bar") + logger.DebugContext(ctx, "msg", "request", HTTPRequest(request, body)) + f.Close() + logtest.DiffTest(t, f.Name(), golden) +} + +func TestLog_HTTPResponse(t *testing.T) { + golden := "httpResponse.log" + t.Setenv(internal.LoggingLevelEnvVar, "debug") + logger, f := setupLogger(t, golden) + ctx := context.Background() + body := []byte(`{"secret":"shh, it's a secret"}`) + response := &http.Response{ + StatusCode: 200, + Header: http.Header{"Foo": []string{"bar"}}, + Body: io.NopCloser(bytes.NewReader(body)), + } + logger.DebugContext(ctx, "msg", "response", HTTPResponse(response, body)) + f.Close() + logtest.DiffTest(t, f.Name(), golden) +} + +func TestLog_HTTPRequest_formData(t *testing.T) { + golden := "httpRequest-form.log" + t.Setenv(internal.LoggingLevelEnvVar, "debug") + logger, f := setupLogger(t, golden) + ctx := context.Background() + form := url.Values{} + form.Add("foo", "bar") + form.Add("baz", "qux") + request, err := http.NewRequest(http.MethodPost, "https://example.com", strings.NewReader(form.Encode())) + if err != nil { + t.Fatal(err) + } + request.Header.Add("foo", "bar") + logger.DebugContext(ctx, "msg", "request", HTTPRequest(request, []byte(form.Encode()))) + f.Close() + logtest.DiffTest(t, f.Name(), golden) +} + +func TestLog_HTTPRequest_jsonArray(t *testing.T) { + golden := "httpRequest-array.log" + t.Setenv(internal.LoggingLevelEnvVar, "debug") + logger, f := setupLogger(t, golden) + ctx := context.Background() + body := []byte(`[{"secret":"shh, it's a secret"},{"secret":"and, another"}]`) + request, err := http.NewRequest(http.MethodPost, "https://example.com", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Add("foo", "bar") + logger.DebugContext(ctx, "msg", "request", HTTPRequest(request, body)) + f.Close() + logtest.DiffTest(t, f.Name(), golden) +} + +func setupLogger(t *testing.T, golden string) (*slog.Logger, *os.File) { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), golden) + if err != nil { + t.Fatal(err) + } + logger := internal.NewLoggerWithWriter(f) + return logger, f +} diff --git a/v2/internallog/testdata/envar-debug.log b/v2/internallog/testdata/envar-debug.log new file mode 100755 index 00000000..ed69d01d --- /dev/null +++ b/v2/internallog/testdata/envar-debug.log @@ -0,0 +1,4 @@ +{"message":"one","severity":"ERROR"} +{"message":"two","severity":"INFO"} +{"message":"three","severity":"WARN"} +{"message":"four","severity":"DEBUG"} diff --git a/v2/internallog/testdata/envar-error.log b/v2/internallog/testdata/envar-error.log new file mode 100755 index 00000000..af29f227 --- /dev/null +++ b/v2/internallog/testdata/envar-error.log @@ -0,0 +1 @@ +{"message":"one","severity":"ERROR"} diff --git a/v2/internallog/testdata/envar-info.log b/v2/internallog/testdata/envar-info.log new file mode 100755 index 00000000..24cb09a2 --- /dev/null +++ b/v2/internallog/testdata/envar-info.log @@ -0,0 +1,3 @@ +{"message":"one","severity":"ERROR"} +{"message":"two","severity":"INFO"} +{"message":"three","severity":"WARN"} diff --git a/v2/internallog/testdata/envar-warn.log b/v2/internallog/testdata/envar-warn.log new file mode 100755 index 00000000..d658b0ef --- /dev/null +++ b/v2/internallog/testdata/envar-warn.log @@ -0,0 +1,2 @@ +{"message":"one","severity":"ERROR"} +{"message":"three","severity":"WARN"} diff --git a/v2/internallog/testdata/httpRequest-array.log b/v2/internallog/testdata/httpRequest-array.log new file mode 100755 index 00000000..4814c04c --- /dev/null +++ b/v2/internallog/testdata/httpRequest-array.log @@ -0,0 +1 @@ +{"message":"msg","request":{"headers":{"Foo":"bar"},"method":"POST","payload":[{"secret":"shh, it's a secret"},{"secret":"and, another"}],"url":"https://example.com"},"severity":"DEBUG"} diff --git a/v2/internallog/testdata/httpRequest-form.log b/v2/internallog/testdata/httpRequest-form.log new file mode 100755 index 00000000..46d6ad14 --- /dev/null +++ b/v2/internallog/testdata/httpRequest-form.log @@ -0,0 +1 @@ +{"message":"msg","request":{"headers":{"Foo":"bar"},"method":"POST","payload":"baz=qux\u0026foo=bar","url":"https://example.com"},"severity":"DEBUG"} diff --git a/v2/internallog/testdata/httpRequest.log b/v2/internallog/testdata/httpRequest.log new file mode 100755 index 00000000..54739c0a --- /dev/null +++ b/v2/internallog/testdata/httpRequest.log @@ -0,0 +1 @@ +{"message":"msg","request":{"headers":{"Foo":"bar"},"method":"POST","payload":{"secret":"shh, it's a secret"},"url":"https://example.com"},"severity":"DEBUG"} diff --git a/v2/internallog/testdata/httpResponse.log b/v2/internallog/testdata/httpResponse.log new file mode 100755 index 00000000..fe39de77 --- /dev/null +++ b/v2/internallog/testdata/httpResponse.log @@ -0,0 +1 @@ +{"message":"msg","response":{"headers":{"Foo":"bar"},"payload":{"secret":"shh, it's a secret"},"status":"200"},"severity":"DEBUG"} diff --git a/v2/internallog/testdata/off.log b/v2/internallog/testdata/off.log new file mode 100755 index 00000000..e69de29b