Skip to content

Instantly share code, notes, and snippets.

@robtimus
Last active April 7, 2025 19:29
Show Gist options
  • Save robtimus/8653534693d7aaa1e23212e12b72b222 to your computer and use it in GitHub Desktop.
Save robtimus/8653534693d7aaa1e23212e12b72b222 to your computer and use it in GitHub Desktop.
java.net.http.HttpClient extension that supports request and response logging, including bodies
/*
* LoggingHttpClient.java
* Copyright 2025 Rob Spoor
*
* 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
*
* http://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.
*/
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.Authenticator;
import java.net.CookieHandler;
import java.net.ProxySelector;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublisher;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandler;
import java.net.http.HttpResponse.BodySubscriber;
import java.net.http.HttpResponse.PushPromiseHandler;
import java.net.http.HttpResponse.ResponseInfo;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.WritableByteChannel;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import java.util.concurrent.Flow.Subscriber;
import java.util.concurrent.Flow.Subscription;
import java.util.stream.Collectors;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import org.slf4j.Logger;
@SuppressWarnings({ "javadoc", "nls" })
public class LoggingHttpClient extends HttpClient {
private static final String REQUEST_LOGGER_FORMAT = """
Request {}:
Method : {}
URI : {}
Headers : {}
Body : {}
""";
private static final String RESPONSE_LOGGER_FORMAT = """
Response to request {} ({} ms):
Status code : {}
Headers : {}
Body : {}
""";
private final HttpClient delegate;
private final Logger logger;
public LoggingHttpClient(HttpClient delegate, Logger logger) {
this.delegate = delegate;
this.logger = logger;
}
@Override
public Optional<CookieHandler> cookieHandler() {
return delegate.cookieHandler();
}
@Override
public Optional<Duration> connectTimeout() {
return delegate.connectTimeout();
}
@Override
public Redirect followRedirects() {
return delegate.followRedirects();
}
@Override
public Optional<ProxySelector> proxy() {
return delegate.proxy();
}
@Override
public SSLContext sslContext() {
return delegate.sslContext();
}
@Override
public SSLParameters sslParameters() {
return delegate.sslParameters();
}
@Override
public Optional<Authenticator> authenticator() {
return delegate.authenticator();
}
@Override
public Version version() {
return delegate.version();
}
@Override
public Optional<Executor> executor() {
return delegate.executor();
}
@Override
public <T> HttpResponse<T> send(HttpRequest request, BodyHandler<T> responseBodyHandler) throws IOException, InterruptedException {
String requestId = UUID.randomUUID().toString();
logRequest(requestId, request);
long startTime = System.currentTimeMillis();
return delegate.send(request, loggingBodyHandler(requestId, startTime, responseBodyHandler));
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, BodyHandler<T> responseBodyHandler) {
String requestId = UUID.randomUUID().toString();
logRequest(requestId, request);
long startTime = System.currentTimeMillis();
return delegate.sendAsync(request, loggingBodyHandler(requestId, startTime, responseBodyHandler));
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, BodyHandler<T> responseBodyHandler,
PushPromiseHandler<T> pushPromiseHandler) {
String requestId = UUID.randomUUID().toString();
logRequest(requestId, request);
long startTime = System.currentTimeMillis();
return delegate.sendAsync(request, loggingBodyHandler(requestId, startTime, responseBodyHandler), pushPromiseHandler);
}
private void logRequest(String requestId, HttpRequest request) {
request.bodyPublisher().ifPresentOrElse(
bodyPublisher -> logRequestWithBody(requestId, request, bodyPublisher),
() -> logRequestWithoutBody(requestId, request));
}
private void logRequestWithBody(String requestId, HttpRequest request, BodyPublisher bodyPublisher) {
String contentType = request.headers().firstValue("Content-Type").orElse(null);
if (contentType == null || isText(contentType)) {
LoggingRequestBodySubscriber subscriber = new LoggingRequestBodySubscriber(requestId, request);
bodyPublisher.subscribe(subscriber);
} else {
logRequest(requestId, request, "<non-text>", null);
}
}
private void logRequestWithoutBody(String requestId, HttpRequest request) {
logRequest(requestId, request, "-", null);
}
private void logRequest(String requestId, HttpRequest request, String body, Throwable throwable) {
String method = request.method();
URI uri = request.uri();
String headers = toString(request.headers());
String loggableBody = body.isEmpty() ? "-" : body;
if (throwable == null) {
logger.info(REQUEST_LOGGER_FORMAT, requestId, method, uri, headers, loggableBody);
} else {
logger.info(REQUEST_LOGGER_FORMAT, requestId, method, uri, headers, loggableBody, throwable);
}
}
private <T> BodyHandler<T> loggingBodyHandler(String requestId, long startTime, BodyHandler<T> bodyHandler) {
return response -> {
BodySubscriber<T> delegate = bodyHandler.apply(response);
return new LoggingResponseBodySubscriber<>(delegate, requestId, startTime, response);
};
}
private void logResponse(String requestId, ResponseInfo responseInfo, String body, Throwable throwable, long duration) {
int statusCode = responseInfo.statusCode();
String headers = toString(responseInfo.headers());
String loggableBody = body.isEmpty() ? "-" : body;
if (throwable == null) {
logger.info(RESPONSE_LOGGER_FORMAT, requestId, duration, statusCode, headers, loggableBody);
} else {
logger.info(RESPONSE_LOGGER_FORMAT, requestId, duration, statusCode, headers, loggableBody, throwable);
}
}
@Override
public WebSocket.Builder newWebSocketBuilder() {
return delegate.newWebSocketBuilder();
}
private final class LoggingRequestBodySubscriber implements Subscriber<ByteBuffer> {
private final String requestId;
private final HttpRequest httpRequest;
private ByteArrayOutputStream outputStream;
private WritableByteChannel byteChannel;
private LoggingRequestBodySubscriber(String requestId, HttpRequest httpRequest) {
this.requestId = requestId;
this.httpRequest = httpRequest;
}
@Override
public void onSubscribe(Subscription subscription) {
outputStream = new ByteArrayOutputStream();
byteChannel = Channels.newChannel(outputStream);
subscription.request(Long.MAX_VALUE);
}
@Override
public void onNext(ByteBuffer item) {
int position = item.position();
int limit = item.limit();
try {
byteChannel.write(item);
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
item.position(position).limit(limit);
}
}
@Override
public void onError(Throwable throwable) {
log(throwable);
}
@Override
public void onComplete() {
log(null);
}
private void log(Throwable throwable) {
String body = outputStream.toString(StandardCharsets.UTF_8);
logRequest(requestId, httpRequest, body, throwable);
}
}
private final class LoggingResponseBodySubscriber<T> implements BodySubscriber<T> {
private final BodySubscriber<T> delegate;
private final String requestId;
private final long startTime;
private final ResponseInfo responseInfo;
private final boolean captureBody;
private ByteArrayOutputStream outputStream;
private WritableByteChannel byteChannel;
private LoggingResponseBodySubscriber(BodySubscriber<T> delegate, String requestId, long startTime, ResponseInfo responseInfo) {
this.delegate = delegate;
this.requestId = requestId;
this.startTime = startTime;
this.responseInfo = responseInfo;
String contentType = responseInfo.headers().firstValue("Content-Type").orElse(null);
this.captureBody = contentType == null || isText(contentType);
}
@Override
public void onSubscribe(Subscription subscription) {
if (captureBody) {
outputStream = new ByteArrayOutputStream();
byteChannel = Channels.newChannel(outputStream);
}
subscription.request(Long.MAX_VALUE);
delegate.onSubscribe(subscription);
}
@Override
public void onNext(List<ByteBuffer> item) {
if (captureBody) {
item.forEach(this::onNext);
}
delegate.onNext(item);
}
private void onNext(ByteBuffer item) {
int position = item.position();
int limit = item.limit();
try {
byteChannel.write(item);
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
item.position(position).limit(limit);
}
}
@Override
public void onError(Throwable throwable) {
log(throwable);
delegate.onError(throwable);
}
@Override
public void onComplete() {
log(null);
delegate.onComplete();
}
private void log(Throwable throwable) {
long endTime = System.currentTimeMillis();
String body = outputStream != null ? outputStream.toString(StandardCharsets.UTF_8) : "<non-text>";
logResponse(requestId, responseInfo, body, throwable, endTime - startTime);
}
@Override
public CompletionStage<T> getBody() {
return delegate.getBody();
}
}
private static String toString(HttpHeaders headers) {
Map<String, List<String>> headersMap = headers.map();
if (headersMap.isEmpty()) {
return "-";
}
return headersMap
.entrySet()
.stream().flatMap(entry -> entry.getValue()
.stream()
.map(value -> "%s=\"%s\"".formatted(entry.getKey(), value)))
.collect(Collectors.joining(", "));
}
private static boolean isText(String contentType) {
return contentType.startsWith("text/")
|| contentType.contains("json")
|| contentType.contains("xml")
|| contentType.startsWith("application/x-www-form-urlencoded");
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment