From 42980e77f0268c43c30477256af2c0548bb837aa Mon Sep 17 00:00:00 2001 From: andreadimaio Date: Wed, 26 Aug 2026 16:55:24 +0200 Subject: [PATCH] Truncate base64 audio payloads in logs and make message building lazy --- .../http/interceptors/LoggerInterceptor.java | 126 ++++++++++-------- .../ai/core/LoggerInterceptorTest.java | 56 ++++++++ 2 files changed, 126 insertions(+), 56 deletions(-) diff --git a/modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/http/interceptors/LoggerInterceptor.java b/modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/http/interceptors/LoggerInterceptor.java index 4821b000..613c3fe2 100644 --- a/modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/http/interceptors/LoggerInterceptor.java +++ b/modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/http/interceptors/LoggerInterceptor.java @@ -40,6 +40,9 @@ public final class LoggerInterceptor implements SyncHttpInterceptor, AsyncHttpIn private static final Pattern BASE64_IMAGE_PATTERN = Pattern.compile("(data:[\\w\\/+]+;base64,)(.{15})([^\"]+)"); + private static final Pattern BASE64_AUDIO_PATTERN = + Pattern.compile("(\"data\"\\s*:\\s*\")(.{15})([^\"]+)(?=\")"); + private static final Pattern JSON_SECRET_PATTERN = Pattern.compile("\"([^\"]*(?:api[_-]?key|password|secret|access_token|refresh_token)[^\"]*)\"\\s*:\\s*\"[^\"]*\"", Pattern.CASE_INSENSITIVE); @@ -127,7 +130,7 @@ public HttpResponse intercept(HttpRequest request, BodyHandler bodyHan private void logRequest(HttpRequest request) { if (!logRequest || !logger.isInfoEnabled()) - return; + return; // skip body read entirely when INFO is disabled Optional maybePublisher = request.bodyPublisher(); if (maybePublisher.isEmpty()) { @@ -170,89 +173,91 @@ public void onComplete() { } private void logResponse(String watsonxAISDKRequestId, Throwable exception) { - if (!logResponse || !logger.isInfoEnabled()) + if (!logResponse) return; - StringJoiner joiner = new StringJoiner("\n", "Response:\n", ""); - joiner.add("- Watsonx-AI-SDK-Request-Id: " + watsonxAISDKRequestId); + logger.atInfo().log(() -> { + StringJoiner joiner = new StringJoiner("\n", "Response:\n", ""); + joiner.add("- Watsonx-AI-SDK-Request-Id: " + watsonxAISDKRequestId); - if (exception instanceof WatsonxException e) { - joiner.add("- status code: " + e.statusCode()); - joiner.add("- body: " + Json.prettyPrint(exception.getMessage())); - } else { - joiner.add("- body: " + exception.getMessage()); - } + if (exception instanceof WatsonxException e) { + joiner.add("- status code: " + e.statusCode()); + joiner.add("- body: " + Json.prettyPrint(exception.getMessage())); + } else { + joiner.add("- body: " + exception.getMessage()); + } - logger.info(joiner.toString()); + return joiner.toString(); + }); } private void logResponse(String watsonxAISDKRequestId, HttpResponse response) { - if (!logResponse || !logger.isInfoEnabled()) + if (!logResponse) return; - try { + logger.atInfo().log(() -> { + try { - String body = null; - String headers = null; - boolean prettyPrint = false; + String body = null; + boolean prettyPrint = false; - T responseBody = response.body(); - boolean isStream = responseBody instanceof InputStream; + T responseBody = response.body(); + boolean isStream = responseBody instanceof InputStream; - if (!isStream) - body = HttpUtils.extractBodyAsString(response).orElse(null); + if (!isStream) + body = HttpUtils.extractBodyAsString(response).orElse(null); - StringJoiner joiner = new StringJoiner("\n", "Response:\n", ""); - joiner.add("- Watsonx-AI-SDK-Request-Id: " + watsonxAISDKRequestId); - joiner.add("- url: " + response.uri()); - joiner.add("- status code: " + response.statusCode()); + StringJoiner joiner = new StringJoiner("\n", "Response:\n", ""); + joiner.add("- Watsonx-AI-SDK-Request-Id: " + watsonxAISDKRequestId); + joiner.add("- url: " + response.uri()); + joiner.add("- status code: " + response.statusCode()); - if (nonNull(response.headers())) { - headers = HttpUtils.inOneLine(response.headers().map()); - joiner.add("- headers: " + headers); + if (nonNull(response.headers())) { + joiner.add("- headers: " + HttpUtils.inOneLine(response.headers().map())); - var contentType = response.headers().firstValue("Content-Type"); + var contentType = response.headers().firstValue("Content-Type"); - if (contentType.isPresent() && contentType.get().contains("application/json")) - prettyPrint = true; - } + if (contentType.isPresent() && contentType.get().contains("application/json")) + prettyPrint = true; + } - if (nonNull(body)) { - body = maskSecrets(body); - body = prettyPrint ? Json.prettyPrint(body) : body; - joiner.add("- body: " + body); - } + if (nonNull(body)) { + body = maskSecrets(body); + body = prettyPrint ? Json.prettyPrint(body) : body; + joiner.add("- body: " + body); + } - logger.info(joiner.toString()); + return joiner.toString(); - } catch (Exception e) { - logger.warn("Failed to log response", e); - } + } catch (Exception e) { + return "Failed to log response: " + e.getMessage(); + } + }); } private void logRequest(HttpRequest request, String body) { - String headers = null; - StringJoiner joiner = new StringJoiner("\n", "Request:\n", ""); - joiner.add("- method: " + request.method()); - joiner.add("- url: " + request.uri()); + logger.atInfo().log(() -> { + StringJoiner joiner = new StringJoiner("\n", "Request:\n", ""); + joiner.add("- method: " + request.method()); + joiner.add("- url: " + request.uri()); - if (nonNull(request.headers())) { - headers = HttpUtils.inOneLine(request.headers().map()); - joiner.add("- headers: " + headers); - if (nonNull(body)) { - body = formatBase64Image(body); - body = maskSecrets(body); + if (nonNull(request.headers())) { + joiner.add("- headers: " + HttpUtils.inOneLine(request.headers().map())); + if (nonNull(body)) { + String formatted = formatBase64Image(body); + formatted = maskSecrets(formatted); - var contentType = request.headers().firstValue("Content-Type"); + var contentType = request.headers().firstValue("Content-Type"); - if (contentType.isPresent() && contentType.get().contains("application/json")) - body = Json.prettyPrint(body); + if (contentType.isPresent() && contentType.get().contains("application/json")) + formatted = Json.prettyPrint(formatted); - joiner.add("- body: " + body); + joiner.add("- body: " + formatted); + } } - } - logger.info(joiner.toString()); + return joiner.toString(); + }); } private boolean isNonRepeatablePublisher(HttpRequest.BodyPublisher publisher) { @@ -293,6 +298,15 @@ private String formatBase64Image(String body) { Matcher matcher = BASE64_IMAGE_PATTERN.matcher(body); StringBuilder sb = new StringBuilder(); + while (matcher.find()) + matcher.appendReplacement(sb, matcher.group(1) + matcher.group(2) + "..."); + + matcher.appendTail(sb); + body = sb.toString(); + + matcher = BASE64_AUDIO_PATTERN.matcher(body); + sb = new StringBuilder(); + while (matcher.find()) matcher.appendReplacement(sb, matcher.group(1) + matcher.group(2) + "..."); diff --git a/modules/watsonx-ai-core/src/test/java/com/ibm/watsonx/ai/core/LoggerInterceptorTest.java b/modules/watsonx-ai-core/src/test/java/com/ibm/watsonx/ai/core/LoggerInterceptorTest.java index ea19d619..5b3b4fff 100644 --- a/modules/watsonx-ai-core/src/test/java/com/ibm/watsonx/ai/core/LoggerInterceptorTest.java +++ b/modules/watsonx-ai-core/src/test/java/com/ibm/watsonx/ai/core/LoggerInterceptorTest.java @@ -121,6 +121,62 @@ void should_log_base64_body_correctly() throws Exception { verify(chain).proceed(any(), any()); } + @Test + void should_truncate_base64_image_in_request_body() throws Exception { + + String fullPayload = "AAAAAAAAAAAAAAAABBBBBBBBBBBBBBBCCCCCCCCCCCCCCCDDDDDDDDDDDDDD"; + String body = "{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/png;base64," + fullPayload + "\"}}"; + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost")) + .header("Content-Type", "application/json") + .POST(BodyPublishers.ofString(body)) + .build(); + + List logs = captureLogs(() -> { + try { + LoggerInterceptor interceptor = new LoggerInterceptor(LogMode.REQUEST); + LoggerInterceptor.Chain chain = mock(LoggerInterceptor.Chain.class); + HttpResponse response = mock(HttpResponse.class); + when(chain.proceed(request, BodyHandlers.ofString())).thenReturn(response); + interceptor.intercept(request, HttpResponse.BodyHandlers.ofString(), 0, chain); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + String requestLog = findLog(logs, "Request:"); + assertFalse(requestLog.contains(fullPayload), "Full base64 image payload must not appear in the log"); + assertTrue(requestLog.contains("AAAAAAAAAAAAAAA..."), () -> "Log must contain truncated image payload, but was: " + requestLog); + } + + @Test + void should_truncate_base64_audio_in_request_body() throws Exception { + + String fullPayload = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA="; + String body = "{\"type\":\"input_audio\",\"input_audio\":{\"format\":\"audio/wav\",\"data\":\"" + fullPayload + "\"}}"; + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost")) + .header("Content-Type", "application/json") + .POST(BodyPublishers.ofString(body)) + .build(); + + List logs = captureLogs(() -> { + try { + LoggerInterceptor interceptor = new LoggerInterceptor(LogMode.REQUEST); + LoggerInterceptor.Chain chain = mock(LoggerInterceptor.Chain.class); + HttpResponse response = mock(HttpResponse.class); + when(chain.proceed(request, BodyHandlers.ofString())).thenReturn(response); + interceptor.intercept(request, HttpResponse.BodyHandlers.ofString(), 0, chain); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + String requestLog = findLog(logs, "Request:"); + assertFalse(requestLog.contains(fullPayload), "Full base64 audio payload must not appear in the log"); + assertTrue(requestLog.contains("UklGRiQAAABXQVZ..."), () -> "Log must contain truncated audio payload, but was: " + requestLog); + } + @Test void should_log_json_body_when_content_type_is_json() throws Exception {