-
Notifications
You must be signed in to change notification settings - Fork 53
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
feat: add client side logging with slf4j #3403
base: main
Are you sure you want to change the base?
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
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 | ||
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. Since we are exposing public util classes in 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. Yes. I was inclined to do this as refactoring later, but I agree these are better of as utils methods from gax. |
||
// 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); | ||
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. What are the possible errors? I think we need to make sure there are no errors. Even if there are possible errors due to misconfiguration, we should fail more gracefully. This would result in one error log entry for every request which would flood customer's application log. 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. These are catch-all safe guard to ensure logging logic never cause user application to crash. Especially due to original timeline, I was concerned that I would miss anything. I will definitely take another look at these logging logic. That said, I still lean to keep these try-catch blocks as safety net. If we are concerned about flooding customers's log, maybe we can do nothing in the catch and let logging silently fail, which I think is acceptable. |
||
} | ||
} | ||
|
||
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 = | ||
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. gRPC metadata could be binary, see https://grpc.io/docs/guides/metadata/. I'm not sure if this is going to fail or not if the value is binary, can you test it out? Either way, I think we should exclude binary headers as they don't provide much value. |
||
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 |
---|---|---|
@@ -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