Using gRPC Client Interceptors to Add Logging Without Touching Your Service
Learn how to add cross‑cutting concerns like logging to gRPC calls using client interceptors—complete with a working Echo example and verification steps.
08 Feb 2026, 02:07 UTC

Problem: Adding cross‑cutting logic without changing every call
When you need to log every RPC, attach authentication headers, or collect metrics, editing each call site is tedious and error‑prone. You want a single place to inject this logic while keeping the service implementation untouched.
How client interceptors fit into the gRPC call flow
gRPC Java provides ClientInterceptor as an extension point. An interceptor receives the ClientCall and Metadata for an RPC, can examine or modify them, then delegates to the next interceptor in the chain. The chain ends with the actual network transport. This model lets you wrap every outgoing call with the same behavior.
Implementing a simple logging interceptor
The interceptor below measures latency, logs the method name, and forwards the call. It is deliberately lightweight—no blocking work and all state is local to the method.
import io.grpc.*;
import java.util.logging.Logger;
public class LoggingInterceptor implements ClientInterceptor {
private static final Logger LOG = Logger.getLogger(LoggingInterceptor.class.getName());
@Override
public ClientCall interceptCall(
MethodDescriptor method,
CallOptions callOptions,
ChannelNext next) {
return new ForwardingClientCall.SimpleForwardingClientCall(next.call(method, callOptions)) {
@Override
public void start(Listener responseListener, Metadata headers) {
long start = System.nanoTime();
super.start(new ForwardingClientCallListener.SimpleForwardingClientCallListener(responseListener) {
@Override
public void onMessage(RespT message) {
super.onMessage(message);
}
@Override
public void onClose(Status status, Metadata trailers) {
long latencyMs = (System.nanoTime() - start) / 1_000_000;
LOG.info(() -> String.format(
"RPC %s finished in %d ms with status %s",
method.getFullMethodName(), latencyMs, status));
super.onClose(status, trailers);
}
}, headers);
}
};
}
}
Wiring the interceptor into a client channel
You register one or more interceptors when building the channel. The order matters: the first interceptor sees the raw call, the last sees the effects of all previous ones.
import io.grpc.*;
import java.util.concurrent.TimeUnit;
public class EchoClient {
public static void main(String[] args) throws Exception {
Channel channel = Grpc.newChannelBuilderForAddress("localhost", 50051, InsecureChannelCredentials.create())
.intercept(new LoggingInterceptor()) // <-- interceptor added here
.build();
try {
EchoGrpc.EchoBlockingStub stub = EchoGrpc.newBlockingStub(channel);
EchoRequest request = EchoRequest.newBuilder().setMessage("hello").build();
EchoResponse response = stub.echo(request);
System.out.println("Received: " + response.getMessage());
} finally {
channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
}
}
}
Worked example with an Echo service
Proto definition
syntax = "proto3";
package echo;
service Echo { rpc Echo (EchoRequest) returns (EchoResponse); }
message EchoRequest { string message = 1; }
message EchoResponse { string message = 1; }
Server snippet (Java)
import io.grpc.*;
import java.io.IOException;
public class EchoServer {
public static void main(String[] args) throws IOException, InterruptedException {
Server server = ServerBuilder.forPort(50051)
.addService(new EchoImpl()) // simple implementation that echoes the message
.build();
server.start();
System.out.println("Server listening on 50051");
server.awaitTermination();
}
static class EchoImpl extends EchoGrpc.EchoImplBase {
@Override
public void echo(EchoRequest req, StreamObserver resp) {
EchoResponse reply = EchoResponse.newBuilder().setMessage(req.getMessage()).build();
resp.onNext(reply);
resp.onCompleted();
}
}
}
Client with logging interceptor
The client code shown earlier already contains the interceptor. When you run the server and then the client, you should see console output similar to:
INFO: RPC /echo.Echo/Echo finished in 2 ms with status OK Received: hello
The log line proves the interceptor ran; the unchanged response proves it did not alter the payload.
Trade‑offs and limitations
- Added latency – each interceptor adds a few microseconds; heavy work inside can noticeably increase round‑trip time.
- Increased complexity – debugging flow requires understanding the interceptor chain.
- Thread‑safety – interceptors must not hold mutable state across calls unless properly synchronized.
- Exception handling – swallowing exceptions inside
interceptCallcan hide failures; let them propagate or convert toStatus.
Verification steps
- Start the Echo server (
java -cp … EchoServer). - Run the Echo client (
java -cp … EchoClient). - Observe the console: a log line from
LoggingInterceptorshould appear for each RPC. - Check that the printed response matches the request you sent.
- (Optional) Enable Netty verbose logging (
-Dio.netty.logger=DEBUG) to confirm framing is unaffected.
Actionable takeaway
If you need to add logging, authentication, or metrics to every gRPC call without modifying service code, implement a ClientInterceptor and register it on the channel. Keep the interceptor lightweight, mindful of ordering, and verify with a simple echo service before rolling it out to production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.