-
Notifications
You must be signed in to change notification settings - Fork 68
feat: add client side logging with slf4j #3403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 38 commits
59b5d73
d34ec88
b6417ae
0c169ad
9ced175
681f7d5
5c01ef2
6605ef3
ebbae15
543298d
aac66f2
487614c
cc3465b
211a3d1
2970210
32f8b7c
245c14d
8358497
97087b2
727a3e9
23bc111
bbe0700
3d7c7f9
b870d81
56a2870
83eedf0
d85c848
1919809
613b6c8
1169eaa
a6b5433
fb0966e
77939fe
04ef774
439e071
673c9fe
c2b607b
3df39fa
4190dc7
fefb436
7901d8a
20f9f5a
172701d
d25e742
a8d2f20
df97834
32dbd0e
37eb391
f95423b
8e01aa0
73210fa
b1386cb
ef26d30
0c466eb
7a23e2d
3821ec3
23f00e8
9f7d77c
86cc4d2
446910a
de01f37
e3862a8
427e820
6ff5479
82ead2b
b5b6e94
5db8884
56316d3
b7234e3
e7acafc
801cef6
cf8ecbc
19faf95
47f1212
420de1e
c222198
227857a
008048a
07888cb
20017cc
2d3ba3e
7c1ea76
7aa8333
d5c759d
1641f55
d86b7a0
f611c74
539031b
091ab38
b3b328e
6aece18
c3db3e7
a975fd5
b1af879
157921b
89da1b4
fad1865
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| /* | ||
| * 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 <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall( | ||
| MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) { | ||
|
|
||
| return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>( | ||
| next.newCall(method, callOptions)) { | ||
| LogData.Builder logDataBuilder = LogData.builder(); | ||
|
|
||
| @Override | ||
| public void start(Listener<RespT> responseListener, Metadata headers) { | ||
| logRequestInfo(method, logDataBuilder, logger); | ||
| recordRequestHeaders(headers, logDataBuilder); | ||
| SimpleForwardingClientCallListener<RespT> responseLoggingListener = | ||
| new SimpleForwardingClientCallListener<RespT>(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) { | ||
| try { | ||
| logResponse(status.getCode().toString(), logDataBuilder); | ||
| } finally { | ||
| logDataBuilder = null; // release resource | ||
| } | ||
| 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 | ||
zhumin8 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // some duplications with http equivalent to avoid exposing as public method for now | ||
| <ReqT, RespT> void logRequestInfo( | ||
| MethodDescriptor<ReqT, RespT> 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); | ||
zhumin8 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
|
|
||
| <RespT> 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(String statusCode, LogData.Builder logDataBuilder) { | ||
| try { | ||
|
|
||
| if (logger.isInfoEnabled()) { | ||
| logDataBuilder.responseStatus(statusCode); | ||
| } | ||
| if (logger.isInfoEnabled() && !logger.isDebugEnabled()) { | ||
| Map<String, String> responseData = logDataBuilder.build().toMapResponse(); | ||
| LoggingUtils.logWithMDC(logger, Level.INFO, responseData, "Received Grpc response"); | ||
| } | ||
| if (logger.isDebugEnabled()) { | ||
| Map<String, String> responsedDetailsMap = logDataBuilder.build().toMapResponse(); | ||
| LoggingUtils.logWithMDC(logger, Level.DEBUG, responsedDetailsMap, "Received Grpc response"); | ||
| } | ||
| } catch (Exception e) { | ||
| logger.error("Error logging request response", e); | ||
| } | ||
| } | ||
|
|
||
| <RespT> void logRequestDetails(RespT message, LogData.Builder logDataBuilder) { | ||
| try { | ||
| if (logger.isDebugEnabled()) { | ||
| logDataBuilder.requestPayload(gson.toJson(message)); | ||
| Map<String, String> 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<String> metadataKey = | ||
zhumin8 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); | ||
| String headerValue = headers.get(metadataKey); | ||
| jsonHeaders.addProperty(key, headerValue); | ||
| }); | ||
| return jsonHeaders; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -467,6 +467,7 @@ private ManagedChannel createSingleChannel() throws IOException { | |
| builder = | ||
| builder | ||
| .intercept(new GrpcChannelUUIDInterceptor()) | ||
| .intercept(new GrpcLoggingInterceptor()) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. just a thought (I don't know if there are any downsides or if this would work): Could we gate adding the logging interceptor here? i.e. Check if the logging env var exists + is configured and only add an interceptor if so. I think there was some mention about not using interceptors (potentially) so it may not work in the future.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess there is no harm in adding a gate here for now.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was just thinking that having it gate there might be a way to remove all the |
||
| .intercept(headerInterceptor) | ||
| .intercept(metadataHandlerInterceptor) | ||
| .userAgent(headerInterceptor.getUserAgentHeader()) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| /* | ||
| * 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<String, Integer> call; | ||
|
|
||
| private static final MethodDescriptor<String, Integer> 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.<MethodDescriptor<String, Integer>>any(), any(CallOptions.class))) | ||
| .thenReturn(call); | ||
| } | ||
|
|
||
| @Test | ||
| void testInterceptor_basic() { | ||
| GrpcLoggingInterceptor interceptor = new GrpcLoggingInterceptor(); | ||
| Channel intercepted = ClientInterceptors.intercept(channel, interceptor); | ||
| @SuppressWarnings("unchecked") | ||
| ClientCall.Listener<Integer> listener = mock(ClientCall.Listener.class); | ||
| ClientCall<String, Integer> 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<Integer> listener = mock(ClientCall.Listener.class); | ||
| ClientCall<String, Integer> 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.getCode().toString()), any(LogData.Builder.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(); | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be package private. Same thing for
HttpJsonLoggingInterceptor.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the current test setup, these needs to be public for testing purposes. I'll look into the test setup again
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bumping this now again. Does this still need to be public with InternalApi?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately, as conversations diverged to other topics since then, I did not got the chance to revamp the testing. As of now, these still need to be public with InternalApi.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this class has to be public due to testing purposes, would it make sense to add a
final?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unfortunately, adding
finalprevents mocking the class.