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 809cb84bf2 TODO-368: JUL/Spring control plane for REST debug logging
809cb84bf2 is described below
commit 809cb84bf2e7201ab207ab82aaba250a7b95a303
Author: James Bognar <[email protected]>
AuthorDate: Sun Aug 16 06:51:27 2026 -0400
TODO-368: JUL/Spring control plane for REST debug logging
- Emit REST debug records at stable INFO level; tier (INFO/DEBUG/TRACE)
controls message detail only.
- Add proxy-safe logger naming (LoggerNaming.userClassName) and wire
RestContext default logger derivation.
- Add Spring Boot auto-config (JuneauRestLoggingAutoConfiguration) with
reset-resistant Logback->JUL LevelChangePropagator, provided logback-classic,
optional OSGi import + manifest assertion, and no-Logback fallback.
- Integration coverage for resource/op-level level propagation; docs +
migration + release-note updates.
---
.../RestDebugLoggingPropagation_HostResource.java | 47 ++++
...estDebugLoggingPropagation_Springboot_Test.java | 243 +++++++++++++++++++++
.../juneau/rest/mock/RestDebugCapture_Test.java | 152 ++++++++++++-
juneau-rest/juneau-rest-server-springboot/pom.xml | 58 +++++
.../JuneauResetResistantLevelChangePropagator.java | 32 +++
.../JuneauRestLoggingAutoConfiguration.java | 167 ++++++++++++++
...rk.boot.autoconfigure.AutoConfiguration.imports | 1 +
...gAutoConfiguration_NoLogbackClasspath_Test.java | 49 +++++
.../JuneauRestLoggingAutoConfiguration_Test.java | 164 ++++++++++++++
.../org/apache/juneau/rest/server/RestContext.java | 4 +-
.../juneau/rest/server/logging/LoggerNaming.java | 68 ++++++
.../rest/server/logging/RestDebugPipeline.java | 19 +-
.../rest/server/logging/LoggerNaming_Test.java | 67 ++++++
.../server/logging/RestDebugPipeline_Test.java | 20 +-
14 files changed, 1073 insertions(+), 18 deletions(-)
diff --git
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/springboot/logging/RestDebugLoggingPropagation_HostResource.java
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/springboot/logging/RestDebugLoggingPropagation_HostResource.java
new file mode 100644
index 0000000000..634805eda5
--- /dev/null
+++
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/springboot/logging/RestDebugLoggingPropagation_HostResource.java
@@ -0,0 +1,47 @@
+/*
+ * 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.springboot.logging;
+
+import java.io.*;
+
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.springboot.*;
+
+/**
+ * Top-level Spring Boot host resource used by logging propagation integration
tests.
+ *
+ * @since 10.0.0
+ */
+@Rest
+public class RestDebugLoggingPropagation_HostResource extends
BasicSpringRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @RestPost(path="/echo")
+ public String echo(RestRequest req) throws IOException {
+ return req.getContent().asString();
+ }
+
+ @RestGet(path="/one")
+ public String one() {
+ return "one";
+ }
+
+ @RestGet(path="/two")
+ public String two() {
+ return "two";
+ }
+}
diff --git
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/springboot/logging/RestDebugLoggingPropagation_Springboot_Test.java
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/springboot/logging/RestDebugLoggingPropagation_Springboot_Test.java
new file mode 100644
index 0000000000..0eb12f9f6f
--- /dev/null
+++
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/springboot/logging/RestDebugLoggingPropagation_Springboot_Test.java
@@ -0,0 +1,243 @@
+/*
+ * 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.springboot.logging;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.*;
+import java.net.http.*;
+import java.net.http.HttpResponse.*;
+import java.time.*;
+import java.util.*;
+import java.util.logging.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+import org.springframework.boot.*;
+import org.springframework.boot.autoconfigure.*;
+import org.springframework.boot.builder.*;
+import org.springframework.boot.web.servlet.*;
+import org.springframework.context.*;
+import org.springframework.context.annotation.*;
+
+/**
+ * Spring Boot end-to-end tests proving that logging-level properties drive
JUL debug detail.
+ *
+ * @since 10.0.0
+ */
[email protected]
+class RestDebugLoggingPropagation_Springboot_Test extends TestBase {
+
+ private static final String HOST =
RestDebugLoggingPropagation_HostResource.class.getName();
+ private static final String OP_ONE = HOST + ".one";
+ private static final String OP_TWO = HOST + ".two";
+ private static final String OP_ECHO = HOST + ".echo";
+
+ private static final HttpClient HTTP = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(5))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ static class App {
+ @Bean public RestDebugLoggingPropagation_HostResource host() {
return new RestDebugLoggingPropagation_HostResource(); }
+ @Bean public
ServletRegistrationBean<RestDebugLoggingPropagation_HostResource>
hostRegistration(RestDebugLoggingPropagation_HostResource host) {
+ return new ServletRegistrationBean<>(host, "/api/*");
+ }
+ }
+
+ private static final class CollectingHandler extends Handler {
+ private final List<LogRecord> records = new ArrayList<>();
+
+ @Override
+ public void publish(LogRecord record) {
+ if (isLoggable(record))
+ records.add(record);
+ }
+
+ @Override public void flush() {}
+ @Override public void close() {}
+
+ List<LogRecord> records() {
+ return records;
+ }
+ }
+
+ private static final class LoggerState {
+ private final Level level;
+ private final boolean useParentHandlers;
+ private final Handler[] handlers;
+
+ LoggerState(Logger logger) {
+ level = logger.getLevel();
+ useParentHandlers = logger.getUseParentHandlers();
+ handlers = logger.getHandlers();
+ }
+
+ void restore(Logger logger) {
+ for (var h : logger.getHandlers())
+ logger.removeHandler(h);
+ for (var h : handlers)
+ logger.addHandler(h);
+ logger.setUseParentHandlers(useParentHandlers);
+ logger.setLevel(level);
+ }
+ }
+
+ private ConfigurableApplicationContext start(String...extraProperties) {
+ var props = new ArrayList<String>();
+ props.add("spring.main.banner-mode=off");
+ props.add("server.port=0");
+ props.add("logging.level.root=WARN");
+ props.add("juneau.rest.logging.propagate-levels=true");
+ props.addAll(Arrays.asList(extraProperties));
+ return new SpringApplicationBuilder(App.class)
+ .web(WebApplicationType.SERVLET)
+ .properties(props.toArray(String[]::new))
+ .run();
+ }
+
+ private int port(ConfigurableApplicationContext ctx) {
+ return ctx.getEnvironment().getProperty("local.server.port",
Integer.class, -1);
+ }
+
+ private HttpResponse<String> get(int port, String path,
String...headers) throws Exception {
+ var b = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + path))
+ .timeout(Duration.ofSeconds(10))
+ .GET();
+ for (var i = 0; i < headers.length; i += 2)
+ b.header(headers[i], headers[i + 1]);
+ return HTTP.send(b.build(), BodyHandlers.ofString());
+ }
+
+ private HttpResponse<String> post(int port, String path, String body,
String...headers) throws Exception {
+ var b = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + path))
+ .timeout(Duration.ofSeconds(10))
+ .POST(HttpRequest.BodyPublishers.ofString(body));
+ for (var i = 0; i < headers.length; i += 2)
+ b.header(headers[i], headers[i + 1]);
+ return HTTP.send(b.build(), BodyHandlers.ofString());
+ }
+
+ @Test void
b01_tracePropertyDrivesFinestBodyDetail_withoutProgrammaticSetLevel() throws
Exception {
+ var logger = Logger.getLogger(OP_ECHO);
+ var state = new LoggerState(logger);
+ var handler = new CollectingHandler();
+ try {
+ for (var h : logger.getHandlers())
+ logger.removeHandler(h);
+ logger.setUseParentHandlers(false);
+ handler.setLevel(Level.INFO);
+ logger.addHandler(handler);
+
+ try (var app = start("logging.level." + HOST +
"=TRACE")) {
+ var port = port(app);
+ var resp = post(port, "/api/echo",
"phase4-body", "Content-Type", "text/plain");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("phase4-body"),
resp.body());
+ }
+
+ var record = handler.records().stream().filter(x ->
OP_ECHO.equals(x.getLoggerName())).reduce((a, b) -> b).orElse(null);
+ assertNotNull(record, "TRACE property should drive JUL
detail without direct Logger.setLevel(...) calls");
+ assertEquals(Level.INFO, record.getLevel());
+ assertTrue(record.getMessage().contains("phase4-body"),
record.getMessage());
+ } finally {
+ state.restore(logger);
+ }
+ }
+
+ @Test void
b02_debugAndInfoPropertiesSelectHeadersVsBasic_allEmitsStayInfo() throws
Exception {
+ var logger = Logger.getLogger(OP_ONE);
+ var state = new LoggerState(logger);
+ var handler = new CollectingHandler();
+ try {
+ for (var h : logger.getHandlers())
+ logger.removeHandler(h);
+ logger.setUseParentHandlers(false);
+ handler.setLevel(Level.INFO);
+ logger.addHandler(handler);
+
+ try (var app = start("logging.level." + HOST +
"=DEBUG")) {
+ var port = port(app);
+ var resp = get(port, "/api/one", "X-Debug",
"true");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("one"),
resp.body());
+ }
+ var debugRecord = handler.records().stream().filter(x
-> OP_ONE.equals(x.getLoggerName())).reduce((a, b) -> b).orElse(null);
+ assertNotNull(debugRecord);
+ assertEquals(Level.INFO, debugRecord.getLevel());
+
assertTrue(debugRecord.getMessage().contains("---Request Headers---"),
debugRecord.getMessage());
+ handler.records().clear();
+
+ try (var app = start("logging.level." + HOST +
"=INFO")) {
+ var port = port(app);
+ var resp = get(port, "/api/one", "X-Debug",
"true");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("one"),
resp.body());
+ }
+ var infoRecord = handler.records().stream().filter(x ->
OP_ONE.equals(x.getLoggerName())).reduce((a, b) -> b).orElse(null);
+ assertNotNull(infoRecord);
+ assertEquals(Level.INFO, infoRecord.getLevel());
+ assertTrue(infoRecord.getMessage().contains("[200] HTTP
GET /api/one"), infoRecord.getMessage());
+
assertFalse(infoRecord.getMessage().contains("---Request Headers---"),
infoRecord.getMessage());
+ } finally {
+ state.restore(logger);
+ }
+ }
+
+ @Test void b05_operationScopedPropertyElevatesOnlyOneOperation() throws
Exception {
+ var parent = Logger.getLogger(HOST);
+ var opOne = Logger.getLogger(OP_ONE);
+ var opTwo = Logger.getLogger(OP_TWO);
+ var parentState = new LoggerState(parent);
+ var opOneState = new LoggerState(opOne);
+ var opTwoState = new LoggerState(opTwo);
+ var handler = new CollectingHandler();
+ try {
+ for (var h : parent.getHandlers())
+ parent.removeHandler(h);
+ parent.setUseParentHandlers(false);
+ handler.setLevel(Level.INFO);
+ parent.addHandler(handler);
+
+ try (var app = start(
+ "logging.level." + HOST + "=INFO",
+ "logging.level." + HOST + ".one=TRACE"
+ )) {
+ var port = port(app);
+ assertEquals(200, get(port, "/api/one",
"X-Scoped", "one").statusCode());
+ assertEquals(200, get(port, "/api/two",
"X-Scoped", "two").statusCode());
+ }
+
+ var oneRecord = handler.records().stream().filter(x ->
OP_ONE.equals(x.getLoggerName())).reduce((a, b) -> b).orElse(null);
+ var twoRecord = handler.records().stream().filter(x ->
OP_TWO.equals(x.getLoggerName())).reduce((a, b) -> b).orElse(null);
+ assertNotNull(oneRecord, "operation-level TRACE should
emit for .one");
+ assertNotNull(twoRecord, "sibling operation should
still emit at INFO detail");
+ assertEquals(Level.INFO, oneRecord.getLevel());
+ assertEquals(Level.INFO, twoRecord.getLevel());
+ assertTrue(oneRecord.getMessage().contains("---Request
Headers---"), oneRecord.getMessage());
+ assertFalse(twoRecord.getMessage().contains("---Request
Headers---"), twoRecord.getMessage());
+ } finally {
+ parentState.restore(parent);
+ opOneState.restore(opOne);
+ opTwoState.restore(opTwo);
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugCapture_Test.java
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugCapture_Test.java
index fe89835d76..4db7be2dda 100644
---
a/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugCapture_Test.java
+++
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RestDebugCapture_Test.java
@@ -19,6 +19,7 @@ package org.apache.juneau.rest.mock;
import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
+import java.util.*;
import java.util.logging.*;
import org.apache.juneau.commons.inject.*;
@@ -62,6 +63,27 @@ class RestDebugCapture_Test {
@Rest(path="/mix", mixins=A05_Mixin.class)
public static class A05_HostResource {}
+ @Rest(path="/proxyname")
+ public static class A08_UserResource {
+ @RestGet(path="/who")
+ public String who() {
+ return "ok";
+ }
+ }
+
+ @Rest(path="/proxyname")
+ public static class A08_UserResource$$SpringCGLIB$$ extends
A08_UserResource {}
+
+ public static class A09_NestedHolder {
+ @Rest(path="/nestedname")
+ public static class NestedResource {
+ @RestGet(path="/who")
+ public String who() {
+ return "ok";
+ }
+ }
+ }
+
/**
* A resource that IS its own {@link RestDebugFormatter}
(highest-precedence resolution path, per
* {@code RestContext#getRestDebugFormatter()}) with a capture cap far
below the 8 KB wrapper default.
@@ -126,7 +148,9 @@ class RestDebugCapture_Test {
.filter(r -> (B_HostA.class.getName() +
".who").equals(r.getLoggerName()))
.findFirst().orElse(null);
assertNotNull(opA, "expected a mixin op-logger record
named <HostA>.who (host-level cascade)");
- assertEquals(Level.FINEST, opA.getLevel());
+ assertEquals(Level.INFO, opA.getLevel());
+ assertTrue(opA.getMessage().contains("---Request
Headers---"),
+ "HostA debug path should still resolve a
fine-grained tier (headers/body), even though emitted records are INFO");
var opB = cb.getRecords().stream()
.filter(r -> (B_HostB.class.getName() +
".who").equals(r.getLoggerName()))
@@ -135,6 +159,8 @@ class RestDebugCapture_Test {
assertEquals(Level.INFO, opB.getLevel(),
"HostB's mixin op logger must NOT inherit
HostA's elevated level -- hosts composing the same mixin "
+ "must be isolated");
+ assertFalse(opB.getMessage().contains("---Request
Headers---"),
+ "HostB remains at the INFO detail tier and must
not render headers");
}
}
@@ -169,9 +195,13 @@ class RestDebugCapture_Test {
assertNotNull(recOne);
assertNotNull(recTwo);
- assertEquals(Level.FINEST, recOne.getLevel());
+ assertEquals(Level.INFO, recOne.getLevel());
+ assertTrue(recOne.getMessage().contains("---Request
Headers---"),
+ "elevated child logger should still render
higher-tier detail");
assertEquals(Level.INFO, recTwo.getLevel(),
"sibling operation must not inherit the
elevated per-op child logger level");
+ assertFalse(recTwo.getMessage().contains("---Request
Headers---"),
+ "sibling operation at INFO tier must not render
headers");
} finally {
opOneLogger.setLevel(prevLevel);
}
@@ -187,6 +217,80 @@ class RestDebugCapture_Test {
}
}
+ private static final class D00_CollectingHandler extends Handler {
+ private final List<java.util.logging.LogRecord> records = new
ArrayList<>();
+
+ @Override
+ public void publish(java.util.logging.LogRecord record) {
+ if (isLoggable(record))
+ records.add(record);
+ }
+
+ @Override
+ public void flush() {}
+
+ @Override
+ public void close() {}
+
+ List<java.util.logging.LogRecord> records() {
+ return records;
+ }
+
+ void clear() {
+ records.clear();
+ }
+ }
+
+ @Test void
d00_realHandlerVisibilityTracksHandlerThreshold_notTierLevel() throws Exception
{
+ var logger = Logger.getLogger(D_Resource.class.getName());
+ var prevLevel = logger.getLevel();
+ var prevUseParentHandlers = logger.getUseParentHandlers();
+ var prevHandlers = logger.getHandlers();
+ var handler = new D00_CollectingHandler();
+ try {
+ logger.setUseParentHandlers(false);
+ for (var h : prevHandlers)
+ logger.removeHandler(h);
+ handler.setLevel(Level.INFO);
+ logger.addHandler(handler);
+ logger.setLevel(Level.FINEST);
+
+ var client =
org.apache.juneau.rest.mock.classic.MockRestClient.create(D_Resource.class).build();
+ client.post("/echo",
"real-handler-secret").run().assertContent("real-handler-secret");
+
+ assertEquals(1, handler.records().size(),
+ "effective logger FINEST with INFO handler
should publish one INFO-stamped debug record");
+ var finestRecord = handler.records().get(0);
+ assertEquals(Level.INFO, finestRecord.getLevel());
+
assertTrue(finestRecord.getMessage().contains("real-handler-secret"),
+ "FINEST detail should still render
request/response body content");
+
+ handler.clear();
+ handler.setLevel(Level.WARNING);
+ client.post("/echo",
"real-handler-secret").run().assertContent("real-handler-secret");
+ assertTrue(handler.records().isEmpty(),
+ "INFO-stamped records must be filtered by
handlers above INFO");
+
+ handler.setLevel(Level.INFO);
+ logger.setLevel(Level.INFO);
+ client.post("/echo",
"real-handler-secret").run().assertContent("real-handler-secret");
+
+ assertEquals(1, handler.records().size(),
+ "logger INFO + handler INFO should preserve the
prior single visible basic-line behavior");
+ var infoRecord = handler.records().get(0);
+ assertEquals(Level.INFO, infoRecord.getLevel());
+ assertTrue(infoRecord.getMessage().contains("[200] HTTP
POST /twophase/echo"), infoRecord.getMessage());
+
assertFalse(infoRecord.getMessage().contains("real-handler-secret"),
+ "INFO tier does not install capture wrappers,
so the body must not render");
+ } finally {
+ logger.removeHandler(handler);
+ for (var h : prevHandlers)
+ logger.addHandler(h);
+ logger.setUseParentHandlers(prevUseParentHandlers);
+ logger.setLevel(prevLevel);
+ }
+ }
+
@Test void d01_fineTier_noCaptureWrapperInstalled_bodyNeverRendered()
throws Exception {
var target = Logger.getLogger(D_Resource.class.getName());
var prevLevel = target.getLevel();
@@ -198,7 +302,7 @@ class RestDebugCapture_Test {
client.post("/echo",
"two-phase-secret").run().assertContent("two-phase-secret");
assertFalse(c.isEmpty());
- assertEquals(Level.FINE, c.last().getLevel());
+ assertEquals(Level.INFO, c.last().getLevel());
assertFalse(c.last().getMessage().contains("two-phase-secret"),
"FINE tier must not install the capture
wrapper, so the body cannot appear in the record: " + c.last().getMessage());
} finally {
@@ -213,7 +317,7 @@ class RestDebugCapture_Test {
client.post("/echo",
"two-phase-secret").run().assertContent("two-phase-secret");
assertFalse(c.isEmpty());
- assertEquals(Level.FINEST, c.last().getLevel());
+ assertEquals(Level.INFO, c.last().getLevel());
assertTrue(c.last().getMessage().contains("two-phase-secret"),
"FINEST tier must install the capture wrapper
and render the body: " + c.last().getMessage());
}
@@ -282,7 +386,9 @@ class RestDebugCapture_Test {
.findFirst().orElse(null);
assertNotNull(rec, "op logger must be named as a child
of the bean-overridden logger, not "
+ F_Resource.class.getName() + ".who");
- assertEquals(Level.FINEST, rec.getLevel());
+ assertEquals(Level.INFO, rec.getLevel());
+ assertTrue(rec.getMessage().contains("---Request
Headers---"),
+ "elevated override logger should still drive
higher-tier detail");
} finally {
overrideLogger.setLevel(prevLevel);
}
@@ -304,7 +410,8 @@ class RestDebugCapture_Test {
.reduce((a, b) -> b)
.orElse(null);
assertNotNull(rec);
- assertEquals(Level.FINEST, rec.getLevel(), "the
resolved resource logger's tier is still FINEST");
+ assertEquals(Level.INFO, rec.getLevel(),
+ "records remain INFO-stamped even when the
resolved detail tier is FINEST");
assertFalse(rec.getMessage().contains("X-Secret-Header"),
"no headers should ever render on the 404/no-op
path, even at FINEST: " + rec.getMessage());
assertFalse(rec.getMessage().contains("leak-test-value"), rec.getMessage());
@@ -322,7 +429,7 @@ class RestDebugCapture_Test {
client.get("/who").run().getContent().asString();
assertFalse(c.isEmpty());
- assertEquals(Level.FINEST, c.last().getLevel());
+ assertEquals(Level.INFO, c.last().getLevel());
assertTrue(c.last().getMessage().contains("[200] HTTP
GET /api/who"));
assertNull(c.last().getThrown());
}
@@ -437,4 +544,35 @@ class RestDebugCapture_Test {
assertFalse(msg.contains("Request Content"), "no body
section should render when bodyCap(0): " + msg);
}
}
+
+ @Test void
a08_proxyShapedResourceUsesUserClassLogger_nestedResourceNameRemainsDistinct()
throws Exception {
+ var userName = A08_UserResource.class.getName() + ".who";
+ var proxyName = A08_UserResource$$SpringCGLIB$$.class.getName()
+ ".who";
+ try (var c =
RichLogger.getLogger(A08_UserResource.class).captureEvents(Level.FINEST)) {
+ var client =
org.apache.juneau.rest.mock.classic.MockRestClient
+ .create(A08_UserResource$$SpringCGLIB$$.class)
+ .debug()
+ .build();
+
+
client.get("/who").run().assertStatus().asCode().is(200);
+
+
assertTrue(c.getRecords().stream().map(java.util.logging.LogRecord::getLoggerName).anyMatch(userName::equals),
+ "proxy-shaped resource should normalize to the
user class logger name");
+
assertFalse(c.getRecords().stream().map(java.util.logging.LogRecord::getLoggerName).anyMatch(proxyName::equals),
+ "proxy-shaped logger name must not leak to
emitted records");
+ }
+
+ var nestedName =
A09_NestedHolder.NestedResource.class.getName() + ".who";
+ try (var c =
RichLogger.getLogger(A09_NestedHolder.NestedResource.class).captureEvents(Level.FINEST))
{
+ var client =
org.apache.juneau.rest.mock.classic.MockRestClient
+ .create(A09_NestedHolder.NestedResource.class)
+ .debug()
+ .build();
+
+
client.get("/who").run().assertStatus().asCode().is(200);
+
+
assertTrue(c.getRecords().stream().map(java.util.logging.LogRecord::getLoggerName).anyMatch(nestedName::equals),
+ "ordinary nested resource names must remain
distinct and unnormalized");
+ }
+ }
}
diff --git a/juneau-rest/juneau-rest-server-springboot/pom.xml
b/juneau-rest/juneau-rest-server-springboot/pom.xml
index e29e076c0e..9a6a95666b 100644
--- a/juneau-rest/juneau-rest-server-springboot/pom.xml
+++ b/juneau-rest/juneau-rest-server-springboot/pom.xml
@@ -83,6 +83,12 @@
<version>${project.version}</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ <version>${logback.version}</version>
+ <scope>provided</scope>
+ </dependency>
</dependencies>
<build>
@@ -124,6 +130,9 @@
<extensions>true</extensions>
<configuration>
<supportIncrementalBuild>true</supportIncrementalBuild>
+ <instructions>
+
<Import-Package>ch.qos.logback.*;resolution:=optional,*</Import-Package>
+ </instructions>
</configuration>
<executions>
<execution>
@@ -135,6 +144,55 @@
</execution>
</executions>
</plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-antrun-plugin</artifactId>
+ <executions>
+ <execution>
+
<id>verify-optional-logback-import</id>
+ <phase>verify</phase>
+ <goals>
+ <goal>run</goal>
+ </goals>
+ <configuration>
+ <target>
+ <loadfile
property="manifest.contents"
srcFile="${project.build.outputDirectory}/META-INF/MANIFEST.MF" />
+ <condition
property="manifest.hasOptionalLogbackImport">
+
<matches string="${manifest.contents}"
pattern="(?s).*ch\.qos\.logback\..*resolution:=optional.*" />
+ </condition>
+ <fail
unless="manifest.hasOptionalLogbackImport"
+
message="Expected optional Logback import in generated MANIFEST.MF." />
+ </target>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
</plugins>
</build>
+
+ <profiles>
+ <profile>
+ <id>no-logback-tests</id>
+ <activation>
+ <property>
+ <name>logback.excluded</name>
+ <value>true</value>
+ </property>
+ </activation>
+ <build>
+ <plugins>
+ <plugin>
+
<groupId>org.apache.maven.plugins</groupId>
+
<artifactId>maven-surefire-plugin</artifactId>
+ <configuration>
+
<classpathDependencyExcludes>
+
<classpathDependencyExclude>ch.qos.logback:logback-classic</classpathDependencyExclude>
+
<classpathDependencyExclude>ch.qos.logback:logback-core</classpathDependencyExclude>
+
</classpathDependencyExcludes>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
</project>
diff --git
a/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/JuneauResetResistantLevelChangePropagator.java
b/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/JuneauResetResistantLevelChangePropagator.java
new file mode 100644
index 0000000000..8b3caffe9b
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/JuneauResetResistantLevelChangePropagator.java
@@ -0,0 +1,32 @@
+/*
+ * 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.server.springboot;
+
+import ch.qos.logback.classic.jul.*;
+
+/**
+ * Reset-resistant JUL propagator used by {@link
JuneauRestLoggingAutoConfiguration}.
+ *
+ * @since 10.0.0
+ */
+class JuneauResetResistantLevelChangePropagator extends LevelChangePropagator {
+
+ @Override
+ public boolean isResetResistant() {
+ return true;
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/JuneauRestLoggingAutoConfiguration.java
b/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/JuneauRestLoggingAutoConfiguration.java
new file mode 100644
index 0000000000..0351d32aa4
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-springboot/src/main/java/org/apache/juneau/rest/server/springboot/JuneauRestLoggingAutoConfiguration.java
@@ -0,0 +1,167 @@
+/*
+ * 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.server.springboot;
+
+import java.util.*;
+
+import org.slf4j.*;
+import org.springframework.beans.factory.*;
+import org.springframework.boot.autoconfigure.*;
+import org.springframework.boot.autoconfigure.condition.*;
+import org.springframework.context.annotation.*;
+import org.springframework.core.env.*;
+
+/**
+ * Spring Boot logging bridge auto-configuration.
+ *
+ * <p>
+ * When enabled (default), and only when Logback is the active SLF4J backend,
installs one JUL
+ * {@code LevelChangePropagator} listener so Spring logging-level changes
propagate to JUL.
+ *
+ * <p>
+ * This is additive by default ({@code
juneau.rest.logging.propagate-levels.reset-jul=false}) and
+ * silently no-ops when the backend is not Logback.
+ *
+ * @since 10.0.0
+ */
+@AutoConfiguration
+@ConditionalOnClass(name="ch.qos.logback.classic.jul.LevelChangePropagator")
+@ConditionalOnProperty(name="juneau.rest.logging.propagate-levels",
havingValue="true", matchIfMissing=true)
+public class JuneauRestLoggingAutoConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean(name="juneauRestLogLevelPropagatorInstaller")
+ public Runnable juneauRestLogLevelPropagatorInstaller(Environment env) {
+ var resetJul =
env.getProperty("juneau.rest.logging.propagate-levels.reset-jul",
Boolean.class, false);
+ return () -> install(LoggerFactory.getILoggerFactory(),
resetJul);
+ }
+
+ @Bean
+ @ConditionalOnBean(name="juneauRestLogLevelPropagatorInstaller")
+ public SmartInitializingSingleton
juneauRestLogLevelPropagatorBootstrap(Runnable
juneauRestLogLevelPropagatorInstaller) {
+ return juneauRestLogLevelPropagatorInstaller::run;
+ }
+
+ void install(ILoggerFactory loggerFactory, boolean resetJul) {
+ var loggerContextClass =
loadClass("ch.qos.logback.classic.LoggerContext");
+ if (loggerContextClass == null || !
loggerContextClass.isInstance(loggerFactory))
+ return;
+
+ var context = loggerContextClass.cast(loggerFactory);
+ if (hasPropagator(context))
+ return;
+
+ var propagator = newPropagator();
+ if (propagator == null)
+ return;
+
+ invoke(propagator, "setContext", context);
+ invoke(propagator, "setResetJUL", new Class<?>[] {
boolean.class }, resetJul);
+ invokeIfPresent(propagator, "start");
+ invoke(context, "addListener", new Class<?>[] {
loadClass("ch.qos.logback.classic.spi.LoggerContextListener") }, propagator);
+ }
+
+ private static boolean hasPropagator(Object context) {
+ var levelChangePropagatorClass =
loadClass("ch.qos.logback.classic.jul.LevelChangePropagator");
+ if (levelChangePropagatorClass == null)
+ return false;
+ for (var listener : listeners(context))
+ if (levelChangePropagatorClass.isInstance(listener))
+ return true;
+ return false;
+ }
+
+ @SuppressWarnings({
+ "unchecked" // Logback context listener list returns raw
listener type at runtime.
+ })
+ private static List<Object> listeners(Object context) {
+ try {
+ var out = invoke(context, "getCopyOfListenerList", new
Class<?>[0]);
+ if (out instanceof List<?> out2)
+ return (List<Object>)out2;
+ } catch (Exception e) {
+ // Fall through to field-based lookup used by older
Logback versions.
+ }
+ for (var c = context.getClass(); c != null; c =
c.getSuperclass()) {
+ for (var f : c.getDeclaredFields()) {
+ if (! List.class.isAssignableFrom(f.getType())
|| ! f.getName().toLowerCase(Locale.ROOT).contains("listener"))
+ continue;
+ try {
+ f.setAccessible(true);
+ var out = f.get(context);
+ if (out instanceof List<?> out2)
+ return (List<Object>)out2;
+ } catch (Exception e) {
+ // Try next field.
+ }
+ }
+ }
+ return List.of();
+ }
+
+ private static Object newPropagator() {
+ try {
+ return
Class.forName(JuneauResetResistantLevelChangePropagator.class.getName()).getDeclaredConstructor().newInstance();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static Class<?> loadClass(String className) {
+ try {
+ return Class.forName(className);
+ } catch (ClassNotFoundException e) {
+ return null;
+ }
+ }
+
+ private static Object invoke(Object target, String method, Class<?>[]
parameterTypes, Object...args) {
+ try {
+ var m = target.getClass().getMethod(method,
parameterTypes);
+ return m.invoke(target, args);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static Object invoke(Object target, String method, Object arg) {
+ try {
+ for (var m : target.getClass().getMethods()) {
+ if (! m.getName().equals(method) ||
m.getParameterCount() != 1)
+ continue;
+ if
(m.getParameters()[0].getType().isAssignableFrom(arg.getClass()))
+ return m.invoke(target, arg);
+ }
+ throw new NoSuchMethodException(method);
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static void invokeIfPresent(Object target, String method) {
+ try {
+ for (var m : target.getClass().getMethods()) {
+ if (m.getName().equals(method) &&
m.getParameterCount() == 0) {
+ m.invoke(target);
+ return;
+ }
+ }
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server-springboot/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
b/juneau-rest/juneau-rest-server-springboot/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 92a16f18f5..7a43af371b 100644
---
a/juneau-rest/juneau-rest-server-springboot/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++
b/juneau-rest/juneau-rest-server-springboot/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -13,3 +13,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.
org.apache.juneau.rest.server.springboot.JuneauRestAutoConfiguration
+org.apache.juneau.rest.server.springboot.JuneauRestLoggingAutoConfiguration
diff --git
a/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/JuneauRestLoggingAutoConfiguration_NoLogbackClasspath_Test.java
b/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/JuneauRestLoggingAutoConfiguration_NoLogbackClasspath_Test.java
new file mode 100644
index 0000000000..a5b9b7608d
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/JuneauRestLoggingAutoConfiguration_NoLogbackClasspath_Test.java
@@ -0,0 +1,49 @@
+/*
+ * 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.server.springboot;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+import org.springframework.boot.*;
+import org.springframework.boot.autoconfigure.*;
+import org.springframework.boot.builder.*;
+
+/**
+ * Verifies startup remains healthy when Logback is excluded from the runtime
classpath.
+ *
+ * @since 10.0.0
+ */
[email protected]
+@EnabledIfSystemProperty(named="logback.excluded", matches="true")
+class JuneauRestLoggingAutoConfiguration_NoLogbackClasspath_Test extends
TestBase {
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ static class A01_NoLogbackApp {}
+
+ @Test void a01_contextStartsWithoutLogbackAndSkipsInstaller() {
+ try (var ctx = new
SpringApplicationBuilder(A01_NoLogbackApp.class)
+ .web(WebApplicationType.NONE)
+ .properties("spring.main.banner-mode=off",
"juneau.rest.logging.propagate-levels=true")
+ .run()) {
+
assertFalse(ctx.containsBean("juneauRestLogLevelPropagatorInstaller"));
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/JuneauRestLoggingAutoConfiguration_Test.java
b/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/JuneauRestLoggingAutoConfiguration_Test.java
new file mode 100644
index 0000000000..03f082ecca
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-springboot/src/test/java/org/apache/juneau/rest/server/springboot/JuneauRestLoggingAutoConfiguration_Test.java
@@ -0,0 +1,164 @@
+/*
+ * 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.server.springboot;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.logging.Handler;
+import java.util.logging.Logger;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+import org.slf4j.*;
+import org.slf4j.helpers.*;
+import org.springframework.boot.*;
+import org.springframework.boot.autoconfigure.*;
+import org.springframework.boot.builder.*;
+import org.springframework.boot.test.context.runner.*;
+
+import ch.qos.logback.classic.*;
+import ch.qos.logback.classic.jul.*;
+
+/**
+ * Tests for {@link JuneauRestLoggingAutoConfiguration}.
+ *
+ * @since 10.0.0
+ */
[email protected]
+class JuneauRestLoggingAutoConfiguration_Test extends TestBase {
+
+ private final ApplicationContextRunner runner = new
ApplicationContextRunner()
+
.withConfiguration(AutoConfigurations.of(JuneauRestLoggingAutoConfiguration.class));
+
+ @Test void a01_discoveryComesFromAutoConfigurationImports() {
+ try (var ctx = new
SpringApplicationBuilder(A01_DiscoveryApp.class)
+ .web(WebApplicationType.NONE)
+ .properties("spring.main.banner-mode=off",
"juneau.rest.logging.propagate-levels=true")
+ .run()) {
+
assertTrue(ctx.containsBean("juneauRestLogLevelPropagatorInstaller"));
+ }
+ }
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ static class A01_DiscoveryApp {}
+
+ @Test void b01_defaultOn_contributesInstallerBean() {
+ runner.run(ctx ->
assertThat(ctx).hasBean("juneauRestLogLevelPropagatorInstaller"));
+ }
+
+ @Test void b02_propertyOff_disablesInstallerBean() {
+
runner.withPropertyValues("juneau.rest.logging.propagate-levels=false")
+ .run(ctx ->
assertThat(ctx).doesNotHaveBean("juneauRestLogLevelPropagatorInstaller"));
+ }
+
+ @Test void c01_nonLogbackBackend_noopsSilently() {
+ var cfg = new JuneauRestLoggingAutoConfiguration();
+ var backend = new ILoggerFactory() {
+ @Override
+ public org.slf4j.Logger getLogger(String name) {
+ return NOPLogger.NOP_LOGGER;
+ }
+ };
+ assertDoesNotThrow(() -> cfg.install(backend, false));
+ }
+
+ @Test void
d01_installsSingleResetResistantPropagator_andSurvivesReset() {
+ var cfg = new JuneauRestLoggingAutoConfiguration();
+ var context = new LoggerContext();
+ var jul = Logger.getLogger("todo368.phase3.d01");
+ var prev = jul.getLevel();
+ try {
+ jul.setLevel(null);
+ cfg.install(context, false);
+ cfg.install(context, false);
+
+ var propagators =
context.getCopyOfListenerList().stream()
+ .filter(LevelChangePropagator.class::isInstance)
+ .map(LevelChangePropagator.class::cast)
+ .toList();
+ assertEquals(1, propagators.size(), "installer must be
idempotent via instanceof check");
+ assertTrue(propagators.get(0).isResetResistant(),
"installed listener must survive LoggerContext.reset()");
+
+
context.getLogger("todo368.phase3.d01").setLevel(Level.DEBUG);
+ assertEquals(java.util.logging.Level.FINE,
jul.getLevel(), "DEBUG should propagate to JUL FINE");
+
+ context.reset();
+
context.getLogger("todo368.phase3.d01").setLevel(Level.TRACE);
+ assertEquals(java.util.logging.Level.FINEST,
jul.getLevel(),
+ "propagation should remain active after reset");
+ } finally {
+ jul.setLevel(prev);
+ context.stop();
+ }
+ }
+
+ @Test void d02_defaultResetJulFalse_preservesExistingJulState() {
+ var cfg = new JuneauRestLoggingAutoConfiguration();
+ var context = new LoggerContext();
+ var jul = Logger.getLogger("todo368.phase3.d02");
+ var prevLevel = jul.getLevel();
+ var prevUseParentHandlers = jul.getUseParentHandlers();
+ var prevHandlers = jul.getHandlers();
+ var marker = new Handler() {
+ @Override public void
publish(java.util.logging.LogRecord record) {}
+ @Override public void flush() {}
+ @Override public void close() {}
+ };
+ try {
+ for (var h : prevHandlers)
+ jul.removeHandler(h);
+ jul.addHandler(marker);
+ jul.setUseParentHandlers(false);
+ jul.setLevel(java.util.logging.Level.WARNING);
+
+ cfg.install(context, false);
+
+ assertEquals(java.util.logging.Level.WARNING,
jul.getLevel(),
+ "reset-jul defaults to false, so explicit JUL
levels stay intact");
+ assertThat(jul.getHandlers()).contains(marker);
+ } finally {
+ jul.removeHandler(marker);
+ for (var h : prevHandlers)
+ jul.addHandler(h);
+ jul.setUseParentHandlers(prevUseParentHandlers);
+ jul.setLevel(prevLevel);
+ context.stop();
+ }
+ }
+
+ @Test void d03_resetJulTrue_clearsAndReappliesFromLogback() {
+ var cfg = new JuneauRestLoggingAutoConfiguration();
+ var context = new LoggerContext();
+ var name = "todo368.phase3.d03";
+ var jul = Logger.getLogger(name);
+ var prev = jul.getLevel();
+ try {
+ jul.setLevel(java.util.logging.Level.WARNING);
+ context.getLogger(name).setLevel(Level.ERROR);
+
+ cfg.install(context, true);
+
+ assertEquals(java.util.logging.Level.SEVERE,
jul.getLevel(),
+ "reset-jul=true should reapply the explicit
Logback level into JUL");
+ } finally {
+ jul.setLevel(prev);
+ context.stop();
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java
index 25925eb010..faa5260f35 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestContext.java
@@ -1576,11 +1576,11 @@ public class RestContext extends Context {
* The {@link RichLogger} for this resource.
*
* <p>
- * Defaults to {@code RichLogger.getLogger(resourceClass.getName())}. A
bean-store override or
+ * Defaults to {@code
RichLogger.getLogger(LoggerNaming.userClassName(resourceClass))}. A bean-store
override or
* {@code @Bean} factory method REPLACES the default.
*/
private final Memoizer<RichLogger> logger = memoizer(() -> {
- var v = Holder.of(RichLogger.getLogger(cn(resourceClass())));
+ var v =
Holder.of(RichLogger.getLogger(LoggerNaming.userClassName(resourceClass())));
beanStore().createBeanFromMethod(RichLogger.class,
resource().get(), RestContext::isBeanMethod, v.get()).ifPresent(v::set);
return v.get();
});
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/LoggerNaming.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/LoggerNaming.java
new file mode 100644
index 0000000000..1b478483b6
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/LoggerNaming.java
@@ -0,0 +1,68 @@
+/*
+ * 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.server.logging;
+
+import java.util.*;
+
+/**
+ * Logger-name normalization for resource classes.
+ *
+ * <p>
+ * Walks up known proxy class names and returns the first non-proxy superclass
so default logger naming is
+ * stable across common runtime-generated proxies while preserving ordinary
nested-class names.
+ *
+ * @since 10.0.0
+ */
+public final class LoggerNaming {
+
+ private static final List<String> KNOWN_PROXY_INFIXES = List.of(
+ "$$SpringCGLIB$$",
+ "$$EnhancerBySpringCGLIB$$",
+ "$$EnhancerByCGLIB$$",
+ "$$FastClassBySpringCGLIB$$",
+ "$ByteBuddy$",
+ "$Proxy",
+ "$HibernateProxy$",
+ "$MockitoMock$"
+ );
+
+ private LoggerNaming() {}
+
+ /**
+ * Returns the user-class binary name for a class used in default
logger derivation.
+ *
+ * <p>
+ * If the supplied class name contains a known proxy infix, this walks
superclasses until it finds a
+ * class name without a known proxy infix. Ordinary nested classes are
returned unchanged.
+ *
+ * @param c The class to normalize. Must not be <jk>null</jk>.
+ * @return The normalized binary class name.
+ */
+ public static String userClassName(Class<?> c) {
+ var original = c;
+ while (c != null && containsKnownProxyInfix(c.getName()))
+ c = c.getSuperclass();
+ return (c != null ? c : original).getName();
+ }
+
+ private static boolean containsKnownProxyInfix(String className) {
+ for (var infix : KNOWN_PROXY_INFIXES)
+ if (className.contains(infix))
+ return true;
+ return false;
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/RestDebugPipeline.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/RestDebugPipeline.java
index f7f0b3a116..dbe7844a17 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/RestDebugPipeline.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/logging/RestDebugPipeline.java
@@ -32,8 +32,9 @@ import org.apache.juneau.rest.server.*;
* <li>tier {@code INFO} — {@code formatBasic} only.
* <li>tier {@code FINE} — {@code +formatHeaders}.
* <li>tier {@code FINEST} — {@code +formatBody} (from the cached
bytes).
- * <li>below {@code INFO} — nothing is emitted.
+ * <li>coarser than {@code INFO} ({@code WARNING}, {@code SEVERE}, {@code
OFF}) — nothing is emitted.
* </ul>
+ * The emitted record level is always {@link Level#INFO}; the resolved tier
controls message detail only.
*
* @since 10.0.0
*/
@@ -42,7 +43,11 @@ public class RestDebugPipeline {
private RestDebugPipeline() {}
/**
- * Emits the debug record for the completed call, if the resolved
logger's level warrants it.
+ * Emits one debug record for the completed call, if the resolved
logger's effective level warrants it.
+ *
+ * <p>
+ * The emitted record is always stamped at {@link Level#INFO}; the
resolved tier controls only which
+ * sections are rendered into the message body.
*
* @param session The completed REST session. Must not be <jk>null</jk>.
*/
@@ -52,13 +57,13 @@ public class RestDebugPipeline {
if (logger == null)
return;
- var level = resolveTier(logger);
- if (level == null)
+ var tier = resolveTier(logger);
+ if (tier == null)
return;
- var msg = render(session, opSession, level);
+ var msg = render(session, opSession, tier);
- var record = new LogRecord(level, msg);
+ var record = new LogRecord(Level.INFO, msg);
record.setLoggerName(logger.getName());
var thrown = session.getException();
if (thrown != null)
@@ -93,7 +98,7 @@ public class RestDebugPipeline {
* Maps the resolved logger's effective level to the cumulative debug
tier.
*
* @param logger The resolved logger.
- * @return {@code FINEST}/{@code FINE}/{@code INFO}, or <jk>null</jk>
if the logger is not loggable at {@code INFO}.
+ * @return {@code FINEST}/{@code FINE}/{@code INFO}, or <jk>null</jk>
if the logger is coarser than {@code INFO}.
*/
static Level resolveTier(Logger logger) {
if (logger.isLoggable(Level.FINEST))
diff --git
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/logging/LoggerNaming_Test.java
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/logging/LoggerNaming_Test.java
new file mode 100644
index 0000000000..06b22065d5
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/logging/LoggerNaming_Test.java
@@ -0,0 +1,67 @@
+/*
+ * 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.server.logging;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit tests for {@link LoggerNaming}.
+ *
+ * @since 10.0.0
+ */
+class LoggerNaming_Test {
+
+ static class A00_UserClass {}
+ static class A01_Nested {
+ static class Inner {}
+ }
+ static class A02_User$$SpringCGLIB$$ extends A00_UserClass {}
+ static class A03_User$$EnhancerBySpringCGLIB$$ extends A00_UserClass {}
+ static class A04_User$$EnhancerByCGLIB$$ extends A00_UserClass {}
+ static class A05_User$$FastClassBySpringCGLIB$$ extends A00_UserClass {}
+ static class A06_User$ByteBuddy$ extends A00_UserClass {}
+ static class A07_User$Proxy0 extends A00_UserClass {}
+ static class A08_User$HibernateProxy$1 extends A00_UserClass {}
+ static class A09_User$MockitoMock$1 extends A00_UserClass {}
+ static class A10_ProxyChain$$SpringCGLIB$$ extends A06_User$ByteBuddy$
{}
+
+ @Test void a01_knownProxyInfixes_resolveToUserSuperclassName() {
+ var expected = A00_UserClass.class.getName();
+ assertEquals(expected,
LoggerNaming.userClassName(A02_User$$SpringCGLIB$$.class));
+ assertEquals(expected,
LoggerNaming.userClassName(A03_User$$EnhancerBySpringCGLIB$$.class));
+ assertEquals(expected,
LoggerNaming.userClassName(A04_User$$EnhancerByCGLIB$$.class));
+ assertEquals(expected,
LoggerNaming.userClassName(A05_User$$FastClassBySpringCGLIB$$.class));
+ assertEquals(expected,
LoggerNaming.userClassName(A06_User$ByteBuddy$.class));
+ assertEquals(expected,
LoggerNaming.userClassName(A07_User$Proxy0.class));
+ assertEquals(expected,
LoggerNaming.userClassName(A08_User$HibernateProxy$1.class));
+ assertEquals(expected,
LoggerNaming.userClassName(A09_User$MockitoMock$1.class));
+ }
+
+ @Test void a02_superclassWalk_handlesMultipleProxyLayers() {
+ assertEquals(A00_UserClass.class.getName(),
LoggerNaming.userClassName(A10_ProxyChain$$SpringCGLIB$$.class));
+ }
+
+ @Test void a03_nonProxyClassName_isUnchanged() {
+ assertEquals(A00_UserClass.class.getName(),
LoggerNaming.userClassName(A00_UserClass.class));
+ }
+
+ @Test void a04_nestedClassName_isPreserved() {
+ assertEquals(A01_Nested.Inner.class.getName(),
LoggerNaming.userClassName(A01_Nested.Inner.class));
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/logging/RestDebugPipeline_Test.java
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/logging/RestDebugPipeline_Test.java
index 2d1f5ad318..0cf7596f57 100644
---
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/logging/RestDebugPipeline_Test.java
+++
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/logging/RestDebugPipeline_Test.java
@@ -43,11 +43,27 @@ class RestDebugPipeline_Test {
assertEquals(Level.FINE,
RestDebugPipeline.resolveTier(newLogger(Level.FINE)));
}
- @Test void a03_info_resolvesInfo() {
+ @Test void a03_finer_resolvesFine() {
+ assertEquals(Level.FINE,
RestDebugPipeline.resolveTier(newLogger(Level.FINER)));
+ }
+
+ @Test void a04_config_resolvesInfo() {
+ assertEquals(Level.INFO,
RestDebugPipeline.resolveTier(newLogger(Level.CONFIG)));
+ }
+
+ @Test void a05_info_resolvesInfo() {
assertEquals(Level.INFO,
RestDebugPipeline.resolveTier(newLogger(Level.INFO)));
}
- @Test void a04_warning_resolvesNull() {
+ @Test void a06_warning_resolvesNull() {
assertNull(RestDebugPipeline.resolveTier(newLogger(Level.WARNING)));
}
+
+ @Test void a07_severe_resolvesNull() {
+
assertNull(RestDebugPipeline.resolveTier(newLogger(Level.SEVERE)));
+ }
+
+ @Test void a08_off_resolvesNull() {
+ assertNull(RestDebugPipeline.resolveTier(newLogger(Level.OFF)));
+ }
}