diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 854f633d24..801ed4c977 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,6 +31,7 @@ jobs: env: GOOGLE_CLOUD_UNIVERSE_DOMAIN: random.com GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS: true + GOOGLE_SDK_JAVA_LOGGING: true - run: bazelisk version - name: Install Maven modules run: | @@ -82,6 +83,7 @@ jobs: env: GOOGLE_CLOUD_UNIVERSE_DOMAIN: random.com GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS: true + GOOGLE_SDK_JAVA_LOGGING: true - run: bazelisk version - name: Install Maven modules run: | @@ -133,6 +135,7 @@ jobs: env: GOOGLE_CLOUD_UNIVERSE_DOMAIN: random.com GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS: true + GOOGLE_SDK_JAVA_LOGGING: true build-java8-gapic-generator-java: name: "build(8) for gapic-generator-java" @@ -255,6 +258,34 @@ jobs: -P enable-integration-tests \ --batch-mode \ --no-transfer-progress + # The `slf4j2_logback` profile brings logging dependency and compiles logging tests, require env var to be set + - name: Showcase integration tests - Logging SLF4J 2.x + working-directory: showcase + run: | + mvn clean verify -P '!showcase,enable-integration-tests,loggingTestBase,slf4j2_logback' \ + --batch-mode \ + --no-transfer-progress + # Set the Env Var for this step only + env: + GOOGLE_SDK_JAVA_LOGGING: true + # The `slf4j1_logback` profile brings logging dependency and compiles logging tests, require env var to be set + - name: Showcase integration tests - Logging SLF4J 1.x + working-directory: showcase + run: | + mvn clean verify -P '!showcase,enable-integration-tests,loggingTestBase,slf4j1_logback' \ + --batch-mode \ + --no-transfer-progress + # Set the Env Var for this step only + env: + GOOGLE_SDK_JAVA_LOGGING: true + # The `disabledLogging` profile tests logging disabled when logging dependency present, + # do not set env var for this step + - name: Showcase integration tests - Logging disabed + working-directory: showcase + run: | + mvn clean verify -P '!showcase,enable-integration-tests,loggingTestBase,disabledLogging' \ + --batch-mode \ + --no-transfer-progress showcase-clirr: if: ${{ github.base_ref != '' }} # Only execute on pull_request trigger event diff --git a/gapic-generator-java-pom-parent/pom.xml b/gapic-generator-java-pom-parent/pom.xml index a426cb9ee9..bb87b52553 100644 --- a/gapic-generator-java-pom-parent/pom.xml +++ b/gapic-generator-java-pom-parent/pom.xml @@ -38,6 +38,7 @@ 3.0.0 1.7.0 5.11.4 + 2.0.16 diff --git a/gax-java/dependencies.properties b/gax-java/dependencies.properties index 4d09063fbb..9dcb8c805d 100644 --- a/gax-java/dependencies.properties +++ b/gax-java/dependencies.properties @@ -76,6 +76,8 @@ maven.com_google_http_client_google_http_client_gson=com.google.http-client:goog maven.org_codehaus_mojo_animal_sniffer_annotations=org.codehaus.mojo:animal-sniffer-annotations:1.24 maven.javax_annotation_javax_annotation_api=javax.annotation:javax.annotation-api:1.3.2 maven.org_graalvm_sdk=org.graalvm.sdk:nativeimage:24.1.2 +maven.org_slf4j_slf4j_api=org.slf4j:slf4j-api:2.0.16 +maven.com_google_protobuf_protobuf_java_util=com.google.protobuf:protobuf-java-util:3.25.5 # Testing maven artifacts maven.junit_junit=junit:junit:4.13.2 diff --git a/gax-java/gax-grpc/pom.xml b/gax-java/gax-grpc/pom.xml index 01a62345ef..cb33a3dae3 100644 --- a/gax-java/gax-grpc/pom.xml +++ b/gax-java/gax-grpc/pom.xml @@ -92,6 +92,12 @@ auto-value-annotations provided + + + org.slf4j + slf4j-api + true + diff --git a/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcLoggingInterceptor.java b/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcLoggingInterceptor.java new file mode 100644 index 0000000000..9c305261cb --- /dev/null +++ b/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcLoggingInterceptor.java @@ -0,0 +1,136 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.grpc; + +import static com.google.api.gax.logging.LoggingUtils.executeWithTryCatch; +import static com.google.api.gax.logging.LoggingUtils.logRequest; +import static com.google.api.gax.logging.LoggingUtils.logResponse; +import static com.google.api.gax.logging.LoggingUtils.recordResponseHeaders; +import static com.google.api.gax.logging.LoggingUtils.recordResponsePayload; +import static com.google.api.gax.logging.LoggingUtils.recordServiceRpcAndRequestHeaders; + +import com.google.api.core.InternalApi; +import com.google.api.gax.logging.LogData; +import com.google.api.gax.logging.LoggerProvider; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ForwardingClientCall; +import io.grpc.ForwardingClientCallListener.SimpleForwardingClientCallListener; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import java.util.HashMap; +import java.util.Map; + +@InternalApi +public class GrpcLoggingInterceptor implements ClientInterceptor { + + private static final LoggerProvider LOGGER_PROVIDER = + LoggerProvider.forClazz(GrpcLoggingInterceptor.class); + + ClientCall.Listener currentListener; // expose for test setup + + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + + return new ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + LogData.Builder logDataBuilder = LogData.builder(); + + @Override + public void start(Listener responseListener, Metadata headers) { + recordServiceRpcAndRequestHeaders( + method.getServiceName(), + method.getFullMethodName(), + null, // endpoint is for http request only + metadataHeadersToMap(headers), + logDataBuilder, + LOGGER_PROVIDER); + SimpleForwardingClientCallListener responseLoggingListener = + new SimpleForwardingClientCallListener(responseListener) { + @Override + public void onHeaders(Metadata headers) { + recordResponseHeaders( + metadataHeadersToMap(headers), logDataBuilder, LOGGER_PROVIDER); + super.onHeaders(headers); + } + + @Override + public void onMessage(RespT message) { + recordResponsePayload(message, logDataBuilder, LOGGER_PROVIDER); + super.onMessage(message); + } + + @Override + public void onClose(Status status, Metadata trailers) { + logResponse(status.getCode().toString(), logDataBuilder, LOGGER_PROVIDER); + super.onClose(status, trailers); + } + }; + currentListener = responseLoggingListener; + super.start(responseLoggingListener, headers); + } + + @Override + public void sendMessage(ReqT message) { + logRequest(message, logDataBuilder, LOGGER_PROVIDER); + super.sendMessage(message); + } + }; + } + + // Helper methods for logging + private static Map metadataHeadersToMap(Metadata headers) { + + Map headersMap = new HashMap<>(); + executeWithTryCatch( + () -> { + for (String key : headers.keys()) { + // grpc header values can be either ASCII strings or binary + // https://grpc.io/docs/guides/metadata/#overview + // this condition identified binary headers and skip for logging + if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + continue; + } + Metadata.Key metadataKey = + Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + String headerValue = headers.get(metadataKey); + + headersMap.put(key, headerValue); + } + }); + + return headersMap; + } +} diff --git a/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java b/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java index b75ba746f9..2b00ade341 100644 --- a/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java +++ b/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java @@ -681,6 +681,7 @@ private ManagedChannel createSingleChannel() throws IOException { builder = builder .intercept(new GrpcChannelUUIDInterceptor()) + .intercept(new GrpcLoggingInterceptor()) .intercept(headerInterceptor) .intercept(metadataHandlerInterceptor) .userAgent(headerInterceptor.getUserAgentHeader()) diff --git a/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcLoggingInterceptorTest.java b/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcLoggingInterceptorTest.java new file mode 100644 index 0000000000..fad4cd468b --- /dev/null +++ b/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcLoggingInterceptorTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.grpc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.api.gax.grpc.testing.FakeMethodDescriptor; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptors; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GrpcLoggingInterceptorTest { + @Mock private Channel channel; + + @Mock private ClientCall call; + + private static final MethodDescriptor method = FakeMethodDescriptor.create(); + + @Test + void testInterceptor_basic() { + when(channel.newCall(Mockito.>any(), any(CallOptions.class))) + .thenReturn(call); + GrpcLoggingInterceptor interceptor = new GrpcLoggingInterceptor(); + Channel intercepted = ClientInterceptors.intercept(channel, interceptor); + @SuppressWarnings("unchecked") + ClientCall.Listener listener = mock(ClientCall.Listener.class); + ClientCall interceptedCall = intercepted.newCall(method, CallOptions.DEFAULT); + // Simulate starting the call + interceptedCall.start(listener, new Metadata()); + // Verify that the underlying call's start() method is invoked + verify(call).start(any(ClientCall.Listener.class), any(Metadata.class)); + + // Simulate sending a message + String requestMessage = "test request"; + interceptedCall.sendMessage(requestMessage); + // Verify that the underlying call's sendMessage() method is invoked + verify(call).sendMessage(requestMessage); + } + + @Test + void testInterceptor_responseListener() { + when(channel.newCall(Mockito.>any(), any(CallOptions.class))) + .thenReturn(call); + GrpcLoggingInterceptor interceptor = spy(new GrpcLoggingInterceptor()); + Channel intercepted = ClientInterceptors.intercept(channel, interceptor); + @SuppressWarnings("unchecked") + ClientCall.Listener listener = mock(ClientCall.Listener.class); + ClientCall interceptedCall = intercepted.newCall(method, CallOptions.DEFAULT); + interceptedCall.start(listener, new Metadata()); + + // Simulate respond interceptor calls + Metadata responseHeaders = new Metadata(); + responseHeaders.put( + Metadata.Key.of("test-header", Metadata.ASCII_STRING_MARSHALLER), "header-value"); + interceptor.currentListener.onHeaders(responseHeaders); + + interceptor.currentListener.onMessage(null); + + Status status = Status.OK; + interceptor.currentListener.onClose(status, new Metadata()); + } +} diff --git a/gax-java/gax-httpjson/pom.xml b/gax-java/gax-httpjson/pom.xml index 268f5e1bdc..30e1389b80 100644 --- a/gax-java/gax-httpjson/pom.xml +++ b/gax-java/gax-httpjson/pom.xml @@ -78,6 +78,12 @@ auto-value-annotations provided + + + org.slf4j + slf4j-api + true + diff --git a/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonLoggingInterceptor.java b/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonLoggingInterceptor.java new file mode 100644 index 0000000000..5a1b1b8c2b --- /dev/null +++ b/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonLoggingInterceptor.java @@ -0,0 +1,116 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.httpjson; + +import static com.google.api.gax.logging.LoggingUtils.logRequest; +import static com.google.api.gax.logging.LoggingUtils.logResponse; +import static com.google.api.gax.logging.LoggingUtils.recordResponseHeaders; +import static com.google.api.gax.logging.LoggingUtils.recordResponsePayload; +import static com.google.api.gax.logging.LoggingUtils.recordServiceRpcAndRequestHeaders; + +import com.google.api.core.InternalApi; +import com.google.api.gax.httpjson.ForwardingHttpJsonClientCall.SimpleForwardingHttpJsonClientCall; +import com.google.api.gax.httpjson.ForwardingHttpJsonClientCallListener.SimpleForwardingHttpJsonClientCallListener; +import com.google.api.gax.logging.LogData; +import com.google.api.gax.logging.LoggerProvider; +import java.util.HashMap; +import java.util.Map; + +@InternalApi +public class HttpJsonLoggingInterceptor implements HttpJsonClientInterceptor { + + private static final LoggerProvider LOGGER_PROVIDER = + LoggerProvider.forClazz(HttpJsonLoggingInterceptor.class); + + @Override + public HttpJsonClientCall interceptCall( + ApiMethodDescriptor method, + HttpJsonCallOptions callOptions, + HttpJsonChannel next) { + + String endpoint = ((ManagedHttpJsonChannel) next).getEndpoint(); + + return new SimpleForwardingHttpJsonClientCall(next.newCall(method, callOptions)) { + + LogData.Builder logDataBuilder = LogData.builder(); + + @Override + public void start( + HttpJsonClientCall.Listener responseListener, HttpJsonMetadata headers) { + recordServiceRpcAndRequestHeaders( + null, // service name is not available for http requests + method.getFullMethodName(), + endpoint, + httpJsonMetadataToMap(headers), + logDataBuilder, + LOGGER_PROVIDER); + + Listener forwardingResponseListener = + new SimpleForwardingHttpJsonClientCallListener(responseListener) { + + @Override + public void onHeaders(HttpJsonMetadata responseHeaders) { + recordResponseHeaders( + httpJsonMetadataToMap(responseHeaders), logDataBuilder, LOGGER_PROVIDER); + super.onHeaders(responseHeaders); + } + + @Override + public void onMessage(RespT message) { + recordResponsePayload(message, logDataBuilder, LOGGER_PROVIDER); + super.onMessage(message); + } + + @Override + public void onClose(int statusCode, HttpJsonMetadata trailers) { + logResponse(String.valueOf(statusCode), logDataBuilder, LOGGER_PROVIDER); + super.onClose(statusCode, trailers); + } + }; + super.start(forwardingResponseListener, headers); + } + + @Override + public void sendMessage(ReqT message) { + logRequest(message, logDataBuilder, LOGGER_PROVIDER); + super.sendMessage(message); + } + }; + } + + // Helper methods for logging, + + private static Map httpJsonMetadataToMap(HttpJsonMetadata headers) { + Map headersMap = new HashMap<>(); + headers.getHeaders().forEach((key, value) -> headersMap.put(key, value.toString())); + return headersMap; + } +} diff --git a/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java b/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java index f92bdf299c..1912bc5e29 100644 --- a/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java +++ b/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java @@ -196,6 +196,7 @@ private HttpJsonTransportChannel createChannel() throws IOException, GeneralSecu HttpJsonClientInterceptor headerInterceptor = new HttpJsonHeaderInterceptor(headerProvider.getHeaders()); + channel = new ManagedHttpJsonInterceptorChannel(channel, new HttpJsonLoggingInterceptor()); channel = new ManagedHttpJsonInterceptorChannel(channel, headerInterceptor); if (interceptorProvider != null && interceptorProvider.getInterceptors() != null) { for (HttpJsonClientInterceptor interceptor : interceptorProvider.getInterceptors()) { diff --git a/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java b/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java index 7a2e7a2f26..bd3bed8556 100644 --- a/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java +++ b/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java @@ -57,6 +57,10 @@ protected ManagedHttpJsonChannel() { this(null, true, null, null); } + String getEndpoint() { + return endpoint; + } + private ManagedHttpJsonChannel( Executor executor, boolean usingDefaultExecutor, diff --git a/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonLoggingInterceptorTest.java b/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonLoggingInterceptorTest.java new file mode 100644 index 0000000000..c7e134b5ce --- /dev/null +++ b/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonLoggingInterceptorTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.httpjson; + +import static org.mockito.Mockito.mock; + +import com.google.api.gax.httpjson.ApiMethodDescriptor.MethodType; + +class HttpJsonLoggingInterceptorTest { + + @SuppressWarnings("unchecked") + private static final ApiMethodDescriptor method = + ApiMethodDescriptor.newBuilder() + .setType(MethodType.UNARY) + .setRequestFormatter(mock(HttpRequestFormatter.class)) + .setRequestFormatter(mock(HttpRequestFormatter.class)) + .setFullMethodName("FakeClient/fake-method") + .setResponseParser(mock(HttpResponseParser.class)) + .build(); +} diff --git a/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTest.java b/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTest.java index 2e46157534..3e6b2d56d1 100644 --- a/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTest.java +++ b/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTest.java @@ -118,8 +118,12 @@ void managedChannelUsesDefaultChannelExecutor() throws IOException { // By default, the channel will be wrapped with ManagedHttpJsonInterceptorChannel ManagedHttpJsonInterceptorChannel interceptorChannel = (ManagedHttpJsonInterceptorChannel) httpJsonTransportChannel.getManagedChannel(); - ManagedHttpJsonChannel managedHttpJsonChannel = interceptorChannel.getChannel(); - assertThat(managedHttpJsonChannel.getExecutor()).isNotNull(); + // call getChannel() twice because interceptors are chained in layers by recursive construction + // inside com.google.api.gax.httpjson.InstantiatingHttpJsonChannelProvider.createChannel + ManagedHttpJsonInterceptorChannel managedHttpJsonChannel = + (ManagedHttpJsonInterceptorChannel) interceptorChannel.getChannel(); + ManagedHttpJsonChannel channel = managedHttpJsonChannel.getChannel(); + assertThat(channel.getExecutor()).isNotNull(); // Clean up the resources (executor, deadlineScheduler, httpTransport) instantiatingHttpJsonChannelProvider.getTransportChannel().shutdownNow(); @@ -146,9 +150,14 @@ void managedChannelUsesCustomExecutor() throws IOException { // By default, the channel will be wrapped with ManagedHttpJsonInterceptorChannel ManagedHttpJsonInterceptorChannel interceptorChannel = (ManagedHttpJsonInterceptorChannel) httpJsonTransportChannel.getManagedChannel(); - ManagedHttpJsonChannel managedHttpJsonChannel = interceptorChannel.getChannel(); - assertThat(managedHttpJsonChannel.getExecutor()).isNotNull(); - assertThat(managedHttpJsonChannel.getExecutor()).isEqualTo(executor); + // call getChannel() twice because interceptors are chained in layers by recursive construction + // inside com.google.api.gax.httpjson.InstantiatingHttpJsonChannelProvider.createChannel + ManagedHttpJsonInterceptorChannel managedHttpJsonChannel = + (ManagedHttpJsonInterceptorChannel) interceptorChannel.getChannel(); + ManagedHttpJsonChannel channel = managedHttpJsonChannel.getChannel(); + + assertThat(channel.getExecutor()).isNotNull(); + assertThat(channel.getExecutor()).isEqualTo(executor); // Clean up the resources (executor, deadlineScheduler, httpTransport) instantiatingHttpJsonChannelProvider.getTransportChannel().shutdownNow(); diff --git a/gax-java/gax/BUILD.bazel b/gax-java/gax/BUILD.bazel index 5dd3ff96bd..80b26ad785 100644 --- a/gax-java/gax/BUILD.bazel +++ b/gax-java/gax/BUILD.bazel @@ -28,7 +28,9 @@ _COMPILE_DEPS = [ "@com_google_code_gson_gson//jar", "@com_google_guava_failureaccess//jar", "@javax_annotation_javax_annotation_api//jar", - "@org_graalvm_sdk//jar" + "@org_graalvm_sdk//jar", + "@org_slf4j_slf4j_api//jar", + "@com_google_protobuf_protobuf_java_util//jar" ] _TEST_COMPILE_DEPS = [ diff --git a/gax-java/gax/pom.xml b/gax-java/gax/pom.xml index 4c2fa5f694..7323e6315e 100644 --- a/gax-java/gax/pom.xml +++ b/gax-java/gax/pom.xml @@ -38,6 +38,10 @@ com.google.protobuf protobuf-java + + com.google.protobuf + protobuf-java-util + org.threeten threetenbp @@ -69,6 +73,12 @@ opentelemetry-api true + + + org.slf4j + slf4j-api + true + @@ -112,7 +122,7 @@ @{argLine} -Djava.util.logging.SimpleFormatter.format="%1$tY %1$tl:%1$tM:%1$tS.%1$tL %2$s %4$s: %5$s%6$s%n" - !EndpointContextTest#endpointContextBuild_universeDomainEnvVarSet+endpointContextBuild_multipleUniverseDomainConfigurations_clientSettingsHasPriority + !EndpointContextTest#endpointContextBuild_universeDomainEnvVarSet+endpointContextBuild_multipleUniverseDomainConfigurations_clientSettingsHasPriority,!LoggingEnabledTest @@ -127,11 +137,12 @@ org.apache.maven.plugins maven-surefire-plugin - EndpointContextTest#endpointContextBuild_universeDomainEnvVarSet+endpointContextBuild_multipleUniverseDomainConfigurations_clientSettingsHasPriority + EndpointContextTest#endpointContextBuild_universeDomainEnvVarSet+endpointContextBuild_multipleUniverseDomainConfigurations_clientSettingsHasPriority,LoggingEnabledTest + \ No newline at end of file diff --git a/gax-java/gax/src/main/java/com/google/api/gax/logging/LogData.java b/gax-java/gax/src/main/java/com/google/api/gax/logging/LogData.java new file mode 100644 index 0000000000..368800dba0 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/logging/LogData.java @@ -0,0 +1,153 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import com.google.api.core.InternalApi; +import com.google.auto.value.AutoValue; +import com.google.gson.Gson; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; + +@InternalApi +@AutoValue +public abstract class LogData { + private static final Gson gson = new Gson(); + + @Nullable + public abstract String serviceName(); + + @Nullable + public abstract String rpcName(); + + @Nullable + public abstract String requestId(); + + @Nullable + public abstract Map requestHeaders(); + + @Nullable + public abstract Map requestPayload(); + + @Nullable + public abstract String responseStatus(); + + @Nullable + public abstract Map responseHeaders(); + + @Nullable + public abstract Map responsePayload(); + + @Nullable + public abstract String httpMethod(); + + @Nullable + public abstract String httpUrl(); + + public static Builder builder() { + return new AutoValue_LogData.Builder(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder serviceName(String serviceName); + + public abstract Builder rpcName(String rpcName); + + public abstract Builder requestId(String requestId); + + public abstract Builder requestHeaders(Map requestHeaders); + + public abstract Builder requestPayload(Map requestPayload); + + public abstract Builder responseStatus(String responseStatus); + + public abstract Builder responseHeaders(Map responseHeaders); + + public abstract Builder responsePayload(Map responsePayload); + + public abstract Builder httpMethod(String httpMethod); + + public abstract Builder httpUrl(String httpUrl); + + public abstract LogData build(); + } + + // helper functions to convert to map for logging + public Map toMapRequest() { + Map map = new HashMap<>(); + if (serviceName() != null) { + map.put("serviceName", serviceName()); + } + if (rpcName() != null) { + map.put("rpcName", rpcName()); + } + if (requestId() != null) { + map.put("requestId", requestId()); + } + if (requestHeaders() != null) { + map.put("request.headers", requestHeaders()); + } + if (requestPayload() != null) { + map.put("request.payload", requestPayload()); + } + if (httpMethod() != null) { + map.put("request.method", httpMethod()); + } + if (httpUrl() != null) { + map.put("request.url", httpUrl()); + } + return map; + } + + public Map toMapResponse() { + Map map = new HashMap<>(); + if (serviceName() != null) { + map.put("serviceName", serviceName()); + } + if (rpcName() != null) { + map.put("rpcName", rpcName()); + } + if (requestId() != null) { + map.put("requestId", requestId()); + } + if (responseStatus() != null) { + map.put("response.status", responseStatus()); + } + if (responseHeaders() != null) { + map.put("response.headers", responseHeaders()); + } + if (responsePayload() != null) { + map.put("response.payload", responsePayload()); + } + return map; + } +} diff --git a/gax-java/gax/src/main/java/com/google/api/gax/logging/LoggerProvider.java b/gax-java/gax/src/main/java/com/google/api/gax/logging/LoggerProvider.java new file mode 100644 index 0000000000..24745ee01e --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/logging/LoggerProvider.java @@ -0,0 +1,56 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import com.google.api.core.InternalApi; +import org.slf4j.Logger; + +@InternalApi +public class LoggerProvider { + + private Logger logger; + private final Class clazz; + + private LoggerProvider(Class clazz) { + this.clazz = clazz; + } + + public static LoggerProvider forClazz(Class clazz) { + return new LoggerProvider(clazz); + } + + public Logger getLogger() { + if (logger == null) { + this.logger = Slf4jUtils.getLogger(clazz); + } + return logger; + } +} diff --git a/gax-java/gax/src/main/java/com/google/api/gax/logging/LoggingUtils.java b/gax-java/gax/src/main/java/com/google/api/gax/logging/LoggingUtils.java new file mode 100644 index 0000000000..43b9254041 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/logging/LoggingUtils.java @@ -0,0 +1,141 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import com.google.api.core.InternalApi; +import java.util.Map; + +@InternalApi +public class LoggingUtils { + + private static boolean loggingEnabled = isLoggingEnabled(); + static final String GOOGLE_SDK_JAVA_LOGGING = "GOOGLE_SDK_JAVA_LOGGING"; + + static boolean isLoggingEnabled() { + String enableLogging = System.getenv(GOOGLE_SDK_JAVA_LOGGING); + return "true".equalsIgnoreCase(enableLogging); + } + + /** + * Sets logDataBuilder with service name, rpc name, endpoint and request headers based on logging + * level + * + * @param serviceName + * @param rpcName + * @param endpoint + * @param requestHeaders + * @param logDataBuilder + * @param loggerProvider + */ + public static void recordServiceRpcAndRequestHeaders( + String serviceName, + String rpcName, + String endpoint, + Map requestHeaders, + LogData.Builder logDataBuilder, + LoggerProvider loggerProvider) { + if (loggingEnabled) { + Slf4jLoggingHelpers.recordServiceRpcAndRequestHeaders( + serviceName, rpcName, endpoint, requestHeaders, logDataBuilder, loggerProvider); + } + } + + /** + * Sets logDataBuilder with response headers based on logging level + * + * @param headers + * @param logDataBuilder + * @param loggerProvider + */ + public static void recordResponseHeaders( + Map headers, LogData.Builder logDataBuilder, LoggerProvider loggerProvider) { + if (loggingEnabled) { + Slf4jLoggingHelpers.recordResponseHeaders(headers, logDataBuilder, loggerProvider); + } + } + + /** + * Sets logDataBuilder with respond payload based on logging level + * + * @param message + * @param logDataBuilder + * @param loggerProvider + * @param + */ + public static void recordResponsePayload( + RespT message, LogData.Builder logDataBuilder, LoggerProvider loggerProvider) { + if (loggingEnabled) { + Slf4jLoggingHelpers.recordResponsePayload(message, logDataBuilder, loggerProvider); + } + } + + /** + * Log response based on logging level configured + * + * @param status + * @param logDataBuilder + * @param loggerProvider + */ + public static void logResponse( + String status, LogData.Builder logDataBuilder, LoggerProvider loggerProvider) { + if (loggingEnabled) { + Slf4jLoggingHelpers.logResponse(status, logDataBuilder, loggerProvider); + } + } + + /** + * Log request based on logging level configured + * + * @param message + * @param logDataBuilder + * @param loggerProvider + * @param + */ + public static void logRequest( + RespT message, LogData.Builder logDataBuilder, LoggerProvider loggerProvider) { + if (loggingEnabled) { + Slf4jLoggingHelpers.logRequest(message, logDataBuilder, loggerProvider); + } + } + + public static void executeWithTryCatch(ThrowingRunnable action) { + try { + action.run(); + } catch (Throwable t) { + // let logging exceptions fail silently + } + } + + @FunctionalInterface + public interface ThrowingRunnable { + void run() throws Throwable; + } +} diff --git a/gax-java/gax/src/main/java/com/google/api/gax/logging/Slf4jLoggingHelpers.java b/gax-java/gax/src/main/java/com/google/api/gax/logging/Slf4jLoggingHelpers.java new file mode 100644 index 0000000000..2a914f4bf6 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/logging/Slf4jLoggingHelpers.java @@ -0,0 +1,155 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import com.google.api.core.InternalApi; +import com.google.common.base.Strings; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import java.util.Map; +import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.event.Level; + +/** Contains helper methods to log requests and responses */ +@InternalApi +class Slf4jLoggingHelpers { + + static final Gson gson = new Gson(); + + static Map messageToMapWithGson(Message message) + throws InvalidProtocolBufferException { + String json = JsonFormat.printer().print(message); + return gson.fromJson(json, new TypeToken>() {}.getType()); + } + + static void recordServiceRpcAndRequestHeaders( + String serviceName, + String rpcName, + String endpoint, + Map requestHeaders, + LogData.Builder logDataBuilder, + LoggerProvider loggerProvider) { + LoggingUtils.executeWithTryCatch( + () -> { + Logger logger = loggerProvider.getLogger(); + if (logger.isInfoEnabled()) { + addIfNotEmpty(logDataBuilder::serviceName, serviceName); + addIfNotEmpty(logDataBuilder::rpcName, rpcName); + addIfNotEmpty(logDataBuilder::httpUrl, endpoint); + } + if (logger.isDebugEnabled()) { + logDataBuilder.requestHeaders(requestHeaders); + } + }); + } + + private static void addIfNotEmpty(Consumer setter, String value) { + if (!Strings.isNullOrEmpty(value)) { + setter.accept(value); + } + } + + static void recordResponseHeaders( + Map headers, LogData.Builder logDataBuilder, LoggerProvider loggerProvider) { + LoggingUtils.executeWithTryCatch( + () -> { + Logger logger = loggerProvider.getLogger(); + if (logger.isDebugEnabled()) { + logDataBuilder.responseHeaders(headers); + } + }); + } + + static void recordResponsePayload( + RespT message, LogData.Builder logDataBuilder, LoggerProvider loggerProvider) { + LoggingUtils.executeWithTryCatch( + () -> { + Logger logger = loggerProvider.getLogger(); + if (logger.isDebugEnabled()) { + if (!(message instanceof Message)) { + // expect RespT to be Message type, otherwise do nothing and return + return; + } + Map messageToMapWithGson = messageToMapWithGson((Message) message); + + logDataBuilder.responsePayload(messageToMapWithGson); + } + }); + } + + static void logResponse( + String status, LogData.Builder logDataBuilder, LoggerProvider loggerProvider) { + LoggingUtils.executeWithTryCatch( + () -> { + Logger logger = loggerProvider.getLogger(); + if (logger.isInfoEnabled()) { + logDataBuilder.responseStatus(status); + } + if (logger.isInfoEnabled() && !logger.isDebugEnabled()) { + Map responseData = logDataBuilder.build().toMapResponse(); + Slf4jUtils.log(logger, Level.INFO, responseData, "Received response"); + } + if (logger.isDebugEnabled()) { + Map responsedDetailsMap = logDataBuilder.build().toMapResponse(); + Slf4jUtils.log(logger, Level.DEBUG, responsedDetailsMap, "Received response"); + } + }); + } + + static void logRequest( + RespT message, LogData.Builder logDataBuilder, LoggerProvider loggerProvider) { + LoggingUtils.executeWithTryCatch( + () -> { + Logger logger = loggerProvider.getLogger(); + if (logger.isInfoEnabled() && !logger.isDebugEnabled()) { + Slf4jUtils.log( + logger, Level.INFO, logDataBuilder.build().toMapRequest(), "Sending request"); + } + if (logger.isDebugEnabled()) { + if (!(message instanceof Message)) { + // expect RespT to be Message type, otherwise do nothing and return + return; + } + Map messageToMapWithGson = messageToMapWithGson((Message) message); + + logDataBuilder.requestPayload(messageToMapWithGson); + Map requestDetailsMap = logDataBuilder.build().toMapRequest(); + Slf4jUtils.log(logger, Level.DEBUG, requestDetailsMap, "Sending request"); + } + }); + } + + private Slf4jLoggingHelpers() {} +} diff --git a/gax-java/gax/src/main/java/com/google/api/gax/logging/Slf4jUtils.java b/gax-java/gax/src/main/java/com/google/api/gax/logging/Slf4jUtils.java new file mode 100644 index 0000000000..e9bce1101d --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/logging/Slf4jUtils.java @@ -0,0 +1,166 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import com.google.api.core.InternalApi; +import java.util.Map; +import java.util.Map.Entry; +import org.slf4j.ILoggerFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.slf4j.spi.LoggingEventBuilder; + +/** + * Contains util methods to get SLF4J logger and log conditionally based SLF4J major version Actual + * interaction with SLF4J happens only in this class. + */ +@InternalApi +class Slf4jUtils { + + private static final Logger NO_OP_LOGGER = org.slf4j.helpers.NOPLogger.NOP_LOGGER; + private static final boolean loggingEnabled = LoggingUtils.isLoggingEnabled(); + + private static final boolean isSLF4J2x; + + static { + isSLF4J2x = checkIfClazzAvailable("org.slf4j.event.KeyValuePair"); + } + + static boolean checkIfClazzAvailable(String clazzName) { + try { + Class.forName(clazzName); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + static Logger getLogger(Class clazz) { + return getLogger(clazz, new DefaultLoggerFactoryProvider()); + } + + // constructor with LoggerFactoryProvider to make testing easier + static Logger getLogger(Class clazz, LoggerFactoryProvider factoryProvider) { + if (loggingEnabled) { + ILoggerFactory loggerFactory = factoryProvider.getLoggerFactory(); + return loggerFactory.getLogger(clazz.getName()); + } else { + // use SLF4j's NOP logger regardless of bindings + return NO_OP_LOGGER; + } + } + + static void log( + Logger logger, org.slf4j.event.Level level, Map contextMap, String message) { + if (isSLF4J2x) { + logWithKeyValuePair(logger, level, contextMap, message); + } else { + logWithMDC(logger, level, contextMap, message); + } + } + + // exposed for testing + static void logWithMDC( + Logger logger, org.slf4j.event.Level level, Map contextMap, String message) { + if (!contextMap.isEmpty()) { + for (Entry entry : contextMap.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + MDC.put( + key, value instanceof String ? (String) value : Slf4jLoggingHelpers.gson.toJson(value)); + } + } + switch (level) { + case TRACE: + logger.trace(message); + break; + case DEBUG: + logger.debug(message); + break; + case INFO: + logger.info(message); + break; + case WARN: + logger.warn(message); + break; + case ERROR: + logger.error(message); + break; + default: + logger.debug(message); + // Default to DEBUG level + } + if (!contextMap.isEmpty()) { + MDC.clear(); + } + } + + private static void logWithKeyValuePair( + Logger logger, org.slf4j.event.Level level, Map contextMap, String message) { + LoggingEventBuilder loggingEventBuilder; + switch (level) { + case TRACE: + loggingEventBuilder = logger.atTrace(); + break; + case DEBUG: + loggingEventBuilder = logger.atDebug(); + break; + case INFO: + loggingEventBuilder = logger.atInfo(); + break; + case WARN: + loggingEventBuilder = logger.atWarn(); + break; + case ERROR: + loggingEventBuilder = logger.atError(); + break; + default: + loggingEventBuilder = logger.atDebug(); + // Default to DEBUG level + } + contextMap.forEach(loggingEventBuilder::addKeyValue); + loggingEventBuilder.log(message); + } + + private Slf4jUtils() {} + + interface LoggerFactoryProvider { + ILoggerFactory getLoggerFactory(); + } + + static class DefaultLoggerFactoryProvider implements LoggerFactoryProvider { + @Override + public ILoggerFactory getLoggerFactory() { + return LoggerFactory.getILoggerFactory(); + } + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/logging/LogDataTest.java b/gax-java/gax/src/test/java/com/google/api/gax/logging/LogDataTest.java new file mode 100644 index 0000000000..ff19dcd958 --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/logging/LogDataTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class LogDataTest { + + @Test + void toMapResponse_correctlyConvertsData() { + Map responsePayload = ImmutableMap.of("key", "value", "key2", 123); + LogData logData = + LogData.builder() + .serviceName("MyService") + .rpcName("myMethod") + .requestHeaders(ImmutableMap.of("fake header", "item")) + .requestId("abcd") + .responsePayload(responsePayload) + .build(); + + Map expectedMap = + ImmutableMap.of( + "serviceName", "MyService", + "rpcName", "myMethod", + "response.payload", responsePayload, + "requestId", "abcd"); + + assertThat(logData.toMapResponse()).isEqualTo(expectedMap); + } + + @Test + void toMapRequest_correctlyConvertsData() { + Map header = ImmutableMap.of("fake header", "item"); + LogData logData = + LogData.builder() + .serviceName("MyService") + .rpcName("myMethod") + .requestHeaders(header) + .requestId("abcd") + .httpUrl("url") + .responsePayload(ImmutableMap.of("key", "value", "key2", 123)) + .build(); + + Map expectedMap = + ImmutableMap.of( + "serviceName", "MyService", + "rpcName", "myMethod", + "request.headers", header, + "requestId", "abcd", + "request.url", "url"); + + assertThat(logData.toMapRequest()).isEqualTo(expectedMap); + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggerProviderTest.java b/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggerProviderTest.java new file mode 100644 index 0000000000..b16e94e538 --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggerProviderTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; + +class LoggerProviderTest { + + @Test + void testGetLogger_CreatesLoggerOnce() { + Class testClass = this.getClass(); + LoggerProvider provider = LoggerProvider.forClazz(testClass); + Logger logger1 = provider.getLogger(); + Logger logger2 = provider.getLogger(); + + assertNotNull(logger1); + assertSame(logger1, logger2); + } + + @Test + void testForClazz_ReturnsNewInstance() { + Class testClass1 = this.getClass(); + Class testClass2 = String.class; + LoggerProvider provider1 = LoggerProvider.forClazz(testClass1); + LoggerProvider provider2 = LoggerProvider.forClazz(testClass2); + + assertNotSame(provider1, provider2); + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggingEnabledTest.java b/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggingEnabledTest.java new file mode 100644 index 0000000000..2d755a6ecc --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggingEnabledTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.api.gax.logging.Slf4jUtils.LoggerFactoryProvider; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.slf4j.ILoggerFactory; +import org.slf4j.Logger; +import org.slf4j.helpers.NOPLogger; +import org.slf4j.helpers.NOPLoggerFactory; + +// These tests should only run when env GOOGLE_SDK_JAVA_LOGGING = true +// it is excluded by default and only included for `envVarTest` profile +class LoggingEnabledTest { + + @Test + void testIsLoggingEnabled_true() { + Assertions.assertTrue(LoggingUtils.isLoggingEnabled()); + } + + @Test + void testGetLogger_loggingEnabled_slf4jBindingPresent() { + // should get ILoggerFactory from TestServiceProvider + Logger logger = Slf4jUtils.getLogger(Slf4jUtilsTest.class); + Assertions.assertInstanceOf(Logger.class, logger); + Assertions.assertNotEquals(NOPLogger.class, logger.getClass()); + } + + @Test + void testGetLogger_loggingEnabled_noBinding_shouldGetNOPLogger() { + // Create a mock LoggerFactoryProvider, mimic SLF4J's behavior to return NOPLoggerFactory when + // no binding + LoggerFactoryProvider mockLoggerFactoryProvider = mock(LoggerFactoryProvider.class); + ILoggerFactory nopLoggerFactory = new NOPLoggerFactory(); + when(mockLoggerFactoryProvider.getLoggerFactory()).thenReturn(nopLoggerFactory); + + Logger logger = Slf4jUtils.getLogger(Slf4jUtilsTest.class, mockLoggerFactoryProvider); + + Assertions.assertInstanceOf(NOPLogger.class, logger); + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggingUtilsTest.java b/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggingUtilsTest.java new file mode 100644 index 0000000000..9e3099e929 --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggingUtilsTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.verify; + +import com.google.api.gax.logging.LoggingUtils.ThrowingRunnable; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class LoggingUtilsTest { + + @Test + void testIsLoggingEnabled_defaultToFalse() { + assertFalse(LoggingUtils.isLoggingEnabled()); + } + + @Test + void testExecuteWithTryCatch_noException() { + assertDoesNotThrow( + () -> + LoggingUtils.executeWithTryCatch( + () -> { + // Some code that should not throw an exception + int x = 5; + int y = 10; + int z = x + y; + assertEquals(15, z); + })); + } + + @Test + void testExecuteWithTryCatch_WithException() throws Throwable { + ThrowingRunnable action = Mockito.mock(ThrowingRunnable.class); + Mockito.doThrow(new RuntimeException("Test Exception")).when(action).run(); + assertDoesNotThrow(() -> LoggingUtils.executeWithTryCatch(action)); + // Verify that the action was executed (despite the exception) + verify(action).run(); + } + + @Test + void testExecuteWithTryCatch_WithNoSuchMethodError() throws Throwable { + ThrowingRunnable action = Mockito.mock(ThrowingRunnable.class); + Mockito.doThrow(new NoSuchMethodError("Test Error")).when(action).run(); + assertDoesNotThrow(() -> LoggingUtils.executeWithTryCatch(action)); + // Verify that the action was executed (despite the error) + verify(action).run(); + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/logging/Slf4jUtilsTest.java b/gax-java/gax/src/test/java/com/google/api/gax/logging/Slf4jUtilsTest.java new file mode 100644 index 0000000000..b40ca1bc2c --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/logging/Slf4jUtilsTest.java @@ -0,0 +1,367 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import static com.google.api.gax.logging.Slf4jLoggingHelpers.messageToMapWithGson; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.protobuf.Field; +import com.google.protobuf.Field.Cardinality; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Option; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.slf4j.Logger; +import org.slf4j.event.Level; +import org.slf4j.helpers.NOPLogger; + +class Slf4jUtilsTest { + + // This test should only run GOOGLE_SDK_JAVA_LOGGING != true + @Test + void testGetLogger_loggingDisabled_shouldGetNOPLogger() { + Logger logger = Slf4jUtils.getLogger(Slf4jUtilsTest.class); + assertEquals(NOPLogger.class, logger.getClass()); + assertFalse(logger.isInfoEnabled()); + assertFalse(logger.isDebugEnabled()); + } + + // These tests does not require GOOGLE_SDK_JAVA_LOGGING + @Test + void testLog_slf4J2xLogger() { + Map contextMap = new HashMap<>(); + contextMap.put("key1", "value"); + contextMap.put("key2", 123); + String message = "Test message"; + TestLogger testLogger = new TestLogger("test-logger"); + Slf4jUtils.log(testLogger, org.slf4j.event.Level.DEBUG, contextMap, message); + + assertEquals(message, testLogger.messageList.get(0)); + + assertEquals("value", testLogger.keyValuePairsMap.get("key1")); + assertEquals(123, testLogger.keyValuePairsMap.get("key2")); + } + + @Test + void testLogWithMDC_InfoLevel_VerifyMDC() { + // this test relies on TestMDCApapter and TestServiceProvider + TestLogger testLogger = new TestLogger("test-logger"); + Map contextMap = new HashMap<>(); + contextMap.put("key1", "value1"); + contextMap.put("key2", 123); + String message = "Test message"; + + // need a service provider + Slf4jUtils.logWithMDC(testLogger, Level.INFO, contextMap, message); + + Map mdcMap = testLogger.MDCMap; + + assertEquals(2, mdcMap.size()); + assertEquals("value1", mdcMap.get("key1")); + assertEquals("123", mdcMap.get("key2")); + + assertEquals(message, testLogger.messageList.get(0)); + } + + @Test + void testLogWithMDC_DEBUG() { + TestLogger testLogger = new TestLogger("test-logger"); + Slf4jUtils.logWithMDC(testLogger, Level.DEBUG, new HashMap<>(), "test message"); + + assertEquals(Level.DEBUG, testLogger.level); + } + + @Test + void testLogWithMDC_TRACE() { + TestLogger testLogger = new TestLogger("test-logger"); + Slf4jUtils.logWithMDC(testLogger, Level.TRACE, new HashMap<>(), "test message"); + + assertEquals(Level.TRACE, testLogger.level); + } + + @Test + void testLogWithMDC_WARN() { + TestLogger testLogger = new TestLogger("test-logger"); + Slf4jUtils.logWithMDC(testLogger, Level.WARN, new HashMap<>(), "test message"); + + assertEquals(Level.WARN, testLogger.level); + } + + @Test + void testLogWithMDC_ERROR() { + TestLogger testLogger = new TestLogger("test-logger"); + Slf4jUtils.logWithMDC(testLogger, Level.ERROR, new HashMap<>(), "test message"); + + assertEquals(Level.ERROR, testLogger.level); + } + + @Test + void testMessageToMap_ValidMessage() throws InvalidProtocolBufferException { + Field field = + Field.newBuilder() + .setNumber(2) + .setName("field_name1") + .addOptions(Option.newBuilder().setName("opt_name1").build()) + .addOptions(Option.newBuilder().setName("opt_name2").build()) + .setCardinality(Cardinality.CARDINALITY_OPTIONAL) + .build(); + + Map map = messageToMapWithGson(field); + + assertEquals("field_name1", map.get("name")); + assertEquals(2.0, map.get("number")); // Gson converts ints to doubles by default + } + + @Test + void testRecordServiceRpcAndRequestHeaders_infoEnabled() { + String serviceName = "testService"; + String rpcName = "testRpc"; + String endpoint = "http://test.com/endpoint"; + Map requestHeaders = new HashMap<>(); + requestHeaders.put("header1", "value1"); + requestHeaders.put("header2", "value2"); + + LogData.Builder logDataBuilder = LogData.builder(); + + TestLogger testLogger = new TestLogger("test-logger", true, true); + + Slf4jLoggingHelpers.recordServiceRpcAndRequestHeaders( + serviceName, + rpcName, + endpoint, + requestHeaders, + logDataBuilder, + setUpLoggerProviderMock(testLogger)); + + LogData logData = logDataBuilder.build(); + assertEquals(serviceName, logData.serviceName()); + assertEquals(rpcName, logData.rpcName()); + assertEquals(endpoint, logData.httpUrl()); + assertEquals(requestHeaders, logData.requestHeaders()); + } + + LoggerProvider setUpLoggerProviderMock(TestLogger testLogger) { + LoggerProvider loggerProvider = mock(LoggerProvider.class); + when(loggerProvider.getLogger()).thenReturn(testLogger); + return loggerProvider; + } + + @Test + void testRecordServiceRpcAndRequestHeaders_infoDisabled() { + String serviceName = "testService"; + String rpcName = "testRpc"; + String endpoint = "http://test.com/endpoint"; + Map requestHeaders = new HashMap<>(); + requestHeaders.put("header1", "value1"); + requestHeaders.put("header2", "value2"); + + LogData.Builder logDataBuilder = LogData.builder(); + + TestLogger testLogger = new TestLogger("test-logger", false, false); + + LoggerProvider loggerProvider = setUpLoggerProviderMock(testLogger); + Slf4jLoggingHelpers.recordServiceRpcAndRequestHeaders( + serviceName, rpcName, endpoint, requestHeaders, logDataBuilder, loggerProvider); + + LogData logData = logDataBuilder.build(); + assertEquals(null, logData.serviceName()); + assertEquals(null, logData.rpcName()); + assertEquals(null, logData.httpUrl()); + assertEquals(null, logData.requestHeaders()); + } + + @Test + void testRecordResponseHeaders_debugEnabled() { + Map responseHeaders = new HashMap<>(); + responseHeaders.put("header1", "value1"); + responseHeaders.put("header2", "value2"); + + LogData.Builder logDataBuilder = LogData.builder(); + TestLogger testLogger = new TestLogger("test-logger", true, true); + + Slf4jLoggingHelpers.recordResponseHeaders( + responseHeaders, logDataBuilder, setUpLoggerProviderMock(testLogger)); + + LogData logData = logDataBuilder.build(); + assertEquals(responseHeaders, logData.responseHeaders()); + } + + @Test + void testRecordResponseHeaders_debugDisabled() { + Map responseHeaders = new HashMap<>(); + responseHeaders.put("header1", "value1"); + responseHeaders.put("header2", "value2"); + + LogData.Builder logDataBuilder = LogData.builder(); + TestLogger testLogger = new TestLogger("test-logger", true, false); + + Slf4jLoggingHelpers.recordResponseHeaders( + responseHeaders, logDataBuilder, setUpLoggerProviderMock(testLogger)); + + LogData logData = logDataBuilder.build(); + assertEquals(null, logData.responseHeaders()); + } + + @Test + void testRecordResponsePayload_debugEnabled() { + + Field field = + Field.newBuilder() + .setName("field_name1") + .addOptions(Option.newBuilder().setName("opt_name1").build()) + .addOptions(Option.newBuilder().setName("opt_name2").build()) + .build(); + + LogData.Builder logDataBuilder = LogData.builder(); + TestLogger testLogger = new TestLogger("test-logger", true, true); + + Slf4jLoggingHelpers.recordResponsePayload( + field, logDataBuilder, setUpLoggerProviderMock(testLogger)); + + LogData logData = logDataBuilder.build(); + assertEquals(2, logData.responsePayload().size()); + assertEquals("field_name1", logData.responsePayload().get("name")); + assertEquals( + "[{name=opt_name1}, {name=opt_name2}]", + logData.responsePayload().get("options").toString()); + } + + @Test + void testLogRequest_infoEnabled_debugDisabled() { + Object message = new Object(); // not used in info path + LogData.Builder logDataBuilder = Mockito.mock(LogData.Builder.class); + + LogData.Builder testLogDataBuilder = + LogData.builder().serviceName("service-name").rpcName("rpc-name"); + when(logDataBuilder.build()).thenReturn(testLogDataBuilder.build()); + + TestLogger testLogger = new TestLogger("test", true, false); + Slf4jLoggingHelpers.logRequest(message, logDataBuilder, setUpLoggerProviderMock(testLogger)); + + assertEquals(2, testLogger.keyValuePairsMap.size()); + assertEquals("Sending request", testLogger.messageList.get(0)); + verify(logDataBuilder, never()).requestPayload(anyMap()); // Ensure debug path is not taken + + assertEquals(Level.INFO, testLogger.level); + } + + @Test + void testLogRequest_debugEnabled() throws InvalidProtocolBufferException { + Field field = + Field.newBuilder() + .setName("field_name1") + .addOptions(Option.newBuilder().setName("opt_name1").build()) + .addOptions(Option.newBuilder().setName("opt_name2").build()) + .build(); + + LogData.Builder logDataBuilder = Mockito.mock(LogData.Builder.class); + LogData.Builder testLogDataBuilder = + LogData.builder() + .serviceName("service-name") + .rpcName("rpc-name") + .requestPayload(Slf4jLoggingHelpers.messageToMapWithGson(field)); + when(logDataBuilder.build()).thenReturn(testLogDataBuilder.build()); + + TestLogger testLogger = new TestLogger("test-logger", true, true); + + Slf4jLoggingHelpers.logRequest(field, logDataBuilder, setUpLoggerProviderMock(testLogger)); + + verify(logDataBuilder).requestPayload(Slf4jLoggingHelpers.messageToMapWithGson(field)); + + assertEquals(3, testLogger.keyValuePairsMap.size()); + assertEquals(2, ((Map) testLogger.keyValuePairsMap.get("request.payload")).size()); + assertEquals("Sending request", testLogger.messageList.get(0)); + + assertEquals(Level.DEBUG, testLogger.level); + } + + @Test + void testLogResponse_infoEnabled_debugDisabled() { + String status = "OK"; + Map responseData = new HashMap<>(); + + LogData.Builder logDataBuilder = Mockito.mock(LogData.Builder.class); + LogData.Builder testLogDataBuilder = + LogData.builder() + .serviceName("service-name") + .rpcName("rpc-name") + .responsePayload(responseData); + when(logDataBuilder.build()).thenReturn(testLogDataBuilder.build()); + TestLogger testLogger = new TestLogger("test-logger", true, false); + + Slf4jLoggingHelpers.logResponse(status, logDataBuilder, setUpLoggerProviderMock(testLogger)); + + verify(logDataBuilder).responseStatus(status); + assertEquals("Received response", (testLogger).messageList.get(0)); + assertEquals(3, (testLogger).keyValuePairsMap.size()); + assertTrue((testLogger).keyValuePairsMap.containsKey("response.payload")); + assertEquals(Level.INFO, (testLogger).level); + } + + @Test + void testLogResponse_infoEnabled_debugEnabled() { + String status = "OK"; + Map responseData = new HashMap<>(); + + LogData.Builder logDataBuilder = Mockito.mock(LogData.Builder.class); + LogData.Builder testLogDataBuilder = + LogData.builder() + .serviceName("service-name") + .rpcName("rpc-name") + .responsePayload(responseData); + when(logDataBuilder.build()).thenReturn(testLogDataBuilder.build()); + TestLogger testLogger = new TestLogger("test-logger", true, true); + + Slf4jLoggingHelpers.logResponse(status, logDataBuilder, setUpLoggerProviderMock(testLogger)); + + verify(logDataBuilder).responseStatus(status); + assertEquals("Received response", (testLogger).messageList.get(0)); + assertEquals(3, (testLogger).keyValuePairsMap.size()); + assertTrue((testLogger).keyValuePairsMap.containsKey("response.payload")); + + assertEquals(Level.DEBUG, (testLogger).level); + } + + @Test + void testCheckIfClazzAvailable() { + assertFalse(Slf4jUtils.checkIfClazzAvailable("fake.class.should.not.be.in.classpath")); + assertTrue(Slf4jUtils.checkIfClazzAvailable("org.slf4j.event.KeyValuePair")); + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/logging/TestLogger.java b/gax-java/gax/src/test/java/com/google/api/gax/logging/TestLogger.java new file mode 100644 index 0000000000..3ee7e513cd --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/logging/TestLogger.java @@ -0,0 +1,301 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.MDC; +import org.slf4j.Marker; +import org.slf4j.event.KeyValuePair; +import org.slf4j.event.Level; +import org.slf4j.event.LoggingEvent; +import org.slf4j.spi.LoggingEventAware; + +/** Logger implementation for testing purposes only. Only implemented methods used in tests. */ +public class TestLogger implements Logger, LoggingEventAware { + Map MDCMap = new HashMap<>(); + List messageList = new ArrayList<>(); + Level level; + + Map keyValuePairsMap = new HashMap<>(); + + private String loggerName; + private boolean infoEnabled; + private boolean debugEnabled; + + public TestLogger(String name) { + loggerName = name; + infoEnabled = true; + debugEnabled = true; + } + + public TestLogger(String name, boolean info, boolean debug) { + loggerName = name; + infoEnabled = info; + debugEnabled = debug; + } + + @Override + public String getName() { + return loggerName; + } + + @Override + public boolean isTraceEnabled() { + return false; + } + + @Override + public void trace(String msg) { + level = Level.TRACE; + } + + @Override + public void trace(String format, Object arg) {} + + @Override + public void trace(String format, Object arg1, Object arg2) {} + + @Override + public void trace(String format, Object... arguments) {} + + @Override + public void trace(String msg, Throwable t) {} + + @Override + public boolean isTraceEnabled(Marker marker) { + return false; + } + + @Override + public void trace(Marker marker, String msg) {} + + @Override + public void trace(Marker marker, String format, Object arg) {} + + @Override + public void trace(Marker marker, String format, Object arg1, Object arg2) {} + + @Override + public void trace(Marker marker, String format, Object... argArray) {} + + @Override + public void trace(Marker marker, String msg, Throwable t) {} + + @Override + public boolean isDebugEnabled() { + return debugEnabled; + } + + @Override + public void debug(String msg) { + Map currentMDC = MDC.getCopyOfContextMap(); + MDCMap.putAll(currentMDC); + messageList.add(msg); + level = Level.DEBUG; + } + + @Override + public void debug(String format, Object arg) {} + + @Override + public void debug(String format, Object arg1, Object arg2) {} + + @Override + public void debug(String format, Object... arguments) {} + + @Override + public void debug(String msg, Throwable t) {} + + @Override + public boolean isDebugEnabled(Marker marker) { + return false; + } + + @Override + public void debug(Marker marker, String msg) {} + + @Override + public void debug(Marker marker, String format, Object arg) {} + + @Override + public void debug(Marker marker, String format, Object arg1, Object arg2) {} + + @Override + public void debug(Marker marker, String format, Object... arguments) {} + + @Override + public void debug(Marker marker, String msg, Throwable t) {} + + @Override + public boolean isInfoEnabled() { + return infoEnabled; + } + + @Override + public void info(String msg) { + // access MDC content here before it is cleared. + // TestMDCAdapter is set up via TestServiceProvider + // to allow MDC values recorded and copied for testing here + Map currentMDC = MDC.getCopyOfContextMap(); + MDCMap.putAll(currentMDC); + messageList.add(msg); + } + + @Override + public void info(String format, Object arg) {} + + @Override + public void info(String format, Object arg1, Object arg2) {} + + @Override + public void info(String format, Object... arguments) {} + + @Override + public void info(String msg, Throwable t) {} + + @Override + public boolean isInfoEnabled(Marker marker) { + return false; + } + + @Override + public void info(Marker marker, String msg) {} + + @Override + public void info(Marker marker, String format, Object arg) {} + + @Override + public void info(Marker marker, String format, Object arg1, Object arg2) {} + + @Override + public void info(Marker marker, String format, Object... arguments) {} + + @Override + public void info(Marker marker, String msg, Throwable t) {} + + @Override + public boolean isWarnEnabled() { + return false; + } + + @Override + public void warn(String msg) { + level = Level.WARN; + } + + @Override + public void warn(String format, Object arg) {} + + @Override + public void warn(String format, Object... arguments) {} + + @Override + public void warn(String format, Object arg1, Object arg2) {} + + @Override + public void warn(String msg, Throwable t) {} + + @Override + public boolean isWarnEnabled(Marker marker) { + return false; + } + + @Override + public void warn(Marker marker, String msg) {} + + @Override + public void warn(Marker marker, String format, Object arg) {} + + @Override + public void warn(Marker marker, String format, Object arg1, Object arg2) {} + + @Override + public void warn(Marker marker, String format, Object... arguments) {} + + @Override + public void warn(Marker marker, String msg, Throwable t) {} + + @Override + public boolean isErrorEnabled() { + return false; + } + + @Override + public void error(String msg) { + level = Level.ERROR; + } + + @Override + public void error(String format, Object arg) {} + + @Override + public void error(String format, Object arg1, Object arg2) {} + + @Override + public void error(String format, Object... arguments) {} + + @Override + public void error(String msg, Throwable t) {} + + @Override + public boolean isErrorEnabled(Marker marker) { + return false; + } + + @Override + public void error(Marker marker, String msg) {} + + @Override + public void error(Marker marker, String format, Object arg) {} + + @Override + public void error(Marker marker, String format, Object arg1, Object arg2) {} + + @Override + public void error(Marker marker, String format, Object... arguments) {} + + @Override + public void error(Marker marker, String msg, Throwable t) {} + + @Override + public void log(LoggingEvent event) { + messageList.add(event.getMessage()); + level = event.getLevel(); + List keyValuePairs = event.getKeyValuePairs(); + for (KeyValuePair pair : keyValuePairs) { + keyValuePairsMap.put(pair.key, pair.value); + } + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/logging/TestMDCAdapter.java b/gax-java/gax/src/test/java/com/google/api/gax/logging/TestMDCAdapter.java new file mode 100644 index 0000000000..b1b5d379cc --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/logging/TestMDCAdapter.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import java.util.Deque; +import java.util.HashMap; +import java.util.Map; +import org.slf4j.spi.MDCAdapter; + +/** + * this adapter is for unit test only. It is set up via TestServiceProvider to test behavior when + * LogWithMDC + */ +public class TestMDCAdapter implements MDCAdapter { + Map mdcValues = new HashMap<>(); + + @Override + public void put(String key, String val) { + mdcValues.put(key, val); + } + + @Override + public String get(String key) { + return ""; + } + + @Override + public void remove(String key) {} + + @Override + public void clear() { + mdcValues.clear(); + } + + @Override + public Map getCopyOfContextMap() { + return mdcValues; + } + + @Override + public void setContextMap(Map contextMap) {} + + @Override + public void pushByKey(String key, String value) {} + + @Override + public String popByKey(String key) { + return ""; + } + + @Override + public Deque getCopyOfDequeByKey(String key) { + return null; + } + + @Override + public void clearDequeByKey(String key) {} +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/logging/TestServiceProvider.java b/gax-java/gax/src/test/java/com/google/api/gax/logging/TestServiceProvider.java new file mode 100644 index 0000000000..69cc43ba2a --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/logging/TestServiceProvider.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.api.gax.logging; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.slf4j.ILoggerFactory; +import org.slf4j.IMarkerFactory; +import org.slf4j.spi.MDCAdapter; +import org.slf4j.spi.SLF4JServiceProvider; + +/** + * This provider is made discoverable to SFL4J's LoggerFactory in + * resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider + */ +public class TestServiceProvider implements SLF4JServiceProvider { + + @Override + public ILoggerFactory getLoggerFactory() { + // mock behavior when provider present + ILoggerFactory mockLoggerFactory = mock(ILoggerFactory.class); + when(mockLoggerFactory.getLogger(anyString())).thenReturn(new TestLogger("test-logger")); + return mockLoggerFactory; + } + + @Override + public IMarkerFactory getMarkerFactory() { + return null; + } + + @Override + public MDCAdapter getMDCAdapter() { + return new TestMDCAdapter(); + } + + @Override + public String getRequestedApiVersion() { + return ""; + } + + @Override + public void initialize() {} +} diff --git a/gax-java/gax/src/test/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider b/gax-java/gax/src/test/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider new file mode 100644 index 0000000000..de8b02c7be --- /dev/null +++ b/gax-java/gax/src/test/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider @@ -0,0 +1 @@ +com.google.api.gax.logging.TestServiceProvider \ No newline at end of file diff --git a/gax-java/pom.xml b/gax-java/pom.xml index 72b31f7070..193276a45d 100644 --- a/gax-java/pom.xml +++ b/gax-java/pom.xml @@ -134,6 +134,11 @@ graal-sdk ${graal-sdk.version} + + org.slf4j + slf4j-api + ${slf4j.version} + com.google.http-client google-http-client-bom @@ -165,8 +170,9 @@ - + + org.junit.jupiter junit-jupiter-engine diff --git a/showcase/gapic-showcase/pom.xml b/showcase/gapic-showcase/pom.xml index 2549eb199f..331146b5d8 100644 --- a/showcase/gapic-showcase/pom.xml +++ b/showcase/gapic-showcase/pom.xml @@ -215,5 +215,144 @@ opentelemetry-sdk-testing test + + + + + + slf4j2_logback + + + + slf4j2_logback + + + + + org.slf4j + slf4j-api + 2.0.16 + test + + + ch.qos.logback + logback-classic + 1.5.16 + test + + + ch.qos.logback + logback-core + 1.5.16 + test + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + **/com/google/showcase/v1beta1/it/*.java + **/com/google/showcase/v1beta1/it/logging/ITLoggingDisabled.java + **/com/google/showcase/v1beta1/it/logging/ITLogging1x.java + + + + + + + + slf4j1_logback + + + + slf4j1_logback + + + + + org.slf4j + slf4j-api + 1.7.36 + test + + + ch.qos.logback + logback-classic + 1.2.13 + test + + + ch.qos.logback + logback-core + 1.2.13 + test + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + **/com/google/showcase/v1beta1/it/*.java + **/com/google/showcase/v1beta1/it/logging/ITLoggingDisabled.java + **/com/google/showcase/v1beta1/it/logging/ITLogging.java + + + + + + + + disabledLogging + + + + + disable_logging + + + + + org.slf4j + slf4j-api + 1.7.36 + test + + + ch.qos.logback + logback-classic + 1.2.13 + test + + + ch.qos.logback + logback-core + 1.2.13 + test + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + **/com/google/showcase/v1beta1/it/*.java + **/com/google/showcase/v1beta1/it/logging/ITLogging1x.java + **/com/google/showcase/v1beta1/it/logging/ITLogging.java + + + + + + + + diff --git a/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITLogging.java b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITLogging.java new file mode 100644 index 0000000000..5979b88c41 --- /dev/null +++ b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITLogging.java @@ -0,0 +1,257 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 com.google.showcase.v1beta1.it.logging; + +import static com.google.common.truth.Truth.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import com.google.api.gax.grpc.GrpcLoggingInterceptor; +import com.google.api.gax.httpjson.HttpJsonLoggingInterceptor; +import com.google.common.collect.ImmutableMap; +import com.google.showcase.v1beta1.EchoClient; +import com.google.showcase.v1beta1.EchoRequest; +import com.google.showcase.v1beta1.EchoResponse; +import com.google.showcase.v1beta1.it.util.TestClientInitializer; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.event.KeyValuePair; + +// This test needs to run with GOOGLE_SDK_JAVA_LOGGING=true +// mvn clean verify -P '!showcase,enable-integration-tests,loggingTestBase,slf4j2_logback' +public class ITLogging { + private static EchoClient grpcClient; + + private static EchoClient httpjsonClient; + + private static final KeyValuePair SERVICE_NAME_KEY_VALUE_PAIR = + new KeyValuePair("serviceName", "google.showcase.v1beta1.Echo"); + private static final KeyValuePair RPC_NAME_KEY_VALUE_PAIR = + new KeyValuePair("rpcName", "google.showcase.v1beta1.Echo/Echo"); + private static final KeyValuePair RESPONSE_STATUS_KEY_VALUE_PAIR = + new KeyValuePair("response.status", "OK"); + private static final KeyValuePair RESPONSE_STATUS_KEY_VALUE_PAIR_HTTP = + new KeyValuePair("response.status", "200"); + private static final KeyValuePair REQUEST_URL_KEY_VALUE_PAIR = + new KeyValuePair("request.url", "http://localhost:7469"); + + private static final KeyValuePair RESPONSE_HEADERS_KEY_VALUE_PAIR = + new KeyValuePair("response.headers", ImmutableMap.of("content-type", "application/grpc")); + + private static final String SENDING_REQUEST_MESSAGE = "Sending request"; + private static final String RECEIVING_RESPONSE_MESSAGE = "Received response"; + private static final String ECHO_STRING = "echo?"; + + private TestAppender setupTestLogger(Class clazz, Level level) { + TestAppender testAppender = new TestAppender(); + testAppender.start(); + org.slf4j.Logger logger = LoggerFactory.getLogger(clazz); + ((ch.qos.logback.classic.Logger) logger).setLevel(level); + ((ch.qos.logback.classic.Logger) logger).addAppender(testAppender); + return testAppender; + } + + @BeforeAll + static void createClients() throws Exception { + // Create gRPC Echo Client + grpcClient = TestClientInitializer.createGrpcEchoClient(); + // Create Http JSON Echo Client + httpjsonClient = TestClientInitializer.createHttpJsonEchoClient(); + } + + @AfterAll + static void destroyClients() throws InterruptedException { + grpcClient.close(); + httpjsonClient.close(); + + grpcClient.awaitTermination(TestClientInitializer.AWAIT_TERMINATION_SECONDS, TimeUnit.SECONDS); + httpjsonClient.awaitTermination( + TestClientInitializer.AWAIT_TERMINATION_SECONDS, TimeUnit.SECONDS); + } + + @Test + void testGrpc_receiveContent_logDebug() { + TestAppender testAppender = setupTestLogger(GrpcLoggingInterceptor.class, Level.DEBUG); + assertThat(echoGrpc(ECHO_STRING)).isEqualTo(ECHO_STRING); + + assertThat(testAppender.events.size()).isEqualTo(2); + // logging event for request + ILoggingEvent loggingEvent1 = testAppender.events.get(0); + assertThat(loggingEvent1.getMessage()).isEqualTo(SENDING_REQUEST_MESSAGE); + assertThat(loggingEvent1.getLevel()).isEqualTo(Level.DEBUG); + List keyValuePairs = loggingEvent1.getKeyValuePairs(); + assertThat(keyValuePairs.size()).isEqualTo(4); + assertThat(keyValuePairs).containsAtLeast(SERVICE_NAME_KEY_VALUE_PAIR, RPC_NAME_KEY_VALUE_PAIR); + + for (KeyValuePair kvp : keyValuePairs) { + if (kvp.key.equals("request.payload")) { + Map payload = (Map) kvp.value; + assertThat(payload.get("content")).isEqualTo(ECHO_STRING); + assertThat(payload.get("requestId")).isNotNull(); + assertThat(payload.get("otherRequestId")).isNotNull(); + } + if (kvp.key.equals("request.headers")) { + Map headers = (Map) kvp.value; + assertThat(headers).containsKey("x-goog-api-version"); + assertThat(headers).containsKey("x-goog-api-client"); + } + } + + // logging event for response + ILoggingEvent loggingEvent2 = testAppender.events.get(1); + assertThat(loggingEvent2.getMessage()).isEqualTo(RECEIVING_RESPONSE_MESSAGE); + + assertThat(loggingEvent2.getLevel()).isEqualTo(Level.DEBUG); + List keyValuePairs2 = loggingEvent2.getKeyValuePairs(); + assertThat(keyValuePairs2.size()).isEqualTo(5); + assertThat(keyValuePairs2) + .containsAtLeast( + RESPONSE_STATUS_KEY_VALUE_PAIR, + RESPONSE_HEADERS_KEY_VALUE_PAIR, + SERVICE_NAME_KEY_VALUE_PAIR, + RPC_NAME_KEY_VALUE_PAIR); + for (KeyValuePair kvp : keyValuePairs2) { + if (kvp.key.equals("response.payload")) { + Map payload = (Map) kvp.value; + assertThat(payload.get("content")).isEqualTo(ECHO_STRING); + assertThat(payload.get("requestId")).isNotNull(); + assertThat(payload.get("otherRequestId")).isNotNull(); + } + } + testAppender.stop(); + } + + @Test + void testGrpc_receiveContent_logInfo() { + TestAppender testAppender = setupTestLogger(GrpcLoggingInterceptor.class, Level.INFO); + assertThat(echoGrpc(ECHO_STRING)).isEqualTo(ECHO_STRING); + + assertThat(testAppender.events.size()).isEqualTo(2); + // logging event for request + ILoggingEvent loggingEvent1 = testAppender.events.get(0); + assertThat(loggingEvent1.getMessage()).isEqualTo(SENDING_REQUEST_MESSAGE); + assertThat(loggingEvent1.getLevel()).isEqualTo(Level.INFO); + List keyValuePairs = loggingEvent1.getKeyValuePairs(); + assertThat(keyValuePairs.size()).isEqualTo(2); + assertThat(keyValuePairs).containsAtLeast(SERVICE_NAME_KEY_VALUE_PAIR, RPC_NAME_KEY_VALUE_PAIR); + + // logging event for response + ILoggingEvent loggingEvent2 = testAppender.events.get(1); + assertThat(loggingEvent2.getMessage()).isEqualTo(RECEIVING_RESPONSE_MESSAGE); + + assertThat(loggingEvent2.getLevel()).isEqualTo(Level.INFO); + List keyValuePairs2 = loggingEvent2.getKeyValuePairs(); + assertThat(keyValuePairs2.size()).isEqualTo(3); + assertThat(keyValuePairs2) + .containsAtLeast( + RESPONSE_STATUS_KEY_VALUE_PAIR, SERVICE_NAME_KEY_VALUE_PAIR, RPC_NAME_KEY_VALUE_PAIR); + testAppender.stop(); + } + + @Test + void testHttpJson_receiveContent_logDebug() { + TestAppender testAppender = setupTestLogger(HttpJsonLoggingInterceptor.class, Level.DEBUG); + assertThat(echoHttpJson(ECHO_STRING)).isEqualTo(ECHO_STRING); + assertThat(testAppender.events.size()).isEqualTo(2); + // logging event for request + ILoggingEvent loggingEvent1 = testAppender.events.get(0); + assertThat(loggingEvent1.getMessage()).isEqualTo(SENDING_REQUEST_MESSAGE); + assertThat(loggingEvent1.getLevel()).isEqualTo(Level.DEBUG); + List keyValuePairs = loggingEvent1.getKeyValuePairs(); + assertThat(keyValuePairs.size()).isEqualTo(4); + assertThat(keyValuePairs).contains(RPC_NAME_KEY_VALUE_PAIR); + assertThat(keyValuePairs).contains(REQUEST_URL_KEY_VALUE_PAIR); + + for (KeyValuePair kvp : keyValuePairs) { + if (kvp.key.equals("request.payload")) { + Map payload = (Map) kvp.value; + assertThat(payload.get("content")).isEqualTo(ECHO_STRING); + assertThat(payload.get("requestId")).isNotNull(); + assertThat(payload.get("otherRequestId")).isNotNull(); + } + if (kvp.key.equals("request.headers")) { + Map headers = (Map) kvp.value; + assertThat(headers).containsKey("x-goog-api-version"); + assertThat(headers).containsKey("x-goog-api-client"); + } + } + + // logging event for response + ILoggingEvent loggingEvent2 = testAppender.events.get(1); + assertThat(loggingEvent2.getMessage()).isEqualTo(RECEIVING_RESPONSE_MESSAGE); + + assertThat(loggingEvent2.getLevel()).isEqualTo(Level.DEBUG); + List keyValuePairs2 = loggingEvent2.getKeyValuePairs(); + assertThat(keyValuePairs2.size()).isEqualTo(4); + assertThat(keyValuePairs2) + .containsAtLeast(RESPONSE_STATUS_KEY_VALUE_PAIR_HTTP, RPC_NAME_KEY_VALUE_PAIR); + for (KeyValuePair kvp : keyValuePairs2) { + if (kvp.key.equals("response.payload")) { + Map payload = (Map) kvp.value; + assertThat(payload.get("content")).isEqualTo(ECHO_STRING); + assertThat(payload.get("requestId")).isNotNull(); + assertThat(payload.get("otherRequestId")).isNotNull(); + } + if (kvp.key.equals("response.headers")) { + Map headers = (Map) kvp.value; + assertThat(headers.size()).isEqualTo(11); + } + } + testAppender.stop(); + } + + @Test + void testHttpJson_receiveContent_logInfo() { + TestAppender testAppender = setupTestLogger(HttpJsonLoggingInterceptor.class, Level.INFO); + assertThat(echoHttpJson(ECHO_STRING)).isEqualTo(ECHO_STRING); + assertThat(testAppender.events.size()).isEqualTo(2); + // logging event for request + ILoggingEvent loggingEvent1 = testAppender.events.get(0); + assertThat(loggingEvent1.getMessage()).isEqualTo(SENDING_REQUEST_MESSAGE); + assertThat(loggingEvent1.getLevel()).isEqualTo(Level.INFO); + List keyValuePairs = loggingEvent1.getKeyValuePairs(); + assertThat(keyValuePairs.size()).isEqualTo(2); + assertThat(keyValuePairs).contains(RPC_NAME_KEY_VALUE_PAIR); + assertThat(keyValuePairs).contains(REQUEST_URL_KEY_VALUE_PAIR); + + // logging event for response + ILoggingEvent loggingEvent2 = testAppender.events.get(1); + assertThat(loggingEvent2.getMessage()).isEqualTo(RECEIVING_RESPONSE_MESSAGE); + + assertThat(loggingEvent2.getLevel()).isEqualTo(Level.INFO); + List keyValuePairs2 = loggingEvent2.getKeyValuePairs(); + assertThat(keyValuePairs2.size()).isEqualTo(2); + assertThat(keyValuePairs2) + .containsAtLeast(RESPONSE_STATUS_KEY_VALUE_PAIR_HTTP, RPC_NAME_KEY_VALUE_PAIR); + testAppender.stop(); + } + + private String echoGrpc(String value) { + EchoResponse response = grpcClient.echo(EchoRequest.newBuilder().setContent(value).build()); + return response.getContent(); + } + + private String echoHttpJson(String value) { + EchoResponse response = httpjsonClient.echo(EchoRequest.newBuilder().setContent(value).build()); + return response.getContent(); + } +} diff --git a/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITLogging1x.java b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITLogging1x.java new file mode 100644 index 0000000000..d0dec4b80d --- /dev/null +++ b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITLogging1x.java @@ -0,0 +1,217 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 com.google.showcase.v1beta1.it.logging; + +import static com.google.common.truth.Truth.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import com.google.api.gax.grpc.GrpcLoggingInterceptor; +import com.google.api.gax.httpjson.HttpJsonLoggingInterceptor; +import com.google.common.collect.ImmutableMap; +import com.google.showcase.v1beta1.EchoClient; +import com.google.showcase.v1beta1.EchoRequest; +import com.google.showcase.v1beta1.EchoResponse; +import com.google.showcase.v1beta1.it.util.TestClientInitializer; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +// This test needs to run with GOOGLE_SDK_JAVA_LOGGING=true +// mvn verify -P +// '!showcase,enable-integration-tests,loggingTestBase,slf4j1_logback ' +public class ITLogging1x { + private static EchoClient grpcClient; + + private static EchoClient httpjsonClient; + + private static final String ECHO_STRING = "echo?"; + private static final String SERVICE_NAME = "google.showcase.v1beta1.Echo"; + private static final String RPC_NAME = "google.showcase.v1beta1.Echo/Echo"; + private static final String ENDPOINT = "http://localhost:7469"; + private static final String SENDING_REQUEST_MESSAGE = "Sending request"; + private static final String RECEIVING_RESPONSE_MESSAGE = "Received response"; + + private static Logger logger = LoggerFactory.getLogger(ITLogging1x.class); + + private TestAppender setupTestLogger(Class clazz, Level level) { + TestAppender testAppender = new TestAppender(); + testAppender.start(); + Logger logger = LoggerFactory.getLogger(clazz); + ((ch.qos.logback.classic.Logger) logger).setLevel(level); + ((ch.qos.logback.classic.Logger) logger).addAppender(testAppender); + return testAppender; + } + + @BeforeAll + static void createClients() throws Exception { + // Create gRPC Echo Client + grpcClient = TestClientInitializer.createGrpcEchoClient(); + // Create Http JSON Echo Client + httpjsonClient = TestClientInitializer.createHttpJsonEchoClient(); + } + + @AfterAll + static void destroyClients() throws InterruptedException { + grpcClient.close(); + httpjsonClient.close(); + + grpcClient.awaitTermination(TestClientInitializer.AWAIT_TERMINATION_SECONDS, TimeUnit.SECONDS); + httpjsonClient.awaitTermination( + TestClientInitializer.AWAIT_TERMINATION_SECONDS, TimeUnit.SECONDS); + } + + @Test + void test() { + assertThat(logger.isInfoEnabled()).isTrue(); + assertThat(logger.isDebugEnabled()).isTrue(); + } + + @Test + void testGrpc_receiveContent_logDebug() { + TestAppender testAppender = setupTestLogger(GrpcLoggingInterceptor.class, Level.DEBUG); + assertThat(echoGrpc(ECHO_STRING)).isEqualTo(ECHO_STRING); + + assertThat(testAppender.events.size()).isEqualTo(2); + // logging event for request + ILoggingEvent loggingEvent1 = testAppender.events.get(0); + assertThat(loggingEvent1.getMessage()).isEqualTo(SENDING_REQUEST_MESSAGE); + assertThat(loggingEvent1.getLevel()).isEqualTo(Level.DEBUG); + Map mdcPropertyMap = loggingEvent1.getMDCPropertyMap(); + assertThat(mdcPropertyMap) + .containsAtLeastEntriesIn( + ImmutableMap.of("serviceName", SERVICE_NAME, "rpcName", RPC_NAME)); + assertThat(mdcPropertyMap).containsKey("request.headers"); + assertThat(mdcPropertyMap.get("request.headers")).startsWith("{\"x-goog-api-"); + + assertThat(mdcPropertyMap).containsKey("request.payload"); + assertThat(mdcPropertyMap.get("request.payload")) + .startsWith("{\"content\":\"echo?\",\"requestId\":"); + + // logging event for response + ILoggingEvent loggingEvent2 = testAppender.events.get(1); + assertThat(loggingEvent2.getMessage()).isEqualTo(RECEIVING_RESPONSE_MESSAGE); + assertThat(loggingEvent2.getLevel()).isEqualTo(Level.DEBUG); + Map responseMdcPropertyMap = loggingEvent2.getMDCPropertyMap(); + assertThat(responseMdcPropertyMap) + .containsAtLeastEntriesIn( + ImmutableMap.of( + "serviceName", SERVICE_NAME, "rpcName", RPC_NAME, "response.status", "OK")); + assertThat(responseMdcPropertyMap).containsKey("response.payload"); + assertThat(responseMdcPropertyMap).containsKey("response.headers"); + + testAppender.stop(); + } + + @Test + void testGrpc_receiveContent_logInfo() { + TestAppender testAppender = setupTestLogger(GrpcLoggingInterceptor.class, Level.INFO); + assertThat(echoGrpc(ECHO_STRING)).isEqualTo(ECHO_STRING); + + assertThat(testAppender.events.size()).isEqualTo(2); + // logging event for request + ILoggingEvent loggingEvent1 = testAppender.events.get(0); + assertThat(loggingEvent1.getMessage()).isEqualTo(SENDING_REQUEST_MESSAGE); + assertThat(loggingEvent1.getLevel()).isEqualTo(Level.INFO); + Map mdcPropertyMap = loggingEvent1.getMDCPropertyMap(); + assertThat(mdcPropertyMap) + .containsExactlyEntriesIn( + ImmutableMap.of("serviceName", SERVICE_NAME, "rpcName", RPC_NAME)); + + // logging event for response + ILoggingEvent loggingEvent2 = testAppender.events.get(1); + assertThat(loggingEvent2.getMessage()).isEqualTo(RECEIVING_RESPONSE_MESSAGE); + assertThat(loggingEvent2.getLevel()).isEqualTo(Level.INFO); + Map responseMdcPropertyMap = loggingEvent2.getMDCPropertyMap(); + assertThat(responseMdcPropertyMap) + .containsExactlyEntriesIn( + ImmutableMap.of( + "serviceName", SERVICE_NAME, "rpcName", RPC_NAME, "response.status", "OK")); + + testAppender.stop(); + } + + @Test + void testHttpJson_receiveContent_logDebug() { + TestAppender testAppender = setupTestLogger(HttpJsonLoggingInterceptor.class, Level.DEBUG); + assertThat(echoHttpJson(ECHO_STRING)).isEqualTo(ECHO_STRING); + assertThat(testAppender.events.size()).isEqualTo(2); + // logging event for request + ILoggingEvent loggingEvent1 = testAppender.events.get(0); + assertThat(loggingEvent1.getMessage()).isEqualTo(SENDING_REQUEST_MESSAGE); + assertThat(loggingEvent1.getLevel()).isEqualTo(Level.DEBUG); + Map mdcPropertyMap = loggingEvent1.getMDCPropertyMap(); + assertThat(mdcPropertyMap).containsEntry("rpcName", RPC_NAME); + assertThat(mdcPropertyMap).containsEntry("request.url", ENDPOINT); + assertThat(mdcPropertyMap).containsKey("request.headers"); + assertThat(mdcPropertyMap.get("request.headers")).startsWith("{\"x-goog-api-"); + assertThat(mdcPropertyMap).containsKey("request.payload"); + assertThat(mdcPropertyMap.get("request.payload")) + .startsWith("{\"content\":\"echo?\",\"requestId\":"); + + // logging event for response + ILoggingEvent loggingEvent2 = testAppender.events.get(1); + assertThat(loggingEvent2.getMessage()).isEqualTo(RECEIVING_RESPONSE_MESSAGE); + assertThat(loggingEvent2.getLevel()).isEqualTo(Level.DEBUG); + Map responseMdcPropertyMap = loggingEvent2.getMDCPropertyMap(); + assertThat(responseMdcPropertyMap) + .containsAtLeastEntriesIn(ImmutableMap.of("rpcName", RPC_NAME, "response.status", "200")); + assertThat(responseMdcPropertyMap).containsKey("response.payload"); + assertThat(responseMdcPropertyMap).containsKey("response.headers"); + testAppender.stop(); + } + + @Test + void testHttpJson_receiveContent_logInfo() { + TestAppender testAppender = setupTestLogger(HttpJsonLoggingInterceptor.class, Level.INFO); + assertThat(echoHttpJson(ECHO_STRING)).isEqualTo(ECHO_STRING); + assertThat(testAppender.events.size()).isEqualTo(2); + // logging event for request + ILoggingEvent loggingEvent1 = testAppender.events.get(0); + assertThat(loggingEvent1.getMessage()).isEqualTo(SENDING_REQUEST_MESSAGE); + assertThat(loggingEvent1.getLevel()).isEqualTo(Level.INFO); + Map mdcPropertyMap = loggingEvent1.getMDCPropertyMap(); + assertThat(mdcPropertyMap) + .containsExactlyEntriesIn( + ImmutableMap.of( + "rpcName", RPC_NAME, + "request.url", ENDPOINT)); + + // logging event for response + ILoggingEvent loggingEvent2 = testAppender.events.get(1); + assertThat(loggingEvent2.getMessage()).isEqualTo(RECEIVING_RESPONSE_MESSAGE); + assertThat(loggingEvent2.getLevel()).isEqualTo(Level.INFO); + Map responseMdcPropertyMap = loggingEvent2.getMDCPropertyMap(); + assertThat(responseMdcPropertyMap) + .containsExactlyEntriesIn(ImmutableMap.of("rpcName", RPC_NAME, "response.status", "200")); + testAppender.stop(); + } + + private String echoGrpc(String value) { + EchoResponse response = grpcClient.echo(EchoRequest.newBuilder().setContent(value).build()); + return response.getContent(); + } + + private String echoHttpJson(String value) { + EchoResponse response = httpjsonClient.echo(EchoRequest.newBuilder().setContent(value).build()); + return response.getContent(); + } +} diff --git a/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITLoggingDisabled.java b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITLoggingDisabled.java new file mode 100644 index 0000000000..16c6acb0a6 --- /dev/null +++ b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/ITLoggingDisabled.java @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 com.google.showcase.v1beta1.it.logging; + +import static com.google.common.truth.Truth.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.google.api.gax.grpc.GrpcLoggingInterceptor; +import com.google.showcase.v1beta1.EchoClient; +import com.google.showcase.v1beta1.EchoRequest; +import com.google.showcase.v1beta1.EchoResponse; +import com.google.showcase.v1beta1.it.util.TestClientInitializer; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +// mvn verify -P '!showcase,enable-integration-tests,loggingTestBase,disabledLogging' +public class ITLoggingDisabled { + + private static EchoClient grpcClient; + private static final String ECHO_STRING = "echo?"; + + private TestAppender setupTestLogger(Class clazz) { + TestAppender testAppender = new TestAppender(); + testAppender.start(); + ListAppender listAppender = new ListAppender<>(); + listAppender.start(); + org.slf4j.Logger logger = LoggerFactory.getLogger(clazz); + ((ch.qos.logback.classic.Logger) logger).setLevel(Level.DEBUG); + ((ch.qos.logback.classic.Logger) logger).addAppender(testAppender); + return testAppender; + } + + @BeforeAll + static void createClients() throws Exception { + // Create gRPC Echo Client + grpcClient = TestClientInitializer.createGrpcEchoClient(); + } + + @AfterAll + static void destroyClients() throws InterruptedException { + grpcClient.close(); + + grpcClient.awaitTermination(TestClientInitializer.AWAIT_TERMINATION_SECONDS, TimeUnit.SECONDS); + } + + // only run when GOOGLE_SDK_JAVA_LOGGING!=true + @Test + void testloggingDisabled() { + TestAppender testAppender = setupTestLogger(GrpcLoggingInterceptor.class); + assertThat(echoGrpc(ECHO_STRING)).isEqualTo(ECHO_STRING); + + assertThat(testAppender.events.size()).isEqualTo(0); + } + + private String echoGrpc(String value) { + EchoResponse response = grpcClient.echo(EchoRequest.newBuilder().setContent(value).build()); + return response.getContent(); + } +} diff --git a/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/TestAppender.java b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/TestAppender.java new file mode 100644 index 0000000000..769ad9a740 --- /dev/null +++ b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/logging/TestAppender.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 com.google.showcase.v1beta1.it.logging; + + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import java.util.ArrayList; +import java.util.List; + +/** Logback appender used to set up tests. */ +public class TestAppender extends AppenderBase { + public List events = new ArrayList<>(); + + @Override + protected void append(ILoggingEvent eventObject) { + // triggering Logback to capture the current MDC context and store it with the log event + // the default ListAppender does not capture MDC contents + eventObject.getMDCPropertyMap(); + + events.add(eventObject); + } + public void clearEvents() { + events.clear(); + } +} + diff --git a/showcase/pom.xml b/showcase/pom.xml index 436d52d359..256034da48 100644 --- a/showcase/pom.xml +++ b/showcase/pom.xml @@ -74,6 +74,16 @@ native + + org.apache.maven.plugins + maven-compiler-plugin + + + + **/com/google/showcase/v1beta1/it/logging/*.java + + + org.graalvm.buildtools native-maven-plugin @@ -103,6 +113,51 @@ true + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + **/com/google/showcase/v1beta1/it/logging/*.java + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + sponge_log + ${skipUnitTests} + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + + org.apache.maven.surefire + surefire-junit-platform + ${surefire.version} + + + + + org.codehaus.mojo + flatten-maven-plugin + + + + + + + loggingTestBase + @@ -138,6 +193,16 @@ enable-golden-tests + + org.apache.maven.plugins + maven-compiler-plugin + + + + **/com/google/showcase/v1beta1/it/logging/*.java + + + org.codehaus.mojo exec-maven-plugin