This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 00970c6bbe TODO-362: redesign NG RestClient debug logging onto 
RichLogger
00970c6bbe is described below

commit 00970c6bbeb34b5d2a6f5b228d94cfe16f8adf33
Author: James Bognar <[email protected]>
AuthorDate: Sat Aug 15 15:12:38 2026 -0400

    TODO-362: redesign NG RestClient debug logging onto RichLogger
    
    Drive NG client debug via JUL logger level only (INFO=basic, FINE=+headers, 
FINEST=+bodies) on RichLogger. Add RestClientDebugFormatter + 
BasicRestClientDebugFormatter and a RestClientDebugPipeline for tier 
resolution/emission. Add 
RestClient.Builder.debugLoggerName(String)/debugFormatter(...) with default 
logger name RestClient.class.getName(); wire RestRequest/RestResponse to emit 
bounded request/response capture through RichLogger. Remove legacy 
RestLogger/RestLogEntry/RestLogLevel [...]
---
 .../rest/client/RestClientFeatures_Test.java       | 648 ++++-----------------
 .../rest/client/BasicRestClientDebugFormatter.java | 135 +++++
 .../apache/juneau/rest/client/BasicRestLogger.java | 251 --------
 .../org/apache/juneau/rest/client/RestClient.java  |  42 +-
 .../rest/client/RestClientDebugFormatter.java      |  76 +++
 .../rest/client/RestClientDebugPipeline.java       |  53 ++
 .../apache/juneau/rest/client/RestLogEntry.java    | 298 ----------
 .../juneau/rest/client/RestLogLevelResolver.java   | 155 -----
 .../org/apache/juneau/rest/client/RestLogger.java  |  63 --
 .../org/apache/juneau/rest/client/RestRequest.java | 182 +++++-
 .../apache/juneau/rest/client/RestResponse.java    | 122 +++-
 .../juneau/rest/client/BasicRestLogger_Test.java   |  74 ---
 .../rest/client/RestClient_DebugLogging_Test.java  | 177 ++++++
 .../juneau/rest/client/RestLogEntry_Test.java      |  91 ---
 14 files changed, 871 insertions(+), 1496 deletions(-)

diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/RestClientFeatures_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/RestClientFeatures_Test.java
index 14cd830812..74e30923ef 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/RestClientFeatures_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/RestClientFeatures_Test.java
@@ -31,8 +31,8 @@ import org.apache.juneau.rest.mock.*;
 import org.junit.jupiter.api.*;
 
 /**
- * Tests for RestCallInterceptor, RestLogger, RestLogEntry, 
RestLogLevelResolver,
- * BasicRestLogger, BodyConverter, and related RestClient features.
+ * Tests for RestCallInterceptor, client debug logging, BodyConverter,
+ * and related RestClient features.
  */
 @SuppressWarnings({
        "java:S5778"  // assertThrows lambdas with chained calls; intermediate 
invocations do not throw in practice
@@ -191,461 +191,132 @@ class RestClientFeatures_Test {
        }
 
        // 
=================================================================================================================
-       // B — RestLogger
+       // B — Client debug logging (RichLogger + formatter tiers)
        // 
=================================================================================================================
 
        @Test
-       void b01_logger_called_on_success() throws Exception {
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.of(200, "ok");
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       try (var r = client.get("/api").run()) { /* 
Intentionally empty: response is executed and auto-closed via 
try-with-resources; assertions follow. */ }
-               }
-               assertEquals(1, entries.size());
-               var entry = entries.get(0);
-               assertNotNull(entry.getRequest());
-               assertNotNull(entry.getResponse());
-               assertNull(entry.getError());
-               assertEquals("GET", entry.getRequest().getMethod());
-               assertEquals(200, entry.getStatusCode());
-               assertFalse(entry.isError());
-               assertFalse(entry.isDebug());
-               assertEquals(System.Logger.Level.INFO, entry.getLevel());
-       }
-
-       @Test
-       void b02_logger_called_on_transport_error() throws Exception {
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.builder()
-                       .fallback(req -> { throw new 
TransportException("simulated failure"); })
-                       .build();
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       assertThrows(TransportException.class, () -> 
client.get("/api").run());
-               }
-               assertEquals(1, entries.size());
-               var entry = entries.get(0);
-               assertNotNull(entry.getError());
-               assertNull(entry.getResponse());
-               assertEquals(0, entry.getStatusCode());
-               assertTrue(entry.isError());
-               assertEquals(System.Logger.Level.ERROR, entry.getLevel());
-       }
-
-       @Test
-       void b03_logger_status400_is_warning() throws Exception {
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.of(404, "Not Found");
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       try (var r = client.get("/missing").run()) { /* 
Intentionally empty: response is executed and auto-closed via 
try-with-resources; assertions follow. */ }
-               }
-               assertEquals(1, entries.size());
-               assertEquals(System.Logger.Level.WARNING, 
entries.get(0).getLevel());
-               assertTrue(entries.get(0).isError());
-       }
-
-       @Test
-       void b04_logger_status500_is_error() throws Exception {
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.of(500, "Internal Server 
Error");
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       try (var r = client.get("/crash").run()) { /* 
Intentionally empty: response is executed and auto-closed via 
try-with-resources; assertions follow. */ }
-               }
-               assertEquals(1, entries.size());
-               assertEquals(System.Logger.Level.ERROR, 
entries.get(0).getLevel());
-       }
-
-       @Test
-       void b05_debug_flag_set_on_entry() throws Exception {
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.of(200, "ok");
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       try (var r = client.get("/").debug().run()) { /* 
Intentionally empty: response is executed and auto-closed via 
try-with-resources; assertions follow. */ }
-               }
-               assertTrue(entries.get(0).isDebug());
-       }
-
-       @Test
-       void b06_logger_null_no_logging() throws Exception {
-               // Without logger, run() should complete normally
-               var transport = MockHttpTransport.of(200, "ok");
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .build()) {
-                       try (var r = client.get("/").run()) {
-                               assertEquals(200, r.getStatusCode());
+       void b01_infoTier_emitsBasicOnly() throws Exception {
+               var loggerName = getClass().getName() + ".b01";
+               var logger = 
org.apache.juneau.commons.logging.RichLogger.getLogger(loggerName);
+               var previous = logger.getLevel();
+               logger.setLevel(java.util.logging.Level.INFO);
+               try (var capture = 
logger.captureEvents(java.util.logging.Level.INFO)) {
+                       try (var client = RestClient.builder()
+                                       .transport(MockHttpTransport.of(200, 
"ok"))
+                                       .rootUrl("http://x.com";)
+                                       .debugLoggerName(loggerName)
+                                       .build();
+                               var response = client.get("/info").run()) {
+                               assertEquals(200, response.getStatusCode());
                        }
-               }
-       }
-
-       // 
=================================================================================================================
-       // C — RestLogEntry.format()
-       // 
=================================================================================================================
-
-       @Test
-       void c01_format_default_success() throws Exception {
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.builder()
-                       .fallback(req -> 
TransportResponse.builder().statusCode(200).reasonPhrase("OK").build())
-                       .build();
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       try (var r = client.get("/users").run()) { /* 
Intentionally empty: response is executed and auto-closed via 
try-with-resources; assertions follow. */ }
-               }
-               var fmt = entries.get(0).format();
-               assertTrue(fmt.contains("GET"), "format should contain method: 
" + fmt);
-               assertTrue(fmt.contains("200"), "format should contain status: 
" + fmt);
-               assertTrue(fmt.contains("ms"), "format should contain elapsed: 
" + fmt);
-       }
-
-       @Test
-       void c02_format_with_template() throws Exception {
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.of(200, "OK");
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://example.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       try (var r = client.get("/items").run()) { /* 
Intentionally empty: response is executed and auto-closed via 
try-with-resources; assertions follow. */ }
-               }
-               var entry = entries.get(0);
-               assertEquals("GET", entry.format("{method}"));
-               assertTrue(entry.format("{uri}").contains("/items"));
-               assertEquals("200", entry.format("{status}"));
-               assertTrue(entry.format("{elapsed}").endsWith("ms"));
-               assertEquals("", entry.format("{error}"));
-       }
-
-       @Test
-       void c03_format_error_template() throws Exception {
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.builder()
-                       .fallback(req -> { throw new 
TransportException("connection refused"); })
-                       .build();
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       assertThrows(TransportException.class, () -> 
client.get("/").run());
-               }
-               var entry = entries.get(0);
-               assertTrue(entry.format("{error}").contains("connection 
refused"));
-       }
-
-       @Test
-       void c04_hasResponseHeader() throws Exception {
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.builder()
-                       .fallback(req -> TransportResponse.builder()
-                               .statusCode(200)
-                               .header("X-Custom", "value")
-                               .build())
-                       .build();
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       try (var r = client.get("/").run()) { /* Intentionally 
empty: response is executed and auto-closed via try-with-resources; assertions 
follow. */ }
-               }
-               var entry = entries.get(0);
-               assertTrue(entry.hasResponseHeader("X-Custom"));
-               assertFalse(entry.hasResponseHeader("X-Missing"));
-       }
-
-       @Test
-       void c05_hasResponseHeader_noResponse() throws Exception {
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.builder()
-                       .fallback(req -> { throw new 
TransportException("fail"); })
-                       .build();
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       assertThrows(TransportException.class, () -> 
client.get("/").run());
-               }
-               assertFalse(entries.get(0).hasResponseHeader("Anything"));
-       }
-
-       @Test
-       void c06_getUri_set_after_run() throws Exception {
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.of(200, "ok");
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       try (var r = client.get("/path").run()) { /* 
Intentionally empty: response is executed and auto-closed via 
try-with-resources; assertions follow. */ }
-               }
-               var uri = entries.get(0).getRequest().getUri();
-               assertNotNull(uri);
-               assertTrue(uri.toString().contains("/path"));
-       }
-
-       // 
=================================================================================================================
-       // D — RestLogLevelResolver
-       // 
=================================================================================================================
-
-       @Test
-       void d01_default_resolver_info_for_2xx() {
-               var entry = stubEntry(200, null);
-               assertEquals(System.Logger.Level.INFO, 
RestLogLevelResolver.DEFAULT.resolve(entry));
-       }
-
-       @Test
-       void d02_default_resolver_warning_for_4xx() {
-               var entry = stubEntry(404, null);
-               assertEquals(System.Logger.Level.WARNING, 
RestLogLevelResolver.DEFAULT.resolve(entry));
-       }
-
-       @Test
-       void d03_default_resolver_error_for_5xx() {
-               var entry = stubEntry(500, null);
-               assertEquals(System.Logger.Level.ERROR, 
RestLogLevelResolver.DEFAULT.resolve(entry));
-       }
-
-       @Test
-       void d04_default_resolver_error_for_transport_error() {
-               var entry = stubEntry(0, new TransportException("fail"));
-               assertEquals(System.Logger.Level.ERROR, 
RestLogLevelResolver.DEFAULT.resolve(entry));
-       }
-
-       @Test
-       void d05_custom_resolver_via_rules() {
-               var resolver = RestLogLevelResolver.rules()
-                       .rule(System.Logger.Level.ERROR, e -> e.getStatusCode() 
>= 500)
-                       .rule(System.Logger.Level.WARNING, e -> 
e.getStatusCode() >= 400)
-                       .defaultLevel(System.Logger.Level.DEBUG)
-                       .build();
-               assertEquals(System.Logger.Level.DEBUG, 
resolver.resolve(stubEntry(200, null)));
-               assertEquals(System.Logger.Level.WARNING, 
resolver.resolve(stubEntry(400, null)));
-               assertEquals(System.Logger.Level.ERROR, 
resolver.resolve(stubEntry(503, null)));
-       }
-
-       @Test
-       void d06_default_resolver_warning_for_thrown_header() throws Exception {
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.builder()
-                       .fallback(req -> TransportResponse.builder()
-                               .statusCode(200)
-                               .header("Thrown", 
"com.example.MyException;message")
-                               .build())
-                       .build();
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       try (var r = client.get("/").run()) { /* Intentionally 
empty: response is executed and auto-closed via try-with-resources; assertions 
follow. */ }
-               }
-               // Thrown header present with 200 → WARNING
-               assertEquals(System.Logger.Level.WARNING, 
entries.get(0).getLevel());
-       }
-
-       // 
=================================================================================================================
-       // E — BasicRestLogger
-       // 
=================================================================================================================
-
-       @Test
-       void e01_basicRestLogger_of_logs_info() throws Exception {
-               var messages = new ArrayList<String>();
-               var jdkLogger = new System.Logger() {
-                       @Override public String getName() { return "test"; }
-                       @Override public boolean isLoggable(Level level) { 
return true; }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String msg, Throwable thrown) { messages.add(level + ":" + msg); }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String format, Object... params) { messages.add(level + ":" + format); }
-               };
-               var transport = MockHttpTransport.builder()
-                       .fallback(req -> 
TransportResponse.builder().statusCode(200).reasonPhrase("OK").build())
-                       .build();
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(BasicRestLogger.of(jdkLogger))
-                               .build()) {
-                       try (var r = client.get("/test").run()) { /* 
Intentionally empty: response is executed and auto-closed via 
try-with-resources; assertions follow. */ }
-               }
-               assertEquals(1, messages.size());
-               assertTrue(messages.get(0).startsWith("INFO:"), "Should log at 
INFO level: " + messages.get(0));
-               assertTrue(messages.get(0).contains("200"), "Message should 
contain status: " + messages.get(0));
-       }
-
-       @Test
-       void e02_basicRestLogger_logs_error_with_exception() throws Exception {
-               var messages = new ArrayList<String>();
-               var throwables = new ArrayList<Throwable>();
-               var jdkLogger = new System.Logger() {
-                       @Override public String getName() { return "test"; }
-                       @Override public boolean isLoggable(Level level) { 
return true; }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String msg, Throwable thrown) {
-                               messages.add(level + ":" + msg);
-                               throwables.add(thrown);
+                       assertNotNull(capture.last());
+                       assertEquals(java.util.logging.Level.INFO, 
capture.last().getLevel());
+                       var msg = capture.last().getMessage();
+                       assertTrue(msg.contains("HTTP GET"), "Basic message 
should include request line");
+                       assertFalse(msg.contains("---Request Headers---"), 
"INFO should not include headers");
+                       assertFalse(msg.contains("---Response Content 
UTF-8---"), "INFO should not include bodies");
+               } finally {
+                       logger.setLevel(previous);
+               }
+       }
+
+       @Test
+       void b02_fineTier_includesHeadersAndRedacts() throws Exception {
+               var loggerName = getClass().getName() + ".b02";
+               var logger = 
org.apache.juneau.commons.logging.RichLogger.getLogger(loggerName);
+               var previous = logger.getLevel();
+               logger.setLevel(java.util.logging.Level.FINE);
+               try (var capture = 
logger.captureEvents(java.util.logging.Level.FINE)) {
+                       try (var client = RestClient.builder()
+                                       .transport(MockHttpTransport.builder()
+                                               .fallback(req -> 
TransportResponse.builder()
+                                                       .statusCode(200)
+                                                       
.header("Authorization", "Bearer response-secret")
+                                                       .build())
+                                               .build())
+                                       .rootUrl("http://x.com";)
+                                       .debugLoggerName(loggerName)
+                                       .build();
+                               var response = 
client.get("/headers").header("Authorization", "Bearer request-secret").run()) {
+                               assertEquals(200, response.getStatusCode());
                        }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String format, Object... params) { messages.add(level + ":" + format); }
-               };
-               var transport = MockHttpTransport.builder()
-                       .fallback(req -> { throw new 
TransportException("network error"); })
-                       .build();
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(BasicRestLogger.of(jdkLogger))
-                               .build()) {
-                       assertThrows(TransportException.class, () -> 
client.get("/fail").run());
-               }
-               assertEquals(1, messages.size());
-               assertTrue(messages.get(0).startsWith("ERROR:"), "Should log at 
ERROR level: " + messages.get(0));
-               assertNotNull(throwables.get(0), "Throwable should be included 
on ERROR");
-       }
-
-       @Test
-       void e03_basicRestLogger_filter_suppresses_entry() throws Exception {
-               var messages = new ArrayList<String>();
-               var jdkLogger = new System.Logger() {
-                       @Override public String getName() { return "test"; }
-                       @Override public boolean isLoggable(Level level) { 
return true; }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String msg, Throwable thrown) { messages.add(msg); }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String format, Object... params) { messages.add(format); }
-               };
-               var transport = MockHttpTransport.of(200, "ok");
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(BasicRestLogger.create()
-                                       .logger(jdkLogger)
-                                       .filter(e -> false) // filter all
-                                       .build())
-                               .build()) {
-                       try (var r = client.get("/").run()) { /* Intentionally 
empty: response is executed and auto-closed via try-with-resources; assertions 
follow. */ }
-               }
-               assertTrue(messages.isEmpty(), "Filtered logger should log 
nothing");
-       }
-
-       @Test
-       void e04_basicRestLogger_customTemplates() throws Exception {
-               var messages = new ArrayList<String>();
-               var jdkLogger = new System.Logger() {
-                       @Override public String getName() { return "test"; }
-                       @Override public boolean isLoggable(Level level) { 
return true; }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String msg, Throwable thrown) { messages.add(msg); }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String format, Object... params) { messages.add(format); }
-               };
-               var transport = MockHttpTransport.of(200, "ok");
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(BasicRestLogger.create()
-                                       .logger(jdkLogger)
-                                       
.infoTemplate("CUSTOM:{method}:{status}")
-                                       .build())
-                               .build()) {
-                       try (var r = client.get("/").run()) { /* Intentionally 
empty: response is executed and auto-closed via try-with-resources; assertions 
follow. */ }
-               }
-               assertEquals(1, messages.size());
-               assertTrue(messages.get(0).startsWith("CUSTOM:GET:200"), 
"Expected custom template: " + messages.get(0));
-       }
-
-       @Test
-       void e05_basicRestLogger_debug_entry_uses_debugTemplate() throws 
Exception {
-               var messages = new ArrayList<String>();
-               var jdkLogger = new System.Logger() {
-                       @Override public String getName() { return "test"; }
-                       @Override public boolean isLoggable(Level level) { 
return true; }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String msg, Throwable thrown) { messages.add(level + ":" + msg); }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String format, Object... params) { messages.add(level + ":" + format); }
-               };
-               var transport = MockHttpTransport.of(200, "ok");
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(BasicRestLogger.create()
-                                       .logger(jdkLogger)
-                                       .debugTemplate("DBG:{method}")
-                                       .build())
-                               .build()) {
-                       try (var r = client.get("/").debug().run()) { /* 
Intentionally empty: response is executed and auto-closed via 
try-with-resources; assertions follow. */ }
-               }
-               assertEquals(1, messages.size());
-               assertTrue(messages.get(0).startsWith("DEBUG:DBG:GET"), 
"Expected debug template: " + messages.get(0));
-       }
-
-       @Test
-       void e06_basicRestLogger_warning_level() throws Exception {
-               var messages = new ArrayList<String>();
-               var jdkLogger = new System.Logger() {
-                       @Override public String getName() { return "test"; }
-                       @Override public boolean isLoggable(Level level) { 
return true; }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String msg, Throwable thrown) { messages.add(level + ":" + msg); }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String format, Object... params) { messages.add(level + ":" + format); }
-               };
-               var transport = MockHttpTransport.of(404, "Not Found");
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(BasicRestLogger.create()
-                                       .logger(jdkLogger)
-                                       .warningTemplate("WARN:{status}")
-                                       .build())
-                               .build()) {
-                       try (var r = client.get("/missing").run()) { /* 
Intentionally empty: response is executed and auto-closed via 
try-with-resources; assertions follow. */ }
-               }
-               assertEquals(1, messages.size());
-               assertTrue(messages.get(0).startsWith("WARNING:WARN:404"), 
"Expected warning template: " + messages.get(0));
-       }
-
-       @Test
-       void e07_basicRestLogger_customLevelResolver() throws Exception {
-               var messages = new ArrayList<String>();
-               var jdkLogger = new System.Logger() {
-                       @Override public String getName() { return "test"; }
-                       @Override public boolean isLoggable(Level level) { 
return true; }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String msg, Throwable thrown) { messages.add(level + ":" + msg); }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String format, Object... params) { messages.add(level + ":" + format); }
-               };
-               var transport = MockHttpTransport.of(200, "ok");
-               var alwaysError = RestLogLevelResolver.rules()
-                       .defaultLevel(System.Logger.Level.ERROR)
-                       .build();
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(BasicRestLogger.create()
-                                       .logger(jdkLogger)
-                                       .levelResolver(alwaysError)
-                                       .build())
-                               .build()) {
-                       try (var r = client.get("/").run()) { /* Intentionally 
empty: response is executed and auto-closed via try-with-resources; assertions 
follow. */ }
+                       assertNotNull(capture.last());
+                       assertEquals(java.util.logging.Level.FINE, 
capture.last().getLevel());
+                       var msg = capture.last().getMessage();
+                       assertTrue(msg.contains("---Request Headers---"), "FINE 
should include request headers");
+                       assertTrue(msg.contains("---Response Headers---"), 
"FINE should include response headers");
+                       assertTrue(msg.contains("Authorization:"), 
"Authorization header should be present");
+                       assertFalse(msg.contains("request-secret"), "Raw 
request secret should not leak");
+                       assertFalse(msg.contains("response-secret"), "Raw 
response secret should not leak");
+               } finally {
+                       logger.setLevel(previous);
+               }
+       }
+
+       @Test
+       void b03_finestTier_includesBodies() throws Exception {
+               var loggerName = getClass().getName() + ".b03";
+               var logger = 
org.apache.juneau.commons.logging.RichLogger.getLogger(loggerName);
+               var previous = logger.getLevel();
+               logger.setLevel(java.util.logging.Level.FINEST);
+               try (var capture = 
logger.captureEvents(java.util.logging.Level.FINEST)) {
+                       try (var client = RestClient.builder()
+                                       .transport(MockHttpTransport.builder()
+                                               .fallback(req -> {
+                                                       if (req.getBody() != 
null) {
+                                                               try {
+                                                                       
req.getBody().writeTo(new ByteArrayOutputStream());
+                                                               } catch 
(IOException e) {
+                                                                       throw 
new RuntimeException(e);
+                                                               }
+                                                       }
+                                                       return 
TransportResponse.builder()
+                                                               .statusCode(200)
+                                                               .body(new 
ByteArrayInputStream("uvwxyz".getBytes(StandardCharsets.UTF_8)))
+                                                               .build();
+                                               })
+                                               .build())
+                                       .rootUrl("http://x.com";)
+                                       .debugLoggerName(loggerName)
+                                       .debugFormatter(new 
BasicRestClientDebugFormatter().bodyCap(4))
+                                       .build();
+                               var response = 
client.post("/body").bodyString("abcdef").run()) {
+                               assertEquals("uvwxyz", 
response.body().asString());
+                       }
+                       assertNotNull(capture.last());
+                       assertEquals(java.util.logging.Level.FINEST, 
capture.last().getLevel());
+                       var msg = capture.last().getMessage();
+                       assertTrue(msg.contains("---Request Content UTF-8---"));
+                       assertTrue(msg.contains("abcd"));
+                       assertTrue(msg.contains("---Response Content 
UTF-8---"));
+                       assertTrue(msg.contains("uvwx"));
+               } finally {
+                       logger.setLevel(previous);
+               }
+       }
+
+       @Test
+       void b04_transportError_emitsThrown() throws Exception {
+               var loggerName = getClass().getName() + ".b04";
+               var logger = 
org.apache.juneau.commons.logging.RichLogger.getLogger(loggerName);
+               var previous = logger.getLevel();
+               logger.setLevel(java.util.logging.Level.INFO);
+               try (var capture = 
logger.captureEvents(java.util.logging.Level.INFO)) {
+                       try (var client = RestClient.builder()
+                                       .transport(MockHttpTransport.builder()
+                                               .fallback(req -> { throw new 
TransportException("simulated failure"); })
+                                               .build())
+                                       .rootUrl("http://x.com";)
+                                       .debugLoggerName(loggerName)
+                                       .build()) {
+                               assertThrows(TransportException.class, () -> 
client.get("/fail").run());
+                       }
+                       assertNotNull(capture.last());
+                       assertNotNull(capture.last().getThrown());
+                       
assertTrue(capture.last().getThrown().getMessage().contains("simulated 
failure"));
+               } finally {
+                       logger.setLevel(previous);
                }
-               assertEquals(1, messages.size());
-               assertTrue(messages.get(0).startsWith("ERROR:"), "Should log at 
ERROR even for 200: " + messages.get(0));
        }
 
        // 
=================================================================================================================
@@ -952,51 +623,6 @@ class RestClientFeatures_Test {
        // K — Additional coverage tests
        // 
=================================================================================================================
 
-       @Test
-       void k01_basicRestLogger_filter_allows_entry() throws Exception {
-               // When filter is non-null and returns TRUE, the entry IS logged
-               var messages = new ArrayList<String>();
-               var jdkLogger = new System.Logger() {
-                       @Override public String getName() { return "test"; }
-                       @Override public boolean isLoggable(Level level) { 
return true; }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String msg, Throwable thrown) { messages.add(msg); }
-                       @Override public void log(Level level, ResourceBundle 
bundle, String format, Object... params) { messages.add(format); }
-               };
-               var transport = MockHttpTransport.of(200, "ok");
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(BasicRestLogger.create()
-                                       .logger(jdkLogger)
-                                       .filter(e -> true) // filter allows all 
entries
-                                       .build())
-                               .build()) {
-                       try (var r = client.get("/").run()) { /* Intentionally 
empty: response is executed and auto-closed via try-with-resources; assertions 
follow. */ }
-               }
-               // With filter returning true, entry IS logged
-               assertEquals(1, messages.size(), "Entry should be logged when 
filter returns true");
-       }
-
-       @Test
-       void k02_restLogEntry_error_with_null_message() throws Exception {
-               // Error with null message falls back to class simple name
-               var entries = new ArrayList<RestLogEntry>();
-               var transport = MockHttpTransport.builder()
-                       .fallback(req -> { throw new 
TransportException((String)null); })
-                       .build();
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       assertThrows(TransportException.class, () -> 
client.get("/").run());
-               }
-               var entry = entries.get(0);
-               // {error} should use class simple name when getMessage() is 
null
-               var errorText = entry.format("{error}");
-               assertTrue(errorText.contains("TransportException"), "Error 
text should contain class name when message is null: " + errorText);
-       }
-
        @Test
        void k03_ngRestResponse_getHeaders() throws Exception {
                var transport = MockHttpTransport.builder()
@@ -1594,34 +1220,4 @@ class RestClientFeatures_Test {
                }
        }
 
-       // 
=================================================================================================================
-       // Helpers
-       // 
=================================================================================================================
-
-       /** Creates a stub RestLogEntry by making a real mock call and 
capturing the logger entry. */
-       private static RestLogEntry stubEntry(int status, Throwable error) {
-               var entries = new ArrayList<RestLogEntry>();
-               MockHttpTransport transport;
-               if (error != null) {
-                       transport = MockHttpTransport.builder()
-                               .fallback(req -> { throw new 
TransportException(error.getMessage()); })
-                               .build();
-               } else {
-                       transport = MockHttpTransport.builder()
-                               .fallback(req -> 
TransportResponse.builder().statusCode(status).build())
-                               .build();
-               }
-               try (var client = RestClient.builder()
-                               .transport(transport)
-                               .rootUrl("http://x.com";)
-                               .logger(entries::add)
-                               .build()) {
-                       try {
-                               try (var r = client.get("/").run()) { /* 
Intentionally empty: response is executed and auto-closed via 
try-with-resources; assertions follow. */ }
-                       } catch (Exception ignored) { /* Intentionally empty: 
the request may fail in error/exception scenarios; the test only inspects the 
captured log entries. */ }
-               } catch (Exception e) {
-                       throw new RuntimeException(e);
-               }
-               return entries.get(0);
-       }
 }
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/BasicRestClientDebugFormatter.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/BasicRestClientDebugFormatter.java
new file mode 100644
index 0000000000..4faaea9ad2
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/BasicRestClientDebugFormatter.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.juneau.rest.client;
+
+import static org.apache.juneau.commons.utils.IoUtils.*;
+import static org.apache.juneau.commons.utils.StringUtils.*;
+
+import java.net.*;
+import java.util.*;
+
+import org.apache.juneau.http.*;
+
+/**
+ * Default {@link RestClientDebugFormatter} implementation.
+ *
+ * @since 10.0.0
+ */
+public class BasicRestClientDebugFormatter implements RestClientDebugFormatter 
{
+
+       /** Default body capture cap, in bytes (8 KB). */
+       public static final int DEFAULT_BODY_CAP = 8 * 1024;
+
+       /** The set of header names whose values are masked. */
+       protected Set<String> redactedHeaders = RedactedHeaders.DEFAULT;
+
+       /** The body capture cap in bytes. */
+       protected int bodyCap = DEFAULT_BODY_CAP;
+
+       /** Constructor. */
+       public BasicRestClientDebugFormatter() {}
+
+       /**
+        * Overrides the redacted-header set.
+        *
+        * @param value The new set of header names to mask.
+        * @return This object.
+        */
+       public BasicRestClientDebugFormatter redactedHeaders(Collection<String> 
value) {
+               redactedHeaders = new LinkedHashSet<>(value);
+               return this;
+       }
+
+       /**
+        * Overrides the body capture cap.
+        *
+        * @param value The new cap in bytes.
+        * @return This object.
+        */
+       public BasicRestClientDebugFormatter bodyCap(int value) {
+               bodyCap = value;
+               return this;
+       }
+
+       @Override /* RestClientDebugFormatter */
+       public int bodyCap() {
+               return bodyCap;
+       }
+
+       @Override /* RestClientDebugFormatter */
+       public String formatBasic(RestRequest req, RestResponse res) {
+               var uri = req.getUri();
+               return new StringBuilder()
+                       .append('[').append(res.getStatusCode()).append("] ")
+                       .append("HTTP ").append(req.getMethod()).append(' ')
+                       .append(uri != null ? uri : URI.create(""))
+                       .append(" (").append(req.getExecTime() != null ? 
req.getExecTime().toMillis() : 0).append("ms)")
+                       .toString();
+       }
+
+       @Override /* RestClientDebugFormatter */
+       public String formatHeaders(RestRequest req, RestResponse res) {
+               var sb = new StringBuilder();
+
+               var reqLen = req.getCachedContentLength();
+               if (reqLen >= 0)
+                       sb.append("\n\tRequest length: 
").append(reqLen).append(" bytes");
+
+               sb.append("\n\tResponse code: ").append(res.getStatusCode());
+
+               var resLen = res.getCachedContentLength();
+               if (resLen >= 0)
+                       sb.append("\n\tResponse length: 
").append(resLen).append(" bytes");
+
+               if (!req.getResolvedHeaders().isEmpty()) {
+                       sb.append("\n---Request Headers---");
+                       for (var h : req.getResolvedHeaders())
+                               sb.append("\n\t").append(h.name()).append(": 
").append(RedactedHeaders.redact(h.name(), h.value(), redactedHeaders));
+               }
+
+               if (!res.getHeaders().isEmpty()) {
+                       sb.append("\n---Response Headers---");
+                       for (var h : res.getHeaders())
+                               sb.append("\n\t").append(h.name()).append(": 
").append(RedactedHeaders.redact(h.name(), h.value(), redactedHeaders));
+               }
+               return sb.toString();
+       }
+
+       @Override /* RestClientDebugFormatter */
+       public String formatBody(RestRequest req, RestResponse res) {
+               var sb = new StringBuilder();
+               appendBody(sb, "Request", req.getCachedContent(), 
req.getCachedContentLength());
+               appendBody(sb, "Response", res.getCachedContent(), 
res.getCachedContentLength());
+               return sb.toString();
+       }
+
+       private void appendBody(StringBuilder sb, String label, byte[] content, 
long totalLength) {
+               if (content == null || content.length == 0)
+                       return;
+               try {
+                       sb.append("\n---").append(label).append(" Content 
UTF-8---");
+                       sb.append("\n").append(new String(content, UTF8));
+                       sb.append("\n---").append(label).append(" Content 
Hex---");
+                       sb.append("\n").append(toSpacedHex(content));
+                       var omitted = totalLength - content.length;
+                       if (omitted > 0)
+                               sb.append("\n…[truncated 
").append(omitted).append(" bytes]");
+               } catch (Exception e) {
+                       sb.append("\n").append(e.getLocalizedMessage());
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/BasicRestLogger.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/BasicRestLogger.java
deleted file mode 100644
index 938ddf8f7b..0000000000
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/BasicRestLogger.java
+++ /dev/null
@@ -1,251 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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.
- */
-package org.apache.juneau.rest.client;
-
-import java.util.function.*;
-
-/**
- * Convenience {@link RestLogger} implementation that routes log entries to a 
{@link java.lang.System.Logger}.
- *
- * <p>
- * Uses the JDK 9+ {@link java.lang.System.Logger} facade, which automatically 
routes to whatever logging framework is on
- * the classpath (SLF4J, Log4j 2, Logback, JUL) with no additional dependency.
- *
- * <h5 class='section'>Example — shorthand:</h5>
- * <p class='bjava'>
- *     RestClient <jv>client</jv> = RestClient.<jsm>builder</jsm>()
- *             
.logger(BasicRestLogger.<jsm>of</jsm>(System.<jsm>getLogger</jsm>(<js>"myapp.http"</js>)))
- *             .build();
- * </p>
- *
- * <h5 class='section'>Example — custom level resolver and template:</h5>
- * <p class='bjava'>
- *     RestClient <jv>client</jv> = RestClient.<jsm>builder</jsm>()
- *             .logger(BasicRestLogger.<jsm>create</jsm>()
- *                     
.logger(System.<jsm>getLogger</jsm>(<js>"myapp.http"</js>))
- *                     .infoTemplate(<js>"{method} {uri} -> {status} 
({elapsed})"</js>)
- *                     .errorTemplate(<js>"{method} {uri} -> {error} 
({elapsed})"</js>)
- *                     .build())
- *             .build();
- * </p>
- *
- * <p>
- * <b>Default behavior</b> (no filter, all requests logged, using {@link 
RestLogLevelResolver#DEFAULT}):
- * <ul>
- *     <li>{@code INFO} → {@code "GET https://api.example.com/users -> 200 OK 
(42ms)"}
- *     <li>{@code WARNING} → {@code "GET https://api.example.com/users -> 404 
Not Found (8ms)"}
- *     <li>{@code ERROR} → {@code "GET https://api.example.com/users -> 
connection refused (12ms)"} + exception
- * </ul>
- *
- * <p>
- * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack.
- * It is not API-frozen: binary- and source-incompatible changes may appear in 
the <b>next major</b> Juneau release
- * (and possibly earlier).
- *
- * <h5 class='section'>See Also:</h5><ul>
- *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/NextGenRestClient";>juneau-ng REST 
client</a>
- * </ul>
- *
- * @since 9.2.1
- */
-public final class BasicRestLogger implements RestLogger {
-
-       private static final String DEFAULT_INFO_TEMPLATE    = "{method} {uri} 
-> {status} {reason} ({elapsed})";
-       private static final String DEFAULT_WARNING_TEMPLATE = "{method} {uri} 
-> {status} {reason} ({elapsed})";
-       private static final String DEFAULT_ERROR_TEMPLATE   = "{method} {uri} 
-> {error} ({elapsed})";
-       private static final String DEFAULT_DEBUG_TEMPLATE   =
-               "--> {method} {uri}\n  (elapsed: {elapsed}, status: {status} 
{reason})";
-
-       private final System.Logger logger;
-       private final RestLogLevelResolver levelResolver;
-       private final Predicate<RestLogEntry> filter;
-       private final String infoTemplate;
-       private final String warningTemplate;
-       private final String errorTemplate;
-       private final String debugTemplate;
-
-       private BasicRestLogger(Builder builder) {
-               this.logger = builder.logger;
-               this.levelResolver = builder.levelResolver;
-               this.filter = builder.filter;
-               this.infoTemplate = builder.infoTemplate;
-               this.warningTemplate = builder.warningTemplate;
-               this.errorTemplate = builder.errorTemplate;
-               this.debugTemplate = builder.debugTemplate;
-       }
-
-       /**
-        * Creates a {@link BasicRestLogger} using {@link 
RestLogLevelResolver#DEFAULT} and default templates.
-        *
-        * @param logger The target {@link java.lang.System.Logger}. Must not 
be <jk>null</jk>.
-        * @return A new instance. Never <jk>null</jk>.
-        */
-       public static BasicRestLogger of(System.Logger logger) {
-               return create().logger(logger).build();
-       }
-
-       /**
-        * Returns a new builder for full configuration.
-        *
-        * @return A new builder. Never <jk>null</jk>.
-        */
-       public static Builder create() {
-               return new Builder();
-       }
-
-       @Override /* RestLogger */
-       public void log(RestLogEntry entry) {
-               if (filter != null && !filter.test(entry))
-                       return;
-
-               if (entry.isDebug()) {
-                       logger.log(System.Logger.Level.DEBUG, 
entry.format(debugTemplate));
-                       return;
-               }
-
-               // Use this logger's own resolver (may differ from the DEFAULT 
used to compute entry.getLevel())
-               var level = levelResolver.resolve(entry);
-
-               var template = switch (level) {
-                       case ERROR   -> errorTemplate;
-                       case WARNING -> warningTemplate;
-                       default      -> infoTemplate;
-               };
-
-               var message = entry.format(template);
-
-               if (level == System.Logger.Level.ERROR && entry.getError() != 
null)
-                       logger.log(level, message, entry.getError());
-               else
-                       logger.log(level, message);
-       }
-
-       // 
-----------------------------------------------------------------------------------------------------------------
-       // Builder
-       // 
-----------------------------------------------------------------------------------------------------------------
-
-       /**
-        * Fluent builder for {@link BasicRestLogger}.
-        *
-        * @since 9.2.1
-        */
-       public static final class Builder {
-
-               System.Logger logger;
-               RestLogLevelResolver levelResolver = 
RestLogLevelResolver.DEFAULT;
-               Predicate<RestLogEntry> filter;
-               String infoTemplate = DEFAULT_INFO_TEMPLATE;
-               String warningTemplate = DEFAULT_WARNING_TEMPLATE;
-               String errorTemplate = DEFAULT_ERROR_TEMPLATE;
-               String debugTemplate = DEFAULT_DEBUG_TEMPLATE;
-
-               private Builder() {}
-
-               /**
-                * Sets the target {@link java.lang.System.Logger} to emit log 
messages to.
-                *
-                * @param value The logger. Must not be <jk>null</jk>.
-                * @return This object.
-                */
-               public Builder logger(System.Logger value) {
-                       logger = value;
-                       return this;
-               }
-
-               /**
-                * Sets the level resolver used to compute the log level for 
each entry.
-                *
-                * <p>
-                * Defaults to {@link RestLogLevelResolver#DEFAULT}.
-                *
-                * @param value The resolver. Must not be <jk>null</jk>.
-                * @return This object.
-                */
-               public Builder levelResolver(RestLogLevelResolver value) {
-                       levelResolver = value;
-                       return this;
-               }
-
-               /**
-                * Sets a filter that controls which entries are logged.
-                *
-                * <p>
-                * Only entries for which the predicate returns {@code true} 
will be logged.
-                * By default, all entries are logged.
-                *
-                * @param value The filter predicate. Can be <jk>null</jk> (no 
filtering \u2014 all entries are logged).
-                * @return This object.
-                */
-               public Builder filter(Predicate<RestLogEntry> value) {
-                       filter = value;
-                       return this;
-               }
-
-               /**
-                * Sets the message template used for {@link 
java.lang.System.Logger.Level#INFO} entries.
-                *
-                * @param value The template string. Must not be <jk>null</jk>.
-                * @return This object.
-                */
-               public Builder infoTemplate(String value) {
-                       infoTemplate = value;
-                       return this;
-               }
-
-               /**
-                * Sets the message template used for {@link 
java.lang.System.Logger.Level#WARNING} entries.
-                *
-                * @param value The template string. Must not be <jk>null</jk>.
-                * @return This object.
-                */
-               public Builder warningTemplate(String value) {
-                       warningTemplate = value;
-                       return this;
-               }
-
-               /**
-                * Sets the message template used for {@link 
java.lang.System.Logger.Level#ERROR} entries.
-                *
-                * @param value The template string. Must not be <jk>null</jk>.
-                * @return This object.
-                */
-               public Builder errorTemplate(String value) {
-                       errorTemplate = value;
-                       return this;
-               }
-
-               /**
-                * Sets the message template used for debug entries (when 
{@link RestLogEntry#isDebug()} is {@code true}).
-                *
-                * @param value The template string. Must not be <jk>null</jk>.
-                * @return This object.
-                */
-               public Builder debugTemplate(String value) {
-                       debugTemplate = value;
-                       return this;
-               }
-
-               /**
-                * Builds and returns the {@link BasicRestLogger}.
-                *
-                * @return A new instance. Never <jk>null</jk>.
-                */
-               public BasicRestLogger build() {
-                       return new BasicRestLogger(this);
-               }
-       }
-}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
index a2863c3c58..298ab5d1d3 100644
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
@@ -24,7 +24,9 @@ import java.io.*;
 import java.util.*;
 import java.util.function.*;
 import java.util.stream.*;
+import java.util.logging.*;
 
+import org.apache.juneau.commons.logging.*;
 import org.apache.juneau.commons.http.*;
 import org.apache.juneau.http.*;
 import org.apache.juneau.http.entity.*;
@@ -94,7 +96,8 @@ public final class RestClient implements Closeable {
        final List<HttpPart> defaultQueryData;
        final String rootUrl;
        final List<RestCallInterceptor> interceptors;
-       final RestLogger logger;
+       final RichLogger debugLogger;
+       final RestClientDebugFormatter debugFormatter;
        final List<BodyConverter<?>> bodyConverters;
        final SerializerSet serializers;
        final ParserSet parsers;
@@ -108,7 +111,8 @@ public final class RestClient implements Closeable {
                this.defaultQueryData = List.copyOf(builder.defaultQueryData);
                this.rootUrl = builder.rootUrl;
                this.interceptors = List.copyOf(builder.interceptors);
-               this.logger = builder.logger;
+               this.debugLogger = RichLogger.getLogger(builder.debugLoggerName 
!= null ? builder.debugLoggerName : RestClient.class.getName());
+               this.debugFormatter = builder.debugFormatter != null ? 
builder.debugFormatter : new BasicRestClientDebugFormatter();
                this.bodyConverters = List.copyOf(builder.bodyConverters);
                this.serializers = builder.serializers;
                this.parsers = builder.parsers;
@@ -380,7 +384,8 @@ public final class RestClient implements Closeable {
                final List<HttpPart> defaultQueryData = l();
                String rootUrl;
                final List<RestCallInterceptor> interceptors = l();
-               RestLogger logger;
+               RestClientDebugFormatter debugFormatter;
+               String debugLoggerName;
                List<BodyConverter<?>> bodyConverters = new 
ArrayList<>(DEFAULT_BODY_CONVERTERS);
                SerializerSet serializers;
                ParserSet parsers;
@@ -488,16 +493,39 @@ public final class RestClient implements Closeable {
                }
 
                /**
-                * Sets the logger called at the end of every request (success 
or failure).
+                * Sets the formatter used for level-driven REST debug logging.
                 *
-                * @param value The logger. May be <jk>null</jk> to disable 
logging.
+                * <p>
+                * The formatter renders cumulative content based on the 
resolved debug logger level:
+                * <ul>
+                *      <li>{@link Level#INFO INFO} - basic line ({@link 
RestClientDebugFormatter#formatBasic(RestRequest, RestResponse)}).
+                *      <li>{@link Level#FINE FINE} - basic + headers.
+                *      <li>{@link Level#FINEST FINEST} - basic + headers + 
body.
+                * </ul>
+                *
+                * @param value The formatter. Can be <jk>null</jk> to restore 
the default formatter.
+                * @return This object.
+                */
+               public Builder debugFormatter(RestClientDebugFormatter value) {
+                       debugFormatter = value;
+                       return this;
+               }
+
+               /**
+                * Sets the logger name used for REST debug records.
+                *
+                * <p>
+                * If not set, defaults to {@code RestClient.class.getName()}.
+                *
+                * @param value The logger name. Can be <jk>null</jk> to use 
the default logger name.
                 * @return This object.
                 */
-               public Builder logger(RestLogger value) {
-                       logger = value;
+               public Builder debugLoggerName(String value) {
+                       debugLoggerName = value;
                        return this;
                }
 
+
                /**
                 * Prepends custom body converters to the default converter 
list.
                 *
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClientDebugFormatter.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClientDebugFormatter.java
new file mode 100644
index 0000000000..d7e0ae5f08
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClientDebugFormatter.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.juneau.rest.client;
+
+/**
+ * Per-tier formatter for JUL-level-driven REST client debug logging.
+ *
+ * <p>
+ * The client debug pipeline invokes these methods cumulatively based on the 
resolved logger level:
+ * <ul>
+ *     <li>{@link #formatBasic(RestRequest, RestResponse)} at {@code INFO}.
+ *     <li>{@link #formatHeaders(RestRequest, RestResponse)} added at {@code 
FINE}.
+ *     <li>{@link #formatBody(RestRequest, RestResponse)} added at {@code 
FINEST}.
+ * </ul>
+ *
+ * <p>
+ * <b>Beta - API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack.
+ *
+ * @since 10.0.0
+ */
+public interface RestClientDebugFormatter {
+
+       /**
+        * Renders the basic ({@code INFO}-tier) portion of a debug record.
+        *
+        * @param req The request.
+        * @param res The response.
+        * @return The formatted message chunk.
+        */
+       String formatBasic(RestRequest req, RestResponse res);
+
+       /**
+        * Renders the headers ({@code FINE}-tier) portion of a debug record.
+        *
+        * @param req The request.
+        * @param res The response.
+        * @return The formatted message chunk.
+        */
+       default String formatHeaders(RestRequest req, RestResponse res) {
+               return "";
+       }
+
+       /**
+        * Renders the body ({@code FINEST}-tier) portion of a debug record.
+        *
+        * @param req The request.
+        * @param res The response.
+        * @return The formatted message chunk.
+        */
+       default String formatBody(RestRequest req, RestResponse res) {
+               return "";
+       }
+
+       /**
+        * Maximum number of request/response body bytes to capture for {@code 
FINEST} rendering.
+        *
+        * @return The capture cap in bytes.
+        */
+       default int bodyCap() {
+               return 8 * 1024;
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClientDebugPipeline.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClientDebugPipeline.java
new file mode 100644
index 0000000000..90b1cab517
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClientDebugPipeline.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.juneau.rest.client;
+
+import java.util.logging.*;
+
+import org.apache.juneau.commons.logging.RichLogger;
+
+final class RestClientDebugPipeline {
+
+       private RestClientDebugPipeline() {}
+
+       static Level resolveTier(RichLogger logger) {
+               if (logger.isLoggable(Level.FINEST))
+                       return Level.FINEST;
+               if (logger.isLoggable(Level.FINE))
+                       return Level.FINE;
+               if (logger.isLoggable(Level.INFO))
+                       return Level.INFO;
+               return null;
+       }
+
+       static void emit(RichLogger logger, RestClientDebugFormatter formatter, 
Level level, RestRequest req, RestResponse res, Throwable thrown) {
+               if (level == null)
+                       return;
+
+               var sb = new StringBuilder(formatter.formatBasic(req, res));
+               if (level.intValue() <= Level.FINE.intValue())
+                       sb.append(formatter.formatHeaders(req, res));
+               if (level.intValue() <= Level.FINEST.intValue())
+                       sb.append(formatter.formatBody(req, res));
+
+               var record = new LogRecord(level, sb.toString());
+               record.setLoggerName(logger.getName());
+               if (thrown != null)
+                       record.setThrown(thrown);
+               logger.log(record);
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogEntry.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogEntry.java
deleted file mode 100644
index 8668f0dedf..0000000000
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogEntry.java
+++ /dev/null
@@ -1,298 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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.
- */
-package org.apache.juneau.rest.client;
-
-import static org.apache.juneau.commons.utils.Shorts.*;
-
-import java.time.*;
-
-/**
- * Structured log entry produced after every {@link RestRequest#run()} 
invocation.
- *
- * <p>
- * Passed to a {@link RestLogger} in the {@code finally} block of {@link 
RestRequest#run()}.
- * When the transport fails before a response is received, {@link 
#getResponse()} is {@code null}
- * and {@link #getError()} is non-{@code null}.
- *
- * <p>
- * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack.
- * It is not API-frozen: binary- and source-incompatible changes may appear in 
the <b>next major</b> Juneau release
- * (and possibly earlier).
- *
- * <h5 class='section'>See Also:</h5><ul>
- *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/NextGenRestClient";>juneau-ng REST 
client</a>
- * </ul>
- *
- * @since 9.2.1
- */
-@SuppressWarnings({
-       "resource" // Eclipse resource analysis: response is borrowed for 
logging snapshot only
-})
-public final class RestLogEntry {
-
-       private final RestRequest request;
-
-       private final RestResponse response;
-       private final Throwable error;
-       private final Duration elapsed;
-       private final boolean debug;
-       private final System.Logger.Level level;
-
-       RestLogEntry(RestRequest request, RestResponse response, Throwable 
error, Duration elapsed, boolean debug, System.Logger.Level level) {
-               this.request = request;
-               this.response = response;
-               this.error = error;
-               this.elapsed = elapsed;
-               this.debug = debug;
-               this.level = level;
-       }
-
-       /**
-        * Returns the request that triggered this log entry.
-        *
-        * @return The request. Never <jk>null</jk>.
-        */
-       public RestRequest getRequest() {
-               return request;
-       }
-
-       /**
-        * Returns the response received, or {@code null} if the transport 
failed before a response was received.
-        *
-        * @return The response, or <jk>null</jk> if the transport failed 
before a response was received.
-        */
-       public RestResponse getResponse() {
-               return response;
-       }
-
-       /**
-        * Returns the error that occurred, or {@code null} on success.
-        *
-        * @return The error, or <jk>null</jk> on success.
-        */
-       public Throwable getError() {
-               return error;
-       }
-
-       /**
-        * Returns the elapsed time for the request (from before {@code onInit} 
to after the response is received).
-        *
-        * @return The elapsed time. Never <jk>null</jk>.
-        */
-       public Duration getElapsed() {
-               return elapsed;
-       }
-
-       /**
-        * Returns {@code true} if {@link RestRequest#debug()} was called on 
the originating request.
-        *
-        * @return {@code true} if this is a debug request.
-        */
-       public boolean isDebug() {
-               return debug;
-       }
-
-       /**
-        * Returns the log level computed by the configured {@link 
RestLogLevelResolver}.
-        *
-        * @return The log level. Never <jk>null</jk>.
-        */
-       public System.Logger.Level getLevel() {
-               return level;
-       }
-
-       /**
-        * Returns {@code true} if the call resulted in an error (transport 
error or HTTP status ≥ 400).
-        *
-        * @return {@code true} if this entry represents an error condition.
-        */
-       public boolean isError() {
-               return error != null || getStatusCode() >= 400;
-       }
-
-       /**
-        * Returns the HTTP status code, or {@code 0} if no response was 
received.
-        *
-        * @return The status code.
-        */
-       public int getStatusCode() {
-               return response != null ? response.getStatusCode() : 0;
-       }
-
-       /**
-        * Returns {@code true} if the response contains a header with the 
given name (case-insensitive).
-        *
-        * @param name The header name. Must not be <jk>null</jk>.
-        * @return {@code true} if the header is present.
-        */
-       public boolean hasResponseHeader(String name) {
-               return response != null && response.getFirstHeader(name) != 
null;
-       }
-
-       /**
-        * Formats the entry using the default template: {@code "METHOD URI -> 
STATUS REASON (ELAPSEDms)"}.
-        *
-        * @return A formatted string. Never <jk>null</jk>.
-        */
-       public String format() {
-               return format("{method} {uri} -> {status} {reason} 
({elapsed})");
-       }
-
-       /**
-        * Formats the entry using a named-placeholder template.
-        *
-        * <p>
-        * Supported placeholders:
-        * <ul>
-        *      <li>{@code {method}} — HTTP method (e.g. {@code GET})
-        *      <li>{@code {uri}} — Full request URI
-        *      <li>{@code {status}} — Response status code, or {@code 0} if no 
response
-        *      <li>{@code {reason}} — Response reason phrase, or empty string
-        *      <li>{@code {elapsed}} — Elapsed time (e.g. {@code 42ms})
-        *      <li>{@code {error}} — Error message, or empty string if no error
-        * </ul>
-        *
-        * @param template The template string. Must not be <jk>null</jk>.
-        * @return A formatted string. Never <jk>null</jk>.
-        */
-       public String format(String template) {
-               var method = request.getMethod();
-               var uri = request.getUri() != null ? 
request.getUri().toString() : ""; // HTT: uri is always set when format() is 
called from logger
-               var status = getStatusCode() > 0 ? 
String.valueOf(getStatusCode()) : "0";
-               var reason = response != null && response.getReasonPhrase() != 
null ? response.getReasonPhrase() : "";
-               var elapsedMs = elapsed.toMillis() + "ms";
-               var errorMsg = "";
-               if (error != null)
-                       errorMsg = error.getMessage() != null ? 
error.getMessage() : cns(error);
-
-               return template
-                       .replace("{method}", method)
-                       .replace("{uri}", uri)
-                       .replace("{status}", status)
-                       .replace("{reason}", reason)
-                       .replace("{elapsed}", elapsedMs)
-                       .replace("{error}", errorMsg);
-       }
-
-       @Override /* Object */
-       public String toString() {
-               return format();
-       }
-
-       /**
-        * Creates a new builder.
-        *
-        * @return A new builder. Never <jk>null</jk>.
-        */
-       public static Builder builder() {
-               return new Builder();
-       }
-
-       /**
-        * Builder for {@link RestLogEntry}.
-        *
-        * @since 9.2.1
-        */
-       public static final class Builder {
-
-               RestRequest request;
-
-               RestResponse response;
-               Throwable error;
-               Duration elapsed = Duration.ZERO;
-               boolean debug;
-               RestLogLevelResolver levelResolver = 
RestLogLevelResolver.DEFAULT;
-
-               private Builder() {}
-
-               /**
-                * Sets the request.
-                *
-                * @param value The request. Must not be <jk>null</jk>.
-                * @return This object.
-                */
-               public Builder request(RestRequest value) {
-                       request = value;
-                       return this;
-               }
-
-               /**
-                * Sets the response.
-                *
-                * @param value The response. May be <jk>null</jk> if transport 
failed before receiving a response.
-                * @return This object.
-                */
-               public Builder response(RestResponse value) {
-                       response = value;
-                       return this;
-               }
-
-               /**
-                * Sets the error.
-                *
-                * @param value The error. May be <jk>null</jk> on success.
-                * @return This object.
-                */
-               public Builder error(Throwable value) {
-                       error = value;
-                       return this;
-               }
-
-               /**
-                * Sets the elapsed time.
-                *
-                * @param value The elapsed duration. Must not be <jk>null</jk>.
-                * @return This object.
-                */
-               public Builder elapsed(Duration value) {
-                       elapsed = value;
-                       return this;
-               }
-
-               /**
-                * Sets whether this was a debug request.
-                *
-                * @param value {@code true} if {@link RestRequest#debug()} was 
called.
-                * @return This object.
-                */
-               public Builder debug(boolean value) {
-                       debug = value;
-                       return this;
-               }
-
-               /**
-                * Sets the level resolver used to compute {@link 
RestLogEntry#getLevel()}.
-                *
-                * @param value The resolver. Must not be <jk>null</jk>.
-                * @return This object.
-                */
-               public Builder levelResolver(RestLogLevelResolver value) {
-                       levelResolver = value;
-                       return this;
-               }
-
-               /**
-                * Builds the {@link RestLogEntry}.
-                *
-                * @return A new instance. Never <jk>null</jk>.
-                */
-               public RestLogEntry build() {
-                       var entry = new RestLogEntry(request, response, error, 
elapsed, debug, null);
-                       return new RestLogEntry(request, response, error, 
elapsed, debug, levelResolver.resolve(entry));
-               }
-       }
-}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogLevelResolver.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogLevelResolver.java
deleted file mode 100644
index 65a274992f..0000000000
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogLevelResolver.java
+++ /dev/null
@@ -1,155 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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.
- */
-package org.apache.juneau.rest.client;
-
-import static org.apache.juneau.commons.utils.Shorts.*;
-
-import java.util.*;
-import java.util.function.*;
-
-/**
- * Determines the {@link java.lang.System.Logger.Level} for a {@link 
RestLogEntry}.
- *
- * <p>
- * Used by {@link BasicRestLogger} (and available to custom {@link RestLogger} 
implementations via
- * {@link RestLogEntry#getLevel()}) to decide how to emit each log line.
- *
- * <p>
- * The built-in {@link #DEFAULT} resolver applies these ordered rules:
- * <ul>
- *     <li>{@link java.lang.System.Logger.Level#ERROR} — transport error or 
HTTP status ≥ 500
- *     <li>{@link java.lang.System.Logger.Level#WARNING} — HTTP status ≥ 400, 
or response contains a {@code Thrown} header
- *     <li>{@link java.lang.System.Logger.Level#INFO} — all other calls
- * </ul>
- *
- * <p>
- * Custom resolvers can be built using the fluent {@link #rules()} builder:
- *
- * <h5 class='section'>Example:</h5>
- * <p class='bjava'>
- *     RestLogLevelResolver <jv>resolver</jv> = 
RestLogLevelResolver.<jsm>rules</jsm>()
- *             .rule(System.Logger.Level.<jsf>ERROR</jsf>, <jv>e</jv> -&gt; 
<jv>e</jv>.getStatusCode() &gt;= 500)
- *             .rule(System.Logger.Level.<jsf>WARNING</jsf>, <jv>e</jv> -&gt; 
<jv>e</jv>.getStatusCode() &gt;= 400)
- *             .defaultLevel(System.Logger.Level.<jsf>INFO</jsf>)
- *             .build();
- * </p>
- *
- * <p>
- * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack.
- * It is not API-frozen: binary- and source-incompatible changes may appear in 
the <b>next major</b> Juneau release
- * (and possibly earlier).
- *
- * <h5 class='section'>See Also:</h5><ul>
- *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/NextGenRestClient";>juneau-ng REST 
client</a>
- * </ul>
- *
- * @since 9.2.1
- */
-@FunctionalInterface
-public interface RestLogLevelResolver {
-
-       /**
-        * Computes the log level for the given entry.
-        *
-        * @param entry The log entry. Never <jk>null</jk>.
-        * @return The computed level. Never <jk>null</jk>.
-        */
-       System.Logger.Level resolve(RestLogEntry entry);
-
-       /**
-        * The default level resolver.
-        *
-        * <ul>
-        *      <li>{@link java.lang.System.Logger.Level#ERROR} — transport 
error ({@code getError() != null}) or HTTP status ≥ 500
-        *      <li>{@link java.lang.System.Logger.Level#WARNING} — HTTP status 
≥ 400, or response has a {@code Thrown} header
-        *        (a Juneau server-side header indicating an exception was 
thrown)
-        *      <li>{@link java.lang.System.Logger.Level#INFO} — all other calls
-        * </ul>
-        */
-       RestLogLevelResolver DEFAULT = rules()
-               .rule(System.Logger.Level.ERROR, e -> e.getError() != null || 
e.getStatusCode() >= 500)
-               .rule(System.Logger.Level.WARNING, e -> e.getStatusCode() >= 
400 || e.hasResponseHeader("Thrown"))
-               .defaultLevel(System.Logger.Level.INFO)
-               .build();
-
-       /**
-        * Returns a new {@link RuleBuilder} for constructing ordered 
predicate-based level resolvers.
-        *
-        * @return A new builder. Never <jk>null</jk>.
-        */
-       static RuleBuilder rules() {
-               return new RuleBuilder();
-       }
-
-       /**
-        * Fluent builder for ordered predicate-based {@link 
RestLogLevelResolver} instances.
-        *
-        * <p>
-        * Rules are evaluated in registration order; the first matching rule 
wins. If no rule matches,
-        * the {@link #defaultLevel(System.Logger.Level)} is used (default: 
{@link java.lang.System.Logger.Level#INFO}).
-        *
-        * @since 9.2.1
-        */
-       class RuleBuilder {
-
-               private final List<Rule> rules = l();
-               private System.Logger.Level defaultLevel = 
System.Logger.Level.INFO;
-
-               private RuleBuilder() {}
-
-               /**
-                * Adds a rule that maps to the given level when the predicate 
matches.
-                *
-                * @param level The level to emit when the predicate is true. 
Must not be <jk>null</jk>.
-                * @param when The predicate tested against each log entry. 
Must not be <jk>null</jk>.
-                * @return This object.
-                */
-               public RuleBuilder rule(System.Logger.Level level, 
Predicate<RestLogEntry> when) {
-                       rules.add(new Rule(level, when));
-                       return this;
-               }
-
-               /**
-                * Sets the fallback level used when no rule matches.
-                *
-                * @param value The default level. Must not be <jk>null</jk>.
-                * @return This object.
-                */
-               public RuleBuilder defaultLevel(System.Logger.Level value) {
-                       defaultLevel = value;
-                       return this;
-               }
-
-               /**
-                * Builds and returns the {@link RestLogLevelResolver}.
-                *
-                * @return A new instance. Never <jk>null</jk>.
-                */
-               public RestLogLevelResolver build() {
-                       var snapshot = List.copyOf(rules);
-                       var fallback = defaultLevel;
-                       return entry -> {
-                               for (var rule : snapshot)
-                                       if (rule.when().test(entry))
-                                               return rule.level();
-                               return fallback;
-                       };
-               }
-
-               private record Rule(System.Logger.Level level, 
Predicate<RestLogEntry> when) {}
-       }
-}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogger.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogger.java
deleted file mode 100644
index e1dfce4cf4..0000000000
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogger.java
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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.
- */
-package org.apache.juneau.rest.client;
-
-/**
- * Pluggable logging interface for REST calls made by {@link RestClient}.
- *
- * <p>
- * Called in the {@code finally} block of every {@link RestRequest#run()} 
invocation, regardless of success or failure.
- * The {@link RestLogEntry} carries the request, response (or {@code null} on 
transport error), elapsed time, and error.
- *
- * <p>
- * This is a {@link FunctionalInterface} — lambda expressions and method 
references are valid implementations:
- *
- * <h5 class='section'>Example:</h5>
- * <p class='bjava'>
- *     RestClient <jv>client</jv> = RestClient.<jsm>builder</jsm>()
- *             .transport(<jv>transport</jv>)
- *             .logger(<jv>entry</jv> -&gt; 
System.<jf>out</jf>.println(<jv>entry</jv>.format()))
- *             .build();
- * </p>
- *
- * <p>
- * Multiple loggers can be combined using lambda composition:
- * <p class='bjava'>
- *     RestLogger <jv>combined</jv> = <jv>entry</jv> -&gt; { 
<jv>errorLogger</jv>.log(<jv>entry</jv>); 
<jv>perfLogger</jv>.log(<jv>entry</jv>); };
- * </p>
- *
- * <p>
- * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack.
- * It is not API-frozen: binary- and source-incompatible changes may appear in 
the <b>next major</b> Juneau release
- * (and possibly earlier).
- *
- * <h5 class='section'>See Also:</h5><ul>
- *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/NextGenRestClient";>juneau-ng REST 
client</a>
- * </ul>
- *
- * @since 9.2.1
- */
-@FunctionalInterface
-public interface RestLogger {
-
-       /**
-        * Receives a log entry for a completed (or failed) REST call.
-        *
-        * @param entry The log entry. Never <jk>null</jk>.
-        */
-       void log(RestLogEntry entry);
-}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestRequest.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestRequest.java
index b837d69d3f..0ddaf93ba8 100644
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestRequest.java
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestRequest.java
@@ -24,6 +24,7 @@ import java.nio.charset.*;
 import java.time.*;
 import java.util.*;
 import java.util.function.*;
+import java.util.logging.*;
 
 import org.apache.juneau.http.*;
 import org.apache.juneau.http.entity.*;
@@ -59,11 +60,15 @@ public final class RestRequest {
        private final Map<String,Object> pathData = m();
        private HttpBody body;
        private TransportBody convertedBody;
-       private boolean debug;
        private URI resolvedUri;
        private Duration timeout;
        // Per-request interceptors, unioned with the client-level interceptors 
at run() time.
        private final List<RestCallInterceptor> requestInterceptors = l();
+       private List<TransportHeader> resolvedHeaders = List.of();
+       private byte[] cachedContent;
+       private long cachedContentLength = -1;
+       private Throwable exception;
+       private Duration execTime;
 
        RestRequest(RestClient client, String method, String url) {
                this.client = client;
@@ -326,24 +331,6 @@ public final class RestRequest {
                return this;
        }
 
-       // --------------------------------------------------
-       // Debug
-       // --------------------------------------------------
-
-       /**
-        * Flags this request for verbose debug logging.
-        *
-        * <p>
-        * When set, the configured {@link RestLogger} will receive an entry 
with {@link RestLogEntry#isDebug()} {@code true},
-        * enabling full request/response header and body logging for this call 
only.
-        *
-        * @return This object.
-        */
-       public RestRequest debug() {
-               debug = true;
-               return this;
-       }
-
        // --------------------------------------------------
        // Timeout / interceptors
        // --------------------------------------------------
@@ -420,6 +407,51 @@ public final class RestRequest {
                return resolvedUri;
        }
 
+       /**
+        * Returns the headers resolved onto the transport request.
+        *
+        * @return The resolved headers in wire order.
+        */
+       public List<TransportHeader> getResolvedHeaders() {
+               return resolvedHeaders;
+       }
+
+       /**
+        * Returns request body bytes captured for debug logging (up to the 
configured body cap).
+        *
+        * @return Captured request bytes, or <jk>null</jk> if not captured.
+        */
+       public byte[] getCachedContent() {
+               return cachedContent;
+       }
+
+       /**
+        * Returns the total request body length observed during write, or 
{@code -1} if unknown.
+        *
+        * @return The total body length.
+        */
+       public long getCachedContentLength() {
+               return cachedContentLength;
+       }
+
+       /**
+        * Returns the terminal request exception, if any.
+        *
+        * @return The terminal exception, or <jk>null</jk>.
+        */
+       public Throwable getException() {
+               return exception;
+       }
+
+       /**
+        * Returns the measured request execution time.
+        *
+        * @return The request execution time, or <jk>null</jk> if unset.
+        */
+       public Duration getExecTime() {
+               return execTime;
+       }
+
        // --------------------------------------------------
        // Execute
        // --------------------------------------------------
@@ -441,6 +473,8 @@ public final class RestRequest {
                var start = Instant.now();
                RestResponse response = null;
                Throwable error = null;
+               var debugLevel = 
RestClientDebugPipeline.resolveTier(client.debugLogger);
+               var debugCap = client.debugFormatter.bodyCap();
                // Union the client-level (builder) interceptors with any 
per-request (interface- then
                // method-level) interceptors, preserving the order builder → 
interface → method.
                var effectiveInterceptors = effectiveInterceptors();
@@ -449,12 +483,22 @@ public final class RestRequest {
                                interceptor.onInit(this);
 
                        var transportRequest = buildTransportRequest();
+                       resolvedHeaders = transportRequest.getHeaders();
+                       if (transportRequest.getBody() == null) {
+                               cachedContentLength = 0;
+                       } else {
+                               cachedContentLength = 
transportRequest.getBody().getContentLength();
+                       }
+                       if (debugLevel == Level.FINEST)
+                               transportRequest = 
captureRequestBody(transportRequest, debugCap);
                        var transportResponse = 
client.transport.execute(transportRequest);
-                       response = new RestResponse(transportResponse, client);
+                       response = new RestResponse(transportResponse, client, 
this, debugLevel, debugCap);
 
                        for (var interceptor : effectiveInterceptors)
                                interceptor.onConnect(this, response);
 
+                       execTime = Duration.between(start, Instant.now());
+                       response.setExecTime(execTime);
                        return response;
                } catch (TransportException | RestCallException e) {
                        error = e;
@@ -464,12 +508,15 @@ public final class RestRequest {
                        throw new TransportException("Request failed: " + 
e.getMessage(), e);
                } finally {
                        var elapsed = Duration.between(start, Instant.now());
+                       execTime = elapsed;
+                       exception = error;
                        RestResponse finalResponse = response;
                        if (error != null && finalResponse != null) {
                                // The response was assigned but an error after 
that point (e.g. a throwing onConnect
                                // interceptor) means it is never returned to 
the caller, so the caller can never close it
                                // themselves — close it here to avoid leaking 
the underlying connection/body.
                                try {
+                                       finalResponse.setExecTime(elapsed);
                                        finalResponse.close();
                                } catch (IOException closeError) { // HTT: 
close() failing on an already-broken response is not reliably reproducible
                                        // suppress — best effort, and the 
original error is what propagates
@@ -482,15 +529,10 @@ public final class RestRequest {
                                        // suppress interceptor close errors — 
best effort
                                }
                        }
-                       if (client.logger != null) {
-                               var entry = RestLogEntry.builder()
-                                       .request(this)
-                                       .response(finalResponse)
-                                       .error(error)
-                                       .elapsed(elapsed)
-                                       .debug(debug)
-                                       .build();
-                               client.logger.log(entry);
+                       if (error != null && finalResponse == null) {
+                               var syntheticResponse = new 
RestResponse(TransportResponse.builder().statusCode(0).build(), client, this, 
debugLevel, debugCap);
+                               syntheticResponse.setExecTime(elapsed);
+                               
RestClientDebugPipeline.emit(client.debugLogger, client.debugFormatter, 
debugLevel, this, syntheticResponse, error);
                        }
                }
        }
@@ -598,6 +640,51 @@ public final class RestRequest {
                return builder.build();
        }
 
+       private TransportRequest captureRequestBody(TransportRequest request, 
int cap) {
+               var originalBody = request.getBody();
+               if (originalBody == null) {
+                       cachedContent = null;
+                       cachedContentLength = 0;
+                       return request;
+               }
+
+               var wrappedBody = new HttpBody() {
+                       @Override
+                       public String getContentType() {
+                               return originalBody.getContentType();
+                       }
+
+                       @Override
+                       public long getContentLength() {
+                               return originalBody.getContentLength();
+                       }
+
+                       @Override
+                       public void writeTo(OutputStream out) throws 
IOException {
+                               var tee = new BoundedCaptureOutputStream(out, 
cap);
+                               try {
+                                       originalBody.writeTo(tee);
+                               } finally {
+                                       cachedContent = tee.getCapturedBytes();
+                                       cachedContentLength = 
tee.getTotalBytesWritten();
+                               }
+                       }
+
+                       @Override
+                       public boolean isRepeatable() {
+                               return originalBody.isRepeatable();
+                       }
+               };
+
+               var builder = TransportRequest.builder()
+                       .method(request.getMethod())
+                       .uri(request.getUri())
+                       .headers(request.getHeaders())
+                       .body(TransportBody.of(wrappedBody))
+                       .timeout(request.getTimeout());
+               return builder.build();
+       }
+
        private String applyPathSubstitutions(String template) {
                var result = template;
                Object remainder = null;
@@ -683,4 +770,41 @@ public final class RestRequest {
        private static String urlEncode(String value) {
                return URLEncoder.encode(value, StandardCharsets.UTF_8);
        }
+
+       private static final class BoundedCaptureOutputStream extends 
OutputStream {
+               private final OutputStream delegate;
+               private final ByteArrayOutputStream capture = new 
ByteArrayOutputStream();
+               private final int cap;
+               private long totalBytesWritten;
+
+               BoundedCaptureOutputStream(OutputStream delegate, int cap) {
+                       this.delegate = delegate;
+                       this.cap = cap;
+               }
+
+               byte[] getCapturedBytes() {
+                       return capture.toByteArray();
+               }
+
+               long getTotalBytesWritten() {
+                       return totalBytesWritten;
+               }
+
+               @Override
+               public void write(int b) throws IOException {
+                       delegate.write(b);
+                       totalBytesWritten++;
+                       if (capture.size() < cap)
+                               capture.write(b);
+               }
+
+               @Override
+               public void write(byte[] b, int off, int len) throws 
IOException {
+                       delegate.write(b, off, len);
+                       totalBytesWritten += len;
+                       var remaining = cap - capture.size();
+                       if (remaining > 0)
+                               capture.write(b, off, Math.min(len, remaining));
+               }
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestResponse.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestResponse.java
index d6d6d5a981..4518aa64a3 100644
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestResponse.java
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestResponse.java
@@ -18,7 +18,9 @@ package org.apache.juneau.rest.client;
 
 import java.io.*;
 import java.nio.charset.*;
+import java.time.*;
 import java.util.*;
+import java.util.logging.*;
 
 import org.apache.juneau.rest.client.assertion.*;
 
@@ -43,10 +45,42 @@ public final class RestResponse implements Closeable {
 
        private final TransportResponse response;
        private final RestClient client;
+       private final RestRequest request;
+       private final Level debugLevel;
+       private final int debugBodyCap;
+       private final InputStream body;
+       private byte[] cachedContent;
+       private long cachedContentLength = -1;
+       private boolean debugEmitted;
+       private Duration execTime;
 
        RestResponse(TransportResponse response, RestClient client) {
+               this(response, client, null, null, 0);
+       }
+
+       RestResponse(TransportResponse response, RestClient client, RestRequest 
request, Level debugLevel, int debugBodyCap) {
                this.response = response;
                this.client = client;
+               this.request = request;
+               this.debugLevel = debugLevel;
+               this.debugBodyCap = debugBodyCap;
+               var originalBody = response.getBody();
+               if (debugLevel == Level.FINEST && originalBody != null)
+                       this.body = new BoundedCaptureInputStream(originalBody, 
debugBodyCap);
+               else
+                       this.body = originalBody;
+               if (originalBody == null) {
+                       cachedContentLength = 0;
+               } else {
+                       var h = response.getFirstHeader("Content-Length");
+                       if (h != null) {
+                               try {
+                                       cachedContentLength = 
Long.parseLong(h.value());
+                               } catch (NumberFormatException e) {
+                                       cachedContentLength = -1;
+                               }
+                       }
+               }
        }
 
        /**
@@ -107,7 +141,7 @@ public final class RestResponse implements Closeable {
         * @throws IOException If an I/O error occurs reading the body.
         */
        public String getBodyAsString() throws IOException {
-               var body = response.getBody();
+               var body = this.body;
                if (body == null)
                        return null;
                return new String(body.readAllBytes(), StandardCharsets.UTF_8);
@@ -122,7 +156,7 @@ public final class RestResponse implements Closeable {
         * @return The body stream, possibly <jk>null</jk>.
         */
        public InputStream getBodyStream() {
-               return response.getBody();
+               return body;
        }
 
        /**
@@ -190,6 +224,90 @@ public final class RestResponse implements Closeable {
 
        @Override /* Closeable */
        public void close() throws IOException {
+               if (body instanceof BoundedCaptureInputStream bcis) {
+                       bcis.drain();
+                       cachedContent = bcis.getCapturedBytes();
+                       cachedContentLength = bcis.getTotalBytesRead();
+               }
+               if (!debugEmitted && client != null && debugLevel != null) {
+                       var thrown = request != null ? request.getException() : 
null;
+                       RestClientDebugPipeline.emit(client.debugLogger, 
client.debugFormatter, debugLevel, request, this, thrown);
+                       debugEmitted = true;
+               }
                response.close();
        }
+
+       void setExecTime(Duration value) {
+               execTime = value;
+       }
+
+       /**
+        * Returns response body bytes captured for debug logging (up to the 
configured body cap).
+        *
+        * @return Captured response bytes, or <jk>null</jk> if not captured.
+        */
+       public byte[] getCachedContent() {
+               return cachedContent;
+       }
+
+       /**
+        * Returns the total response body length observed while 
reading/draining, or {@code -1} if unknown.
+        *
+        * @return The total body length.
+        */
+       public long getCachedContentLength() {
+               return cachedContentLength;
+       }
+
+       Duration getExecTime() {
+               return execTime;
+       }
+
+       private static final class BoundedCaptureInputStream extends 
FilterInputStream {
+               private final ByteArrayOutputStream capture = new 
ByteArrayOutputStream();
+               private final int cap;
+               private long totalBytesRead;
+
+               BoundedCaptureInputStream(InputStream in, int cap) {
+                       super(in);
+                       this.cap = cap;
+               }
+
+               byte[] getCapturedBytes() {
+                       return capture.toByteArray();
+               }
+
+               long getTotalBytesRead() {
+                       return totalBytesRead;
+               }
+
+               void drain() throws IOException {
+                       while (read() != -1) {
+                               // consume
+                       }
+               }
+
+               @Override
+               public int read() throws IOException {
+                       var b = super.read();
+                       if (b == -1)
+                               return -1;
+                       totalBytesRead++;
+                       if (capture.size() < cap)
+                               capture.write(b);
+                       return b;
+               }
+
+               @Override
+               public int read(byte[] b, int off, int len) throws IOException {
+                       var count = super.read(b, off, len);
+                       if (count <= 0)
+                               return count;
+                       totalBytesRead += count;
+                       var remaining = cap - capture.size();
+                       if (remaining > 0)
+                               capture.write(b, off, Math.min(count, 
remaining));
+                       return count;
+               }
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/BasicRestLogger_Test.java
 
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/BasicRestLogger_Test.java
deleted file mode 100644
index 4a9ef45b44..0000000000
--- 
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/BasicRestLogger_Test.java
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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.
- */
-package org.apache.juneau.rest.client;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-import org.apache.juneau.*;
-import org.junit.jupiter.api.*;
-
-/**
- * Unit tests for {@link BasicRestLogger.Builder#errorTemplate(String)}.
- *
- * <p>
- * {@code infoTemplate}/{@code warningTemplate}/{@code debugTemplate} are 
exercised via the broader
- * {@code RestClientFeatures_Test} suite in {@code juneau-integration-tests}; 
{@code errorTemplate} specifically
- * requires a transport-level failure (no {@link RestResponse}), which this 
test constructs directly.
- */
-@SuppressWarnings({
-       "resource" // 'failing' is handed to (and closed by) the enclosing 
RestClient under test.
-})
-class BasicRestLogger_Test extends TestBase {
-
-       private static final class CapturingLogger implements System.Logger {
-               String message;
-               Level level;
-               Throwable thrown;
-
-               @Override public String getName() { return "test"; }
-               @Override public boolean isLoggable(Level level) { return true; 
}
-
-               @Override
-               public void log(Level level, java.util.ResourceBundle bundle, 
String msg, Throwable thrown) {
-                       this.level = level;
-                       this.message = msg;
-                       this.thrown = thrown;
-               }
-
-               @Override
-               public void log(Level level, java.util.ResourceBundle bundle, 
String format, Object... params) {
-                       this.level = level;
-                       this.message = format;
-               }
-       }
-
-       @Test
-       void a01_errorTemplate_appliedOnTransportFailure() throws Exception {
-               var captured = new CapturingLogger();
-               HttpTransport failing = tReq -> {
-                       throw new TransportException("boom");
-               };
-               try (var client = RestClient.builder().transport(failing)
-                               
.logger(BasicRestLogger.create().logger(captured).errorTemplate("ERR[{method} 
{uri}]: {error}").build())
-                               .build()) {
-                       assertThrows(TransportException.class, () -> 
client.get("http://x/";).run());
-               }
-               assertEquals(System.Logger.Level.ERROR, captured.level);
-               assertTrue(captured.message.startsWith("ERR[GET http://x/]:";), 
"Unexpected message: " + captured.message);
-               assertNotNull(captured.thrown, "The transport exception should 
be passed through to the logger");
-       }
-}
diff --git 
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestClient_DebugLogging_Test.java
 
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestClient_DebugLogging_Test.java
new file mode 100644
index 0000000000..5eefc47299
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestClient_DebugLogging_Test.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.juneau.rest.client;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.charset.*;
+import java.util.logging.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.logging.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for level-driven REST client debug logging.
+ */
+@SuppressWarnings({
+       "resource" // Clients/responses in tests are closed with 
try-with-resources.
+})
+class RestClient_DebugLogging_Test extends TestBase {
+
+       @Test
+       void a01_infoTier_emitsBasicOnly() throws Exception {
+               var loggerName = getClass().getName() + ".a01";
+               var logger = RichLogger.getLogger(loggerName);
+               var prevLevel = logger.getLevel();
+               logger.setLevel(Level.INFO);
+               try (var c = logger.captureEvents(Level.INFO)) {
+                       try (var client = RestClient.builder()
+                               .transport(req -> response(200, "ok"))
+                               .debugLoggerName(loggerName)
+                               .build();
+                               var res = 
client.get("http://example.com/basic";).run()) {
+                               assertSame(logger, client.debugLogger);
+                               assertEquals(Level.INFO, 
RestClientDebugPipeline.resolveTier(client.debugLogger));
+                               res.body().asString();
+                       }
+                       assertNotNull(c.last());
+                       var msg = c.last().getMessage();
+                       assertEquals(Level.INFO, c.last().getLevel());
+                       assertTrue(msg.contains("[200] HTTP GET 
http://example.com/basic";));
+                       assertFalse(msg.contains("---Request Headers---"));
+                       assertFalse(msg.contains("---Request Content 
UTF-8---"));
+               } finally {
+                       logger.setLevel(prevLevel);
+               }
+       }
+
+       @Test
+       void a02_fineTier_includesHeadersWithoutBodies() throws Exception {
+               var loggerName = getClass().getName() + ".a02";
+               var logger = RichLogger.getLogger(loggerName);
+               var prevLevel = logger.getLevel();
+               logger.setLevel(Level.FINE);
+               try (var c = logger.captureEvents(Level.FINE)) {
+                       try (var client = RestClient.builder()
+                               .transport(req -> 
TransportResponse.builder().statusCode(200).header("X-Res", "rv").body(new 
ByteArrayInputStream("rb".getBytes(StandardCharsets.UTF_8))).build())
+                               .debugLoggerName(loggerName)
+                               .build();
+                               var res = 
client.get("http://example.com/h";).header("Authorization", "secret").run()) {
+                               res.body().asString();
+                       }
+                       assertNotNull(c.last());
+                       assertEquals(Level.FINE, c.last().getLevel());
+                       var msg = c.last().getMessage();
+                       assertTrue(msg.contains("---Request Headers---"));
+                       assertTrue(msg.contains("Authorization: [REDACTED]"));
+                       assertTrue(msg.contains("---Response Headers---"));
+                       assertTrue(msg.contains("X-Res: rv"));
+                       assertFalse(msg.contains("---Request Content 
UTF-8---"));
+               } finally {
+                       logger.setLevel(prevLevel);
+               }
+       }
+
+       @Test
+       void a03_finestTier_includesBodiesWithTruncation() throws Exception {
+               var loggerName = getClass().getName() + ".a03";
+               var logger = RichLogger.getLogger(loggerName);
+               var prevLevel = logger.getLevel();
+               logger.setLevel(Level.FINEST);
+               try (var c = logger.captureEvents(Level.FINEST)) {
+                       try (var client = RestClient.builder()
+                               .transport(req -> {
+                                       if (req.getBody() != null) {
+                                               try {
+                                                       
req.getBody().writeTo(new ByteArrayOutputStream());
+                                               } catch (IOException e) {
+                                                       throw new 
RuntimeException(e);
+                                               }
+                                       }
+                                       return response(200, "uvwxyz");
+                               })
+                               .debugLoggerName(loggerName)
+                               .debugFormatter(new 
BasicRestClientDebugFormatter().bodyCap(4))
+                               .build();
+                               var res = 
client.post("http://example.com/body";).bodyString("abcdef").run()) {
+                               res.body().asString();
+                       }
+                       assertNotNull(c.last());
+                       assertEquals(Level.FINEST, c.last().getLevel());
+                       var msg = c.last().getMessage();
+                       assertTrue(msg.contains("---Request Content UTF-8---"));
+                       assertTrue(msg.contains("abcd"));
+                       assertTrue(msg.contains("…[truncated 2 bytes]"));
+                       assertTrue(msg.contains("---Response Content 
UTF-8---"));
+                       assertTrue(msg.contains("uvwx"));
+               } finally {
+                       logger.setLevel(prevLevel);
+               }
+       }
+
+       @Test
+       void a04_finestTier_drainsBodyOnCloseWhenPartiallyRead() throws 
Exception {
+               var loggerName = getClass().getName() + ".a04";
+               var logger = RichLogger.getLogger(loggerName);
+               var prevLevel = logger.getLevel();
+               logger.setLevel(Level.FINEST);
+               try (var c = logger.captureEvents(Level.FINEST)) {
+                       try (var client = RestClient.builder()
+                               .transport(req -> response(200, "abcdef"))
+                               .debugLoggerName(loggerName)
+                               .build();
+                               var res = 
client.get("http://example.com/partial";).run()) {
+                               var stream = res.body().asStream();
+                               assertNotNull(stream);
+                               assertEquals('a', stream.read());
+                       }
+                       assertNotNull(c.last());
+                       var msg = c.last().getMessage();
+                       assertTrue(msg.contains("---Response Content 
UTF-8---"));
+                       assertTrue(msg.contains("abcdef"));
+               } finally {
+                       logger.setLevel(prevLevel);
+               }
+       }
+
+       @Test
+       void a05_defaultLoggerName_isRestClientClassName() throws Exception {
+               var logger = RichLogger.getLogger(RestClient.class.getName());
+               var prevLevel = logger.getLevel();
+               logger.setLevel(Level.INFO);
+               try (var c = logger.captureEvents(Level.INFO)) {
+                       try (var client = RestClient.builder().transport(req -> 
response(200, "ok")).build();
+                               var res = 
client.get("http://example.com/default";).run()) {
+                               res.body().asString();
+                       }
+                       assertNotNull(c.last());
+                       assertEquals(RestClient.class.getName(), 
c.last().getLoggerName());
+               } finally {
+                       logger.setLevel(prevLevel);
+               }
+       }
+
+       private static TransportResponse response(int statusCode, String body) {
+               return TransportResponse.builder()
+                       .statusCode(statusCode)
+                       .reasonPhrase("OK")
+                       .body(new 
ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)))
+                       .build();
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestLogEntry_Test.java
 
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestLogEntry_Test.java
deleted file mode 100644
index d61d134aa2..0000000000
--- 
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestLogEntry_Test.java
+++ /dev/null
@@ -1,91 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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.
- */
-package org.apache.juneau.rest.client;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-import java.time.*;
-
-import org.apache.juneau.*;
-import org.junit.jupiter.api.*;
-
-/**
- * Unit tests for {@link RestLogEntry} constructed directly via {@link 
RestLogEntry#builder()}, covering the
- * plain accessors ({@link RestLogEntry#getElapsed()}, {@link 
RestLogEntry#toString()}) and the builder's
- * {@link RestLogEntry.Builder#levelResolver(RestLogLevelResolver)} override, 
none of which require an actual
- * transport round-trip.
- */
-class RestLogEntry_Test extends TestBase {
-
-       @SuppressWarnings({
-               "resource" // Factory returns a Closeable for the caller to 
close; Eclipse JDT @Owning warning is by design.
-       })
-       private static RestResponse response(int statusCode) {
-               var b = TransportResponse.builder().statusCode(statusCode);
-               return new RestResponse(b.build(), RestClient.create());
-       }
-
-       @Test
-       void a01_getElapsed_returnsConfiguredDuration() throws Exception {
-               try (var client = RestClient.create()) {
-                       var req = client.get("http://x/";);
-                       try (var resp = response(200)) {
-                               var entry = 
RestLogEntry.builder().request(req).response(resp).elapsed(Duration.ofMillis(42)).build();
-                               assertEquals(Duration.ofMillis(42), 
entry.getElapsed());
-                       }
-               }
-       }
-
-       @Test
-       void a02_toString_delegatesToDefaultFormat() throws Exception {
-               try (var client = RestClient.create()) {
-                       var req = client.get("http://x/";);
-                       try (var resp = response(200)) {
-                               var entry = 
RestLogEntry.builder().request(req).response(resp).elapsed(Duration.ofMillis(7)).build();
-                               assertEquals(entry.format(), entry.toString());
-                               assertTrue(entry.toString().contains("GET"), 
"Unexpected: " + entry.toString());
-                       }
-               }
-       }
-
-       @Test
-       void a03_levelResolver_overrideIsUsedToComputeLevel() throws Exception {
-               try (var client = RestClient.create()) {
-                       var req = client.get("http://x/";);
-                       try (var resp = response(200)) {
-                               var entry = 
RestLogEntry.builder().request(req).response(resp)
-                                               .levelResolver(e -> 
System.Logger.Level.ERROR)
-                                               .build();
-                               assertEquals(System.Logger.Level.ERROR, 
entry.getLevel());
-                       }
-               }
-       }
-
-       @Test
-       void a04_debugFlagAndError_propagateThroughBuilder() throws Exception {
-               try (var client = RestClient.create()) {
-                       var req = client.get("http://x/";);
-                       var ex = new RuntimeException("boom");
-                       var entry = 
RestLogEntry.builder().request(req).error(ex).debug(true).build();
-                       assertTrue(entry.isDebug());
-                       assertSame(ex, entry.getError());
-                       assertTrue(entry.isError());
-                       assertEquals(0, entry.getStatusCode());
-                       assertFalse(entry.hasResponseHeader("X-Any"));
-               }
-       }
-}

Reply via email to