diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 82cdfc44ea..0acf6c46c5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -255,6 +255,15 @@ jobs: -P enable-integration-tests \ --batch-mode \ --no-transfer-progress + # The `envVarLoggingTest` profile runs tests that require an environment variable + - name: Showcase integration tests - Logging + run: | + mvn verify -P '!showcase,enable-integration-tests,envVarLoggingTest' \ + --batch-mode \ + --no-transfer-progress + # Set the Env Var for this step only + env: + GOOGLE_SDK_JAVA_LOGGING: true showcase-native: runs-on: ubuntu-22.04 diff --git a/gapic-generator-java-pom-parent/pom.xml b/gapic-generator-java-pom-parent/pom.xml index b593af113b..d5fa5a552d 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.3 + 2.0.16 diff --git a/gax-java/dependencies.properties b/gax-java/dependencies.properties index 12224344a4..e1a3acdcc1 100644 --- a/gax-java/dependencies.properties +++ b/gax-java/dependencies.properties @@ -76,6 +76,7 @@ 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:graal-sdk:22.3.5 +maven.org_slf4j_slf4j_api=org.slf4j:slf4j-api:2.0.16 # Testing maven artifacts maven.junit_junit=junit:junit:4.13.2 @@ -88,3 +89,5 @@ maven.net_bytebuddy_byte_buddy=net.bytebuddy:byte-buddy:1.15.10 maven.org_objenesis_objenesis=org.objenesis:objenesis:2.6 maven.org_junit_jupiter_junit_jupiter_api=org.junit.jupiter:junit-jupiter-api:5.11.3 maven.org_junit_jupiter_junit_jupiter_params=org.junit.jupiter:junit-jupiter-params:5.11.3 +maven.ch_qos_logback_logback_classic=ch.qos.logback:logback-classic:1.3.14 +maven.ch_qos_logback_logback_core=ch.qos.logback:logback-core:1.3.14 \ No newline at end of file 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..2a9953a91b --- /dev/null +++ b/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcLoggingInterceptor.java @@ -0,0 +1,197 @@ +/* + * Copyright 2024 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 com.google.api.core.InternalApi; +import com.google.api.gax.logging.LogData; +import com.google.api.gax.logging.LoggingUtils; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +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.Map; +import org.slf4j.Logger; +import org.slf4j.event.Level; + +@InternalApi +public class GrpcLoggingInterceptor implements ClientInterceptor { + + private static final Logger LOGGER = LoggingUtils.getLogger(GrpcLoggingInterceptor.class); + private static final Gson GSON = new Gson(); + + 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) { + logRequestInfo(method, logDataBuilder, LOGGER); + recordRequestHeaders(headers, logDataBuilder); + SimpleForwardingClientCallListener responseLoggingListener = + new SimpleForwardingClientCallListener(responseListener) { + @Override + public void onHeaders(Metadata headers) { + recordResponseHeaders(headers, logDataBuilder); + super.onHeaders(headers); + } + + @Override + public void onMessage(RespT message) { + recordResponsePayload(message, logDataBuilder); + super.onMessage(message); + } + + @Override + public void onClose(Status status, Metadata trailers) { + logResponse(status, logDataBuilder, LOGGER); + super.onClose(status, trailers); + } + }; + currentListener = responseLoggingListener; + super.start(responseLoggingListener, headers); + } + + @Override + public void sendMessage(ReqT message) { + logRequestDetails(message, logDataBuilder); + super.sendMessage(message); + } + }; + } + + // Helper methods for logging + // some duplications with http equivalent to avoid exposing as public method for now + void logRequestInfo( + MethodDescriptor method, LogData.Builder logDataBuilder, Logger logger) { + try { + if (logger.isInfoEnabled()) { + logDataBuilder.serviceName(method.getServiceName()).rpcName(method.getFullMethodName()); + + if (!logger.isDebugEnabled()) { + LoggingUtils.logWithMDC( + logger, Level.INFO, logDataBuilder.build().toMapRequest(), "Sending gRPC request"); + } + } + } catch (Exception e) { + logger.error("Error logging request info (and headers)", e); + } + } + + private void recordRequestHeaders(Metadata headers, LogData.Builder logDataBuilder) { + try { + if (LOGGER.isDebugEnabled()) { + JsonObject requestHeaders = mapHeadersToJsonObject(headers); + logDataBuilder.requestHeaders(GSON.toJson(requestHeaders)); + } + } catch (Exception e) { + LOGGER.error("Error recording request headers", e); + } + } + + void recordResponseHeaders(Metadata headers, LogData.Builder logDataBuilder) { + try { + if (LOGGER.isDebugEnabled()) { + JsonObject responseHeaders = mapHeadersToJsonObject(headers); + logDataBuilder.responseHeaders(GSON.toJson(responseHeaders)); + } + } catch (Exception e) { + LOGGER.error("Error recording response headers", e); + } + } + + void recordResponsePayload(RespT message, LogData.Builder logDataBuilder) { + try { + if (LOGGER.isDebugEnabled()) { + logDataBuilder.responsePayload(GSON.toJsonTree(message)); + } + } catch (Exception e) { + LOGGER.error("Error recording response payload", e); + } + } + + void logResponse(Status status, LogData.Builder logDataBuilder, Logger logger) { + try { + if (logger.isInfoEnabled()) { + logDataBuilder.responseStatus(status.getCode().toString()); + } + if (logger.isInfoEnabled() && !logger.isDebugEnabled()) { + Map responseData = logDataBuilder.build().toMapResponse(); + LoggingUtils.logWithMDC(logger, Level.INFO, responseData, "Received Grpc response"); + } + if (logger.isDebugEnabled()) { + Map responsedDetailsMap = logDataBuilder.build().toMapResponse(); + LoggingUtils.logWithMDC(logger, Level.DEBUG, responsedDetailsMap, "Received Grpc response"); + } + } catch (Exception e) { + logger.error("Error logging request response", e); + } + } + + void logRequestDetails(RespT message, LogData.Builder logDataBuilder) { + try { + if (LOGGER.isDebugEnabled()) { + logDataBuilder.requestPayload(GSON.toJsonTree(message)); + Map requestDetailsMap = logDataBuilder.build().toMapRequest(); + LoggingUtils.logWithMDC( + LOGGER, Level.DEBUG, requestDetailsMap, "Sending gRPC request: request payload"); + } + } catch (Exception e) { + LOGGER.error("Error logging request details", e); + } + } + + private static JsonObject mapHeadersToJsonObject(Metadata headers) { + JsonObject jsonHeaders = new JsonObject(); + headers + .keys() + .forEach( + key -> { + Metadata.Key metadataKey = + Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + String headerValue = headers.get(metadataKey); + jsonHeaders.addProperty(key, headerValue); + }); + return jsonHeaders; + } +} 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 ae4d7f9e51..1f23868cd5 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 @@ -467,6 +467,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..e15a69db2d --- /dev/null +++ b/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcLoggingInterceptorTest.java @@ -0,0 +1,157 @@ +/* + * Copyright 2024 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.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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 ch.qos.logback.classic.Level; +import com.google.api.gax.grpc.testing.FakeMethodDescriptor; +import com.google.api.gax.logging.LogData; +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.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class GrpcLoggingInterceptorTest { + @Mock private Channel channel; + + @Mock private ClientCall call; + + private static final MethodDescriptor method = FakeMethodDescriptor.create(); + + private static final Logger LOGGER = LoggerFactory.getLogger(GrpcLoggingInterceptorTest.class); + /** Sets up mocks. */ + @BeforeEach + void setUp() { + MockitoAnnotations.initMocks(this); + when(channel.newCall(Mockito.>any(), any(CallOptions.class))) + .thenReturn(call); + } + + @Test + void testInterceptor_basic() { + 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() { + 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()); + + // --- Verify that the response listener's methods were called --- + verify(interceptor).recordResponseHeaders(eq(responseHeaders), any(LogData.Builder.class)); + verify(interceptor).recordResponsePayload(any(), any(LogData.Builder.class)); + verify(interceptor).logResponse(eq(status), any(LogData.Builder.class), any(Logger.class)); + } + + @Test + void testLogRequestInfo() { + + TestAppender testAppender = setupTestLogger(GrpcLoggingInterceptorTest.class); + GrpcLoggingInterceptor interceptor = new GrpcLoggingInterceptor(); + interceptor.logRequestInfo(method, LogData.builder(), LOGGER); + + Assertions.assertEquals(1, testAppender.events.size()); + assertEquals(Level.INFO, testAppender.events.get(0).getLevel()); + assertEquals( + "{\"serviceName\":\"FakeClient\",\"message\":\"Sending gRPC request\",\"rpcName\":\"FakeClient/fake-method\"}", + testAppender.events.get(0).getMessage()); + testAppender.stop(); + } + + @Test + void TestLogResponseInfo() { + TestAppender testAppender = setupTestLogger(GrpcLoggingInterceptorTest.class); + GrpcLoggingInterceptor interceptor = new GrpcLoggingInterceptor(); + interceptor.logResponse(Status.CANCELLED, LogData.builder(), LOGGER); + + Assertions.assertEquals(1, testAppender.events.size()); + assertEquals(Level.INFO, testAppender.events.get(0).getLevel()); + assertEquals( + "{\"response.status\":\"CANCELLED\",\"message\":\"Received Grpc response\"}", + testAppender.events.get(0).getMessage()); + testAppender.stop(); + } + + private TestAppender setupTestLogger(Class clazz) { + TestAppender testAppender = new TestAppender(); + testAppender.start(); + Logger logger = LoggerFactory.getLogger(clazz); + ((ch.qos.logback.classic.Logger) logger).addAppender(testAppender); + return testAppender; + } +} diff --git a/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/TestAppender.java b/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/TestAppender.java new file mode 100644 index 0000000000..0aa34ebb5a --- /dev/null +++ b/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/TestAppender.java @@ -0,0 +1,52 @@ +/* + * Copyright 2024 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 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 + eventObject.getMDCPropertyMap(); + events.add(eventObject); + } + + public void clearEvents() { + events.clear(); + } +} diff --git a/gax-java/gax-grpc/src/test/resources/logback-test.xml b/gax-java/gax-grpc/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..802d0e18a7 --- /dev/null +++ b/gax-java/gax-grpc/src/test/resources/logback-test.xml @@ -0,0 +1,6 @@ + + + + + + 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..a465ab471a --- /dev/null +++ b/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonLoggingInterceptor.java @@ -0,0 +1,195 @@ +/* + * Copyright 2024 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 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.LoggingUtils; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.event.Level; + +@InternalApi +public class HttpJsonLoggingInterceptor implements HttpJsonClientInterceptor { + + private static final Logger LOGGER = LoggingUtils.getLogger(HttpJsonLoggingInterceptor.class); + private static final Gson GSON = new Gson(); + + @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) { + + logRequestInfo(method, endpoint, logDataBuilder, LOGGER); + recordRequestHeaders(headers, logDataBuilder); + + Listener forwardingResponseListener = + new SimpleForwardingHttpJsonClientCallListener(responseListener) { + + @Override + public void onHeaders(HttpJsonMetadata responseHeaders) { + recordResponseHeaders(responseHeaders, logDataBuilder); + super.onHeaders(responseHeaders); + } + + @Override + public void onMessage(RespT message) { + recordResponsePayload(message, logDataBuilder); + super.onMessage(message); + } + + @Override + public void onClose(int statusCode, HttpJsonMetadata trailers) { + logResponse(statusCode, logDataBuilder, LOGGER); + super.onClose(statusCode, trailers); + } + }; + super.start(forwardingResponseListener, headers); + } + + @Override + public void sendMessage(ReqT message) { + logRequestDetails(message, logDataBuilder); + super.sendMessage(message); + } + }; + } + + // Helper methods for logging, + // some duplications with grpc equivalent to avoid exposing as public method + void logRequestInfo( + ApiMethodDescriptor method, + String endpoint, + LogData.Builder logDataBuilder, + Logger logger) { + try { + if (logger.isInfoEnabled()) { + logDataBuilder + .rpcName(method.getFullMethodName()) + .httpMethod(method.getHttpMethod()) + .httpUrl(endpoint); + + if (!logger.isDebugEnabled()) { + LoggingUtils.logWithMDC( + logger, Level.INFO, logDataBuilder.build().toMapRequest(), "Sending HTTP request"); + } + } + } catch (Exception e) { + logger.error("Error logging request info (and headers)", e); + } + } + + private void recordRequestHeaders(HttpJsonMetadata headers, LogData.Builder logDataBuilder) { + try { + if (LOGGER.isDebugEnabled()) { + JsonObject requestHeaders = new JsonObject(); + headers + .getHeaders() + .forEach((key, value) -> requestHeaders.addProperty(key, value.toString())); + logDataBuilder.requestHeaders(GSON.toJson(requestHeaders)); + } + } catch (Exception e) { + LOGGER.error("Error recording request headers", e); + } + } + + private void recordResponseHeaders( + HttpJsonMetadata responseHeaders, LogData.Builder logDataBuilder) { + try { + if (LOGGER.isDebugEnabled()) { + + Map> map = new HashMap<>(); + responseHeaders.getHeaders().forEach((key, value) -> map.put(key, (List) value)); + logDataBuilder.responseHeaders(GSON.toJson(map)); + } + } catch (Exception e) { + LOGGER.error("Error recording response headers", e); + } + } + + private void recordResponsePayload(RespT message, LogData.Builder logDataBuilder) { + try { + if (LOGGER.isDebugEnabled()) { + logDataBuilder.responsePayload(GSON.toJsonTree(message)); + } + } catch (Exception e) { + LOGGER.error("Error recording response payload", e); + } + } + + void logResponse(int statusCode, LogData.Builder logDataBuilder, Logger logger) { + try { + if (logger.isInfoEnabled()) { + logDataBuilder.responseStatus(String.valueOf(statusCode)); + } + if (logger.isInfoEnabled() && !logger.isDebugEnabled()) { + Map responseData = logDataBuilder.build().toMapResponse(); + LoggingUtils.logWithMDC(logger, Level.INFO, responseData, "Received HTTP response"); + } + if (logger.isDebugEnabled()) { + Map responsedDetailsMap = logDataBuilder.build().toMapResponse(); + LoggingUtils.logWithMDC(logger, Level.DEBUG, responsedDetailsMap, "Received HTTP response"); + } + } catch (Exception e) { + logger.error("Error logging request response", e); + } + } + + private void logRequestDetails(RespT message, LogData.Builder logDataBuilder) { + try { + if (LOGGER.isDebugEnabled()) { + logDataBuilder.requestPayload(GSON.toJsonTree(message)); + Map requestDetailsMap = logDataBuilder.build().toMapRequest(); + LoggingUtils.logWithMDC( + LOGGER, Level.DEBUG, requestDetailsMap, "Sending HTTP request: request payload"); + } + } catch (Exception e) { + LOGGER.error("Error logging request details", e); + } + } +} 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..0966407fba --- /dev/null +++ b/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonLoggingInterceptorTest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2024 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.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; + +import ch.qos.logback.classic.Level; +import com.google.api.gax.httpjson.ApiMethodDescriptor.MethodType; +import com.google.api.gax.logging.LogData; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class HttpJsonLoggingInterceptorTest { + + private static final Logger LOGGER = + LoggerFactory.getLogger(HttpJsonLoggingInterceptorTest.class); + + @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(); + + @Test + void testLogRequestInfo() { + + TestAppender testAppender = setupTestLogger(HttpJsonLoggingInterceptorTest.class); + HttpJsonLoggingInterceptor interceptor = new HttpJsonLoggingInterceptor(); + interceptor.logRequestInfo(method, "fake.endpoint", LogData.builder(), LOGGER); + + Assertions.assertEquals(1, testAppender.events.size()); + assertEquals(Level.INFO, testAppender.events.get(0).getLevel()); + assertEquals( + "{\"request.url\":\"fake.endpoint\",\"message\":\"Sending HTTP request\",\"rpcName\":\"FakeClient/fake-method\"}", + testAppender.events.get(0).getMessage()); + testAppender.stop(); + } + + @Test + void testLogResponseInfo() { + + TestAppender testAppender = setupTestLogger(HttpJsonLoggingInterceptorTest.class); + HttpJsonLoggingInterceptor interceptor = new HttpJsonLoggingInterceptor(); + interceptor.logResponse(200, LogData.builder(), LOGGER); + + Assertions.assertEquals(1, testAppender.events.size()); + assertEquals(Level.INFO, testAppender.events.get(0).getLevel()); + assertEquals( + "{\"response.status\":\"200\",\"message\":\"Received HTTP response\"}", + testAppender.events.get(0).getMessage()); + testAppender.stop(); + } + + private TestAppender setupTestLogger(Class clazz) { + TestAppender testAppender = new TestAppender(); + testAppender.start(); + Logger logger = LoggerFactory.getLogger(clazz); + ((ch.qos.logback.classic.Logger) logger).addAppender(testAppender); + return testAppender; + } +} 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-httpjson/src/test/java/com/google/api/gax/httpjson/TestAppender.java b/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/TestAppender.java new file mode 100644 index 0000000000..c89ebfb3b7 --- /dev/null +++ b/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/TestAppender.java @@ -0,0 +1,52 @@ +/* + * Copyright 2024 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 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 + eventObject.getMDCPropertyMap(); + events.add(eventObject); + } + + public void clearEvents() { + events.clear(); + } +} diff --git a/gax-java/gax-httpjson/src/test/resources/logback-test.xml b/gax-java/gax-httpjson/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..8a6dc9b23d --- /dev/null +++ b/gax-java/gax-httpjson/src/test/resources/logback-test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/gax-java/gax/pom.xml b/gax-java/gax/pom.xml index 4a690e1094..bfec74bab1 100644 --- a/gax-java/gax/pom.xml +++ b/gax-java/gax/pom.xml @@ -69,6 +69,11 @@ opentelemetry-api true + + + org.slf4j + slf4j-api + 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..57751dc6d0 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/logging/LogData.java @@ -0,0 +1,155 @@ +/* + * Copyright 2024 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 com.google.gson.JsonElement; +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 String requestHeaders(); + + @Nullable + public abstract JsonElement requestPayload(); + + @Nullable + public abstract String responseStatus(); + + @Nullable + public abstract String responseHeaders(); + + @Nullable + public abstract JsonElement 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(String requestHeaders); + + public abstract Builder requestPayload(JsonElement requestPayload); + + public abstract Builder responseStatus(String responseStatus); + + public abstract Builder responseHeaders(String responseHeaders); + + public abstract Builder responsePayload(JsonElement 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 + // todo: error handling? + 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", gson.toJson(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", gson.toJson(responsePayload())); + } + return map; + } +} 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..c738426aa9 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/logging/LoggingUtils.java @@ -0,0 +1,121 @@ +/* + * Copyright 2024 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.api.gax.rpc.internal.EnvironmentProvider; +import com.google.api.gax.rpc.internal.SystemEnvironmentProvider; +import com.google.gson.Gson; +import java.util.Map; +import org.slf4j.ILoggerFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +@InternalApi +public class LoggingUtils { + + private static EnvironmentProvider environmentProvider = SystemEnvironmentProvider.getInstance(); + private static final Logger NO_OP_LOGGER = org.slf4j.helpers.NOPLogger.NOP_LOGGER; + private static boolean loggingEnabled = isLoggingEnabled(); + static final String GOOGLE_SDK_JAVA_LOGGING = "GOOGLE_SDK_JAVA_LOGGING"; + private static final Gson gson = new Gson(); + // expose this setter for testing purposes + static void setEnvironmentProvider(EnvironmentProvider provider) { + environmentProvider = provider; + // Recalculate LOGGING_ENABLED after setting the new provider + loggingEnabled = isLoggingEnabled(); + } + + private LoggingUtils() {} + + public 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) { + return factoryProvider.getLoggerFactory().getLogger(clazz.getName()); + } else { + // use SLF4j's NOP logger regardless of bindings + return NO_OP_LOGGER; + } + } + + public static void logWithMDC( + Logger logger, org.slf4j.event.Level level, Map contextMap, String message) { + if (!contextMap.isEmpty()) { + contextMap.forEach(MDC::put); + contextMap.put("message", message); + message = gson.toJson(contextMap); + } + 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.info(message); + // Default to INFO level + } + if (!contextMap.isEmpty()) { + MDC.clear(); + } + } + + static boolean isLoggingEnabled() { + String enableLogging = environmentProvider.getenv(GOOGLE_SDK_JAVA_LOGGING); + return "true".equalsIgnoreCase(enableLogging); + } + + interface LoggerFactoryProvider { + ILoggerFactory getLoggerFactory(); + } + + static class DefaultLoggerFactoryProvider implements LoggerFactoryProvider { + @Override + public ILoggerFactory getLoggerFactory() { + return LoggerFactory.getILoggerFactory(); + } + } +} diff --git a/gax-java/gax/src/main/java/com/google/api/gax/rpc/internal/SystemEnvironmentProvider.java b/gax-java/gax/src/main/java/com/google/api/gax/rpc/internal/SystemEnvironmentProvider.java new file mode 100644 index 0000000000..29d45ba3c5 --- /dev/null +++ b/gax-java/gax/src/main/java/com/google/api/gax/rpc/internal/SystemEnvironmentProvider.java @@ -0,0 +1,50 @@ +/* + * Copyright 2024 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.rpc.internal; + +import com.google.api.core.InternalApi; + +/** Represents the default system environment provider. */ +@InternalApi +public class SystemEnvironmentProvider implements EnvironmentProvider { + static final SystemEnvironmentProvider INSTANCE = new SystemEnvironmentProvider(); + + private SystemEnvironmentProvider() {} + + @Override + public String getenv(String name) { + return System.getenv(name); + } + + public static SystemEnvironmentProvider getInstance() { + return INSTANCE; + } +} 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..d19c74591a --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/logging/LogDataTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2024 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 com.google.gson.JsonParser; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class LogDataTest { + + @Test + void toMapResponse_correctlyConvertsData() { + LogData logData = + LogData.builder() + .serviceName("MyService") + .rpcName("myMethod") + .requestHeaders("fake header") + .requestId("abcd") + .responsePayload(JsonParser.parseString("{\"key\": \"value\"}")) + .build(); + + Map expectedMap = + ImmutableMap.of( + "serviceName", "MyService", + "rpcName", "myMethod", + "response.payload", "{\"key\":\"value\"}", + "requestId", "abcd"); + + assertThat(logData.toMapResponse()).isEqualTo(expectedMap); + } + + @Test + void toMapRequest_correctlyConvertsData() { + LogData logData = + LogData.builder() + .serviceName("MyService") + .rpcName("myMethod") + .requestHeaders("fake header") + .requestId("abcd") + .httpUrl("url") + .responsePayload(JsonParser.parseString("{\"key\": \"value\"}")) + .build(); + + Map expectedMap = + ImmutableMap.of( + "serviceName", "MyService", + "rpcName", "myMethod", + "request.headers", "fake 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/LoggingUtilsTest.java b/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggingUtilsTest.java new file mode 100644 index 0000000000..d53bd0bc02 --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/logging/LoggingUtilsTest.java @@ -0,0 +1,145 @@ +/* + * Copyright 2024 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 ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import com.google.api.gax.logging.LoggingUtils.LoggerFactoryProvider; +import com.google.api.gax.rpc.internal.EnvironmentProvider; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.slf4j.ILoggerFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.helpers.NOPLogger; + +class LoggingUtilsTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(LoggingUtilsTest.class); + private EnvironmentProvider envProvider = Mockito.mock(EnvironmentProvider.class); + + @AfterEach + void tearDown() { + LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + loggerContext.getLogger(Logger.ROOT_LOGGER_NAME).detachAppender("CONSOLE"); + } + + @Test + void testGetLogger_loggingEnabled_slf4jBindingPresent() { + Mockito.when(envProvider.getenv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING)).thenReturn("true"); + LoggingUtils.setEnvironmentProvider(envProvider); + Logger logger = LoggingUtils.getLogger(LoggingUtilsTest.class); + Assertions.assertInstanceOf(Logger.class, logger); + Assertions.assertNotEquals(NOPLogger.class, logger.getClass()); + } + + @Test + void testGetLogger_loggingDisabled() { + Mockito.when(envProvider.getenv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING)).thenReturn("false"); + LoggingUtils.setEnvironmentProvider(envProvider); + + Logger logger = LoggingUtils.getLogger(LoggingUtilsTest.class); + Assertions.assertEquals(NOPLogger.class, logger.getClass()); + Assertions.assertFalse(logger.isInfoEnabled()); + Assertions.assertFalse(logger.isDebugEnabled()); + } + + @Test + void testGetLogger_loggingEnabled_noBinding() { + Mockito.when(envProvider.getenv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING)).thenReturn("true"); + LoggingUtils.setEnvironmentProvider(envProvider); + // Create a mock LoggerFactoryProvider + LoggerFactoryProvider mockLoggerFactoryProvider = mock(LoggerFactoryProvider.class); + ILoggerFactory mockLoggerFactory = mock(ILoggerFactory.class); + when(mockLoggerFactoryProvider.getLoggerFactory()).thenReturn(mockLoggerFactory); + when(mockLoggerFactory.getLogger(anyString())) + .thenReturn(org.slf4j.helpers.NOPLogger.NOP_LOGGER); + + // Use the mock LoggerFactoryProvider in getLogger() + Logger logger = LoggingUtils.getLogger(LoggingUtilsTest.class, mockLoggerFactoryProvider); + + // Assert that the returned logger is a NOPLogger + Assertions.assertInstanceOf(NOPLogger.class, logger); + } + + @Test + void testIsLoggingEnabled_true() { + Mockito.when(envProvider.getenv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING)).thenReturn("true"); + LoggingUtils.setEnvironmentProvider(envProvider); + Assertions.assertTrue(LoggingUtils.isLoggingEnabled()); + Mockito.when(envProvider.getenv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING)).thenReturn("TRUE"); + LoggingUtils.setEnvironmentProvider(envProvider); + Assertions.assertTrue(LoggingUtils.isLoggingEnabled()); + Mockito.when(envProvider.getenv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING)).thenReturn("True"); + LoggingUtils.setEnvironmentProvider(envProvider); + Assertions.assertTrue(LoggingUtils.isLoggingEnabled()); + } + + @Test + void testIsLoggingEnabled_defaultToFalse() { + LoggingUtils.setEnvironmentProvider(envProvider); + Assertions.assertFalse(LoggingUtils.isLoggingEnabled()); + } + + private TestAppender setupTestLogger(Class clazz) { + TestAppender testAppender = new TestAppender(); + testAppender.start(); + Logger logger = LoggerFactory.getLogger(clazz); + ((ch.qos.logback.classic.Logger) logger).addAppender(testAppender); + return testAppender; + } + + @Test + void testLogWithMDC_slf4jLogger() { + TestAppender testAppender = setupTestLogger(LoggingUtilsTest.class); + Map contextMap = new HashMap<>(); + contextMap.put("key", "value"); + LoggingUtils.logWithMDC(LOGGER, org.slf4j.event.Level.DEBUG, contextMap, "test message"); + + Assertions.assertEquals(1, testAppender.events.size()); + Assertions.assertEquals( + "{\"message\":\"test message\",\"key\":\"value\"}", + testAppender.events.get(0).getFormattedMessage()); + + // Verify MDC content + ILoggingEvent event = testAppender.events.get(0); + Assertions.assertEquals("value", event.getMDCPropertyMap().get("key")); + testAppender.stop(); + } +} diff --git a/gax-java/gax/src/test/java/com/google/api/gax/logging/TestAppender.java b/gax-java/gax/src/test/java/com/google/api/gax/logging/TestAppender.java new file mode 100644 index 0000000000..1206553ee6 --- /dev/null +++ b/gax-java/gax/src/test/java/com/google/api/gax/logging/TestAppender.java @@ -0,0 +1,52 @@ +/* + * Copyright 2024 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 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 + eventObject.getMDCPropertyMap(); + events.add(eventObject); + } + + public void clearEvents() { + events.clear(); + } +} diff --git a/gax-java/pom.xml b/gax-java/pom.xml index fd93b003c2..1b27682da0 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 @@ -205,6 +210,20 @@ test + + + ch.qos.logback + logback-classic + + 1.3.14 + test + + + ch.qos.logback + logback-core + 1.3.14 + test + diff --git a/showcase/gapic-showcase/pom.xml b/showcase/gapic-showcase/pom.xml index 2549eb199f..583ab17219 100644 --- a/showcase/gapic-showcase/pom.xml +++ b/showcase/gapic-showcase/pom.xml @@ -215,5 +215,20 @@ opentelemetry-sdk-testing test + + + + ch.qos.logback + logback-classic + + 1.3.14 + test + + + ch.qos.logback + logback-core + 1.3.14 + test + diff --git a/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITLogging.java b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITLogging.java new file mode 100644 index 0000000000..51e7145e99 --- /dev/null +++ b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITLogging.java @@ -0,0 +1,109 @@ +/* + * Copyright 2024 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; + +import static com.google.common.truth.Truth.assertThat; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import com.google.api.gax.grpc.GrpcLoggingInterceptor; +import com.google.api.gax.httpjson.HttpJsonLoggingInterceptor; +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.TestAppender; +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; + +// This test needs to run with GOOGLE_SDK_JAVA_LOGGING=true +public class ITLogging { + private static EchoClient grpcClient; + + private static EchoClient httpjsonClient; + + private TestAppender setupTestLogger(Class clazz) { + TestAppender testAppender = new TestAppender(); + testAppender.start(); + org.slf4j.Logger logger = LoggerFactory.getLogger(clazz); + ((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); + assertThat(echoGrpc("grpc-echo?")).isEqualTo("grpc-echo?"); + assertThat(testAppender.events.size()).isEqualTo(2); + assertThat(testAppender.events.get(0).getMessage()) + .isEqualTo( + "{\"serviceName\":\"google.showcase.v1beta1.Echo\",\"message\":\"Sending gRPC request\",\"rpcName\":\"google.showcase.v1beta1.Echo/Echo\"}"); + assertThat(testAppender.events.get(0).getLevel()).isEqualTo(ch.qos.logback.classic.Level.INFO); + assertThat(testAppender.events.get(1).getMessage()) + .isEqualTo( + "{\"serviceName\":\"google.showcase.v1beta1.Echo\",\"response.status\":\"OK\",\"message\":\"Received Grpc response\",\"rpcName\":\"google.showcase.v1beta1.Echo/Echo\"}"); + testAppender.stop(); + } + + @Test + void testHttpJson_receiveContent_logDebug() { + TestAppender testAppender = setupTestLogger(HttpJsonLoggingInterceptor.class); + assertThat(echoHttpJson("http-echo?")).isEqualTo("http-echo?"); + assertThat(testAppender.events.size()).isEqualTo(2); + ILoggingEvent loggingEvent1 = testAppender.events.get(0); + assertThat(loggingEvent1.getMessage()) + .isEqualTo( + "{\"request.method\":\"POST\",\"request.url\":\"http://localhost:7469\",\"message\":\"Sending HTTP request\",\"rpcName\":\"google.showcase.v1beta1.Echo/Echo\"}"); + assertThat(loggingEvent1.getLevel()).isEqualTo(ch.qos.logback.classic.Level.INFO); + assertThat(loggingEvent1.getMDCPropertyMap()).hasSize(3); + assertThat(loggingEvent1.getMDCPropertyMap()).containsKey("rpcName"); + assertThat(testAppender.events.get(1).getMessage()) + .isEqualTo( + "{\"response.status\":\"200\",\"message\":\"Received HTTP response\",\"rpcName\":\"google.showcase.v1beta1.Echo/Echo\"}"); + 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/util/TestAppender.java b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/util/TestAppender.java new file mode 100644 index 0000000000..0032b09d9d --- /dev/null +++ b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/util/TestAppender.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024 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.util; + + +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 + eventObject.getMDCPropertyMap(); + + events.add(eventObject); + } + public void clearEvents() { + events.clear(); + } +} + diff --git a/showcase/gapic-showcase/src/test/resources/logback-test.xml b/showcase/gapic-showcase/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..66ed2652ea --- /dev/null +++ b/showcase/gapic-showcase/src/test/resources/logback-test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/showcase/gapic-showcase/src/test/resources/logging.properties b/showcase/gapic-showcase/src/test/resources/logging.properties new file mode 100644 index 0000000000..ce394f934c --- /dev/null +++ b/showcase/gapic-showcase/src/test/resources/logging.properties @@ -0,0 +1,5 @@ +handlers=java.util.logging.ConsoleHandler, com.google.api.gax.logging.JsonContextMapHandler + +java.util.logging.ConsoleHandler.level=ALL +java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter +com.google.api.gax.logging.JsonContextMapHandler.level=ALL diff --git a/showcase/pom.xml b/showcase/pom.xml index 42320f4da7..bb60c0ceca 100644 --- a/showcase/pom.xml +++ b/showcase/pom.xml @@ -95,6 +95,15 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + + **/ITLogging.java + + + @@ -117,6 +126,50 @@ org.apache.maven.plugins maven-failsafe-plugin + + + **/ITLogging.java + + + + + + + org.apache.maven.surefire + surefire-junit-platform + ${surefire.version} + + + + + org.codehaus.mojo + flatten-maven-plugin + + + + + + + envVarLoggingTest + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.0 + + sponge_log + ${skipUnitTests} + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + **/ITLogging.java + +