Skip to content
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

Open
wants to merge 47 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
59b5d73
poc: add log in grpc interceptor.
zhumin8 Nov 19, 2024
d34ec88
try out basics with httpjson interceptor.
zhumin8 Nov 20, 2024
b6417ae
implement conditional logging, use JUL when no binding. log formattin…
zhumin8 Nov 25, 2024
0c169ad
temp manual testing with showcase.
zhumin8 Nov 25, 2024
9ced175
simplify logic for conditional logger.
zhumin8 Nov 25, 2024
681f7d5
implement logging for basic case.
zhumin8 Nov 26, 2024
5c01ef2
add a JsonContextMapHandler to format in JUL case.
zhumin8 Nov 27, 2024
6605ef3
fix response payload for streaming. Add if guards to bypass log when …
zhumin8 Nov 27, 2024
ebbae15
when logging disabled, assign NOPLogger directly.
zhumin8 Nov 27, 2024
543298d
some temp settings for testing with showcase.
zhumin8 Dec 2, 2024
aac66f2
remove JUL wrapper for now. refactor getLogger(). Add tests for Loggi…
zhumin8 Dec 5, 2024
487614c
minor clean up.:
zhumin8 Dec 5, 2024
cc3465b
fix test after adding interceptor for logging for http.
zhumin8 Dec 5, 2024
211a3d1
cleanup and fix response header.
zhumin8 Dec 6, 2024
2970210
lint
zhumin8 Dec 6, 2024
32f8b7c
lint: license.
zhumin8 Dec 6, 2024
245c14d
refactor GrpcLoggingInterceptor for readability, intro LogData.
zhumin8 Dec 6, 2024
8358497
minor cleanups.
zhumin8 Dec 6, 2024
97087b2
GrpcLoggingInterceptor fix for thread safe, separate out debug loggin…
zhumin8 Dec 6, 2024
727a3e9
minor changes + add showcase it test.(need to run with env var, setup…
zhumin8 Dec 6, 2024
23bc111
refactor HttpJsonLoggingInterceptor. remove public modifier from logg…
zhumin8 Dec 6, 2024
bbe0700
add GrpcLoggingInterceptorTest and test interceptor structure.
zhumin8 Dec 8, 2024
3d7c7f9
fix status code '0'.
zhumin8 Dec 10, 2024
b870d81
merge logs in interceptors, record log data. remove request id.
zhumin8 Dec 10, 2024
56a2870
replace txt message to json message with all fields duplicated.
zhumin8 Dec 10, 2024
83eedf0
add try catch to logging methods
zhumin8 Dec 10, 2024
d85c848
add showcase tests. expose logging interceptors as public to setup te…
zhumin8 Dec 10, 2024
1919809
add internal api annotation.
zhumin8 Dec 10, 2024
613b6c8
lint
zhumin8 Dec 10, 2024
1169eaa
fix typo
zhumin8 Dec 11, 2024
a6b5433
remove unused test class.
zhumin8 Dec 11, 2024
fb0966e
try to exclude test from showcase-native.
zhumin8 Dec 11, 2024
77939fe
fix logback version.
zhumin8 Dec 11, 2024
04ef774
revert accidental change.
zhumin8 Dec 11, 2024
439e071
fix typo in ci.
zhumin8 Dec 11, 2024
673c9fe
cleanup gax pom. add tests.
zhumin8 Dec 11, 2024
c2b607b
add test.
zhumin8 Dec 11, 2024
3df39fa
Merge branch 'main' into client-log
zhumin8 Dec 11, 2024
4190dc7
rm redundant assignment.
zhumin8 Dec 11, 2024
fefb436
add logging test setup in httpjson.
zhumin8 Dec 11, 2024
7901d8a
add log response test to interceptors.
zhumin8 Dec 11, 2024
20f9f5a
remove redundant code.
zhumin8 Dec 11, 2024
172701d
add slf4j and test dep to dependencies.properties.
zhumin8 Dec 11, 2024
d25e742
Merge branch 'main' into client-log
zhumin8 Dec 11, 2024
a8d2f20
make requestPayload also as JsonElement in LogData.
zhumin8 Dec 11, 2024
df97834
fix typo in dependencies.properties.
zhumin8 Dec 12, 2024
32dbd0e
add test assertion to verify logger not enabled at levels when env no…
zhumin8 Dec 12, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions gapic-generator-java-pom-parent/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
<j2objc-annotations.version>3.0.0</j2objc-annotations.version>
<threetenbp.version>1.7.0</threetenbp.version>
<junit.version>5.11.3</junit.version>
<slf4j.version>2.0.16</slf4j.version>
</properties>

<developers>
Expand Down
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 {
Copy link
Collaborator

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.

Copy link
Contributor Author

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


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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are exposing public util classes in gax already, I think it's better to move these to gax to prevent duplication. This can also help us remove the dependency of gson in gax-grpc, and LogData does not have to exposed publicly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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);
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.
Same thing for all other try catch blocks.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 =
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
Expand Up @@ -467,6 +467,7 @@ private ManagedChannel createSingleChannel() throws IOException {
builder =
builder
.intercept(new GrpcChannelUUIDInterceptor())
.intercept(new GrpcLoggingInterceptor())
.intercept(headerInterceptor)
.intercept(metadataHandlerInterceptor)
.userAgent(headerInterceptor.getUserAgentHeader())
Expand Down
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;
}
}
Loading
Loading