You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/10/15 11:23:27 UTC
(maven) 01/01: feat: logging foundation – structured LogEvent, JUL handler, Log API enhancements
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch feature/logging-foundation
in repository https://gitbox.apache.org/repos/asf/maven.git
commit d70fcc0f9e44bf4d0a104f9429f0b0dd882a45aa
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Thu Oct 15 13:23:13 2026 +0200
feat: logging foundation – structured LogEvent, JUL handler, Log API enhancements
Structured LogEvent (maven-api-core, maven-core):
- LogEvent / LogLevel API for structured log event representation
- Source metadata: sourceClassName(), sourceMethodName(), threadId()
- Execution context: projectId() and mojoId()
Custom JUL Handler (maven-logging):
- MavenJulHandler replaces SLF4JBridgeHandler — all JUL events route
through SLF4J for consistent formatting regardless of origin
- JUL metadata flows as method parameters via logDirect() instead of
ThreadLocals
- Re-entrancy guard for JLine StyleResolver → JUL → SLF4J bootstrap cycle
Direct logging path (maven-logging, maven-core):
- MavenSimpleLogger.logDirect() bypasses the SLF4J call chain for Log API
and JUL events, carrying source metadata as parameters
- MavenBaseLogger.formatLogPrefix() extracted as shared formatting logic
- Eliminates ThreadLocal round-trips, lambda allocations, and SLF4J
dispatch overhead for Maven-controlled logging paths
Capability-driven optimization (maven-logging):
- LogSink.needsFormattedMessage() — skip formatting when consumer uses
structured fields (e.g. machine/JSON output mode)
- LogSink.needsMetadata() — skip StackWalker and SourceMetadata allocation
when consumer doesn't read source info (plain/rich console modes)
- ProjectBuildLogAppender wires flags: metadata auto-enabled when
reportCapture is active
Structured LogSink (maven-logging, maven-core):
- MavenSimpleLogger.LogSink replaces Consumer<String> with structured
callback including level, logger name, clean message, and metadata
- ProjectBuildLogAppender produces LogEvent objects with source metadata
- BuildEventListener.projectLogMessage() now takes LogEvent
Log API metadata (maven-core):
- DefaultLog resolves caller class/method via StackWalker, gated behind
LogSink.needsMetadata() for zero overhead in plain/rich console modes
Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
apache-maven/pom.xml | 7 -
api/maven-api-core/src/main/java/module-info.java | 1 +
.../apache/maven/api/build/report/LogEvent.java | 189 +++++++++++++++
.../apache/maven/api/build/report/LogLevel.java | 34 ++-
.../maven/api/build/report/package-info.java | 26 +-
compat/maven-embedder/pom.xml | 5 -
impl/maven-cli/pom.xml | 9 -
.../apache/maven/cling/invoker/LookupInvoker.java | 71 +++++-
.../cling/logging/impl/LogbackConfiguration.java | 46 ----
.../META-INF/maven/slf4j-configuration.properties | 2 -
.../maven/internal/build/DefaultLogEvent.java | 81 +++++++
.../org/apache/maven/internal/impl/DefaultLog.java | 99 ++++++--
.../apache/maven/logging/BuildEventListener.java | 3 +-
.../maven/logging/ProjectBuildLogAppender.java | 145 +++++++++++-
.../maven/logging/SimpleBuildEventListener.java | 6 +-
.../apache/maven/internal/impl/DefaultLogTest.java | 5 +-
.../maven/jline/JLineMessageBuilderFactory.java | 13 +-
impl/maven-logging/src/main/java/module-info.java | 1 +
.../org/apache/maven/slf4j/MavenBaseLogger.java | 62 +++--
.../org/apache/maven/slf4j/MavenJulHandler.java | 262 +++++++++++++++++++++
.../org/apache/maven/slf4j/MavenSimpleLogger.java | 135 ++++++++++-
.../apache/maven/slf4j/MavenJulHandlerTest.java | 119 ++++++++++
pom.xml | 11 -
23 files changed, 1143 insertions(+), 189 deletions(-)
diff --git a/apache-maven/pom.xml b/apache-maven/pom.xml
index 9965888818..a473c9fe44 100644
--- a/apache-maven/pom.xml
+++ b/apache-maven/pom.xml
@@ -78,13 +78,6 @@ under the License.
<version>${slf4jVersion}</version>
<scope>runtime</scope>
</dependency>
- <!-- bridge from java.util.logging (JUL) to SLF4J -->
- <dependency>
- <groupId>org.slf4j</groupId>
- <artifactId>jul-to-slf4j</artifactId>
- <version>${slf4jVersion}</version>
- <scope>runtime</scope>
- </dependency>
<dependency>
<groupId>org.apache.maven.resolver</groupId>
<artifactId>maven-resolver-connector-basic</artifactId>
diff --git a/api/maven-api-core/src/main/java/module-info.java b/api/maven-api-core/src/main/java/module-info.java
index 7cbfb5792d..d0f9d73f72 100644
--- a/api/maven-api-core/src/main/java/module-info.java
+++ b/api/maven-api-core/src/main/java/module-info.java
@@ -28,6 +28,7 @@
requires java.compiler;
exports org.apache.maven.api;
+ exports org.apache.maven.api.build.report;
exports org.apache.maven.api.cache;
exports org.apache.maven.api.feature;
exports org.apache.maven.api.plugin;
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java
new file mode 100644
index 0000000000..64b232171d
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java
@@ -0,0 +1,189 @@
+/*
+ * 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.maven.api.build.report;
+
+import java.time.Instant;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+
+/**
+ * A structured log event captured during the build.
+ * <p>
+ * Each event carries the log level, timestamp, message, and optionally
+ * the logger name and a stack trace. This replaces raw log line strings
+ * in the build report, enabling programmatic filtering by level and
+ * correlation by timestamp.
+ * <p>
+ * Events originating from the Maven Log API or from JUL
+ * ({@code java.util.logging}) carry additional source metadata: the
+ * source class name, source method name, and thread identifier.
+ * For Log API events the source class name is the mojo implementation
+ * FQCN; for JUL events it comes from {@code LogRecord}. Events from
+ * direct SLF4J logging have these fields set to {@code null}.
+ *
+ * @since 4.1.0
+ */
+@Experimental
+@Immutable
+public interface LogEvent {
+
+ /**
+ * When this log event was produced (wall-clock time).
+ *
+ * @return the event instant, never {@code null}
+ */
+ @Nonnull
+ Instant timestamp();
+
+ /**
+ * The severity level of this log event.
+ *
+ * @return the log level, never {@code null}
+ */
+ @Nonnull
+ LogLevel level();
+
+ /**
+ * The log message, without level prefix or timestamp formatting.
+ *
+ * @return the log message, never {@code null}
+ */
+ @Nonnull
+ String message();
+
+ /**
+ * The name of the logger that produced this event
+ * (e.g. {@code "org.apache.maven.plugins.compiler.CompilerMojo"}).
+ *
+ * @return the logger name, or {@code null} if unavailable
+ */
+ @Nullable
+ String loggerName();
+
+ /**
+ * The stack trace associated with this event, if an exception was logged.
+ * <p>
+ * The trace is formatted as a multi-line string and may be truncated
+ * for very deep stack traces.
+ *
+ * @return the stack trace string, or {@code null} if no exception was logged
+ */
+ @Nullable
+ String stackTrace();
+
+ /**
+ * The fully formatted log line as rendered for console output, including
+ * the level prefix, timestamp, and any ANSI styling applied by the logger.
+ * <p>
+ * This is the string that would be printed to the terminal in verbose mode.
+ * Console renderers that just need pass-through output can use this directly,
+ * while renderers that apply custom formatting (e.g. rich mode) can use the
+ * structured fields ({@link #level()}, {@link #message()}) instead.
+ * <p>
+ * May be {@code null} if the event was created outside the SLF4J pipeline
+ * (e.g. in tests or by programmatic construction).
+ *
+ * @return the formatted log line, or {@code null}
+ */
+ @Nullable
+ String formattedMessage();
+
+ // ---- Source metadata (populated for Log API and JUL events) ----
+
+ /**
+ * The fully qualified class name of the source that issued the log call.
+ * <p>
+ * For Maven Log API events this is the mojo implementation class name.
+ * For JUL events it is the value from {@code LogRecord.getSourceClassName()}.
+ * For direct SLF4J logging it is {@code null}.
+ *
+ * @return the source class name, or {@code null}
+ */
+ @Nullable
+ default String sourceClassName() {
+ return null;
+ }
+
+ /**
+ * The method name of the source that issued the log call.
+ * <p>
+ * For Maven Log API events this is resolved via {@link StackWalker}.
+ * For JUL events it is the value from {@code LogRecord.getSourceMethodName()}.
+ * For direct SLF4J logging it is {@code null}.
+ *
+ * @return the source method name, or {@code null}
+ */
+ @Nullable
+ default String sourceMethodName() {
+ return null;
+ }
+
+ /**
+ * The thread identifier from which this log event originated.
+ * <p>
+ * Populated for both Log API and JUL events. Returns {@code -1}
+ * if the thread ID is not available (i.e. for direct SLF4J events).
+ *
+ * @return the thread ID, or {@code -1} if unavailable
+ */
+ default long threadId() {
+ return -1;
+ }
+
+ /**
+ * A monotonically increasing sequence number for total ordering of
+ * log events, useful when multiple events share the same timestamp.
+ * <p>
+ * Assigned by the logging pipeline when the event is captured,
+ * providing a global ordering across all event sources (Log API,
+ * JUL, and direct SLF4J).
+ *
+ * @return the sequence number, or {@code -1} if unavailable
+ */
+ default long sequenceNumber() {
+ return -1;
+ }
+
+ /**
+ * The project this log event belongs to
+ * (e.g. {@code "org.apache.maven:maven-core:4.1.0-SNAPSHOT"}), or {@code null}
+ * if the event was not produced in the context of a specific project.
+ *
+ * @return the project identifier, or {@code null}
+ */
+ @Nullable
+ default String projectId() {
+ return null;
+ }
+
+ /**
+ * The mojo execution that produced this event
+ * (e.g. {@code "compiler:compile@default-compile"}), or {@code null}
+ * if the event was logged outside a mojo execution.
+ *
+ * @return the mojo execution identifier, or {@code null}
+ */
+ @Nullable
+ default String mojoId() {
+ return null;
+ }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java
similarity index 56%
rename from impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java
rename to api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java
index bbd487fa5f..cb1de663f2 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java
@@ -16,29 +16,23 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.maven.cling.logging.impl;
+package org.apache.maven.api.build.report;
-import org.apache.maven.cling.logging.BaseSlf4jConfiguration;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
/**
- * Configuration for slf4j-log4j2.
+ * Log severity levels, mirroring the standard SLF4J levels.
*
- * @since 3.1.0
+ * @since 4.1.0
+ * @see LogEvent#level()
*/
-public class Log4j2Configuration extends BaseSlf4jConfiguration {
- @Override
- public void setRootLoggerLevel(Level level) {
- String value =
- switch (level) {
- case DEBUG -> "debug";
- case INFO -> "info";
- default -> "error";
- };
- System.setProperty("maven.logging.root.level", value);
- }
-
- @Override
- public void activate() {
- // no op
- }
+@Experimental
+@Immutable
+public enum LogLevel {
+ TRACE,
+ DEBUG,
+ INFO,
+ WARN,
+ ERROR
}
diff --git a/impl/maven-logging/src/main/java/module-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
similarity index 63%
copy from impl/maven-logging/src/main/java/module-info.java
copy to api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
index 57e1df7014..8aee68ff90 100644
--- a/impl/maven-logging/src/main/java/module-info.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
@@ -17,17 +17,17 @@
* under the License.
*/
-module org.apache.maven.logging {
- requires org.apache.maven.api;
- requires org.apache.maven.jline;
- requires org.slf4j;
-
- exports org.apache.maven.logging.api;
- exports org.apache.maven.slf4j to
- org.apache.maven.cling,
- org.apache.maven.embedder,
- org.apache.maven.core;
+/**
+ * Structured build report data model.
+ * <p>
+ * This package provides structured representations of build execution
+ * data, including log events and (in future) full build reports.
+ * {@link org.apache.maven.api.build.report.LogEvent} is the foundational
+ * type representing a single structured log entry captured during the build.
+ *
+ * @since 4.1.0
+ */
+@Experimental
+package org.apache.maven.api.build.report;
- provides org.slf4j.spi.SLF4JServiceProvider with
- org.apache.maven.slf4j.MavenServiceProvider;
-}
+import org.apache.maven.api.annotations.Experimental;
diff --git a/compat/maven-embedder/pom.xml b/compat/maven-embedder/pom.xml
index 2df8588ec1..56730ccb43 100644
--- a/compat/maven-embedder/pom.xml
+++ b/compat/maven-embedder/pom.xml
@@ -163,11 +163,6 @@ under the License.
<artifactId>commons-cli</artifactId>
</dependency>
- <dependency>
- <groupId>ch.qos.logback</groupId>
- <artifactId>logback-classic</artifactId>
- <optional>true</optional>
- </dependency>
<dependency>
<groupId>org.jline</groupId>
<artifactId>jansi-core</artifactId>
diff --git a/impl/maven-cli/pom.xml b/impl/maven-cli/pom.xml
index 06bd4c72dd..46ad87debc 100644
--- a/impl/maven-cli/pom.xml
+++ b/impl/maven-cli/pom.xml
@@ -199,19 +199,10 @@ under the License.
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
- <dependency>
- <groupId>org.slf4j</groupId>
- <artifactId>jul-to-slf4j</artifactId>
- </dependency>
<dependency>
<groupId>commons-cli</groupId>
<artifactId>commons-cli</artifactId>
</dependency>
- <dependency>
- <groupId>ch.qos.logback</groupId>
- <artifactId>logback-classic</artifactId>
- <optional>true</optional>
- </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java
index 8f6ace0ee7..105aed20fe 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java
@@ -38,6 +38,8 @@
import java.util.function.Consumer;
import java.util.function.UnaryOperator;
import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogManager;
import org.apache.maven.api.Constants;
import org.apache.maven.api.ProtoSession;
@@ -85,6 +87,7 @@
import org.apache.maven.logging.ProjectBuildLogAppender;
import org.apache.maven.logging.SimpleBuildEventListener;
import org.apache.maven.logging.api.LogLevelRecorder;
+import org.apache.maven.slf4j.MavenJulHandler;
import org.apache.maven.slf4j.MavenSimpleLogger;
import org.codehaus.plexus.PlexusContainer;
import org.jline.terminal.Terminal;
@@ -93,7 +96,6 @@
import org.jline.terminal.spi.TerminalExt;
import org.jline.utils.OSUtils;
import org.slf4j.LoggerFactory;
-import org.slf4j.bridge.SLF4JBridgeHandler;
import org.slf4j.spi.LocationAwareLogger;
import static java.util.Objects.requireNonNull;
@@ -125,8 +127,10 @@ public final int invoke(InvokerRequest invokerRequest) {
Properties oldProps = new Properties();
oldProps.putAll(System.getProperties());
ClassLoader oldCL = Thread.currentThread().getContextClassLoader();
- // activateLogging() replaces them with SLF4JBridgeHandler, whose classes live in Maven's realm
- Handler[] oldRootLoggerHandlers = java.util.logging.Logger.getLogger("").getHandlers();
+ // activateLogging() replaces them with MavenJulHandler, whose classes live in Maven's realm
+ java.util.logging.Logger julRoot = java.util.logging.Logger.getLogger("");
+ Handler[] oldRootLoggerHandlers = julRoot.getHandlers();
+ java.util.logging.Level oldRootLoggerLevel = julRoot.getLevel();
try (C context = createContext(invokerRequest)) {
if (contextConsumer != null) {
contextConsumer.accept(context);
@@ -150,16 +154,18 @@ public final int invoke(InvokerRequest invokerRequest) {
} finally {
Thread.currentThread().setContextClassLoader(oldCL);
System.setProperties(oldProps);
- restoreRootLoggerHandlers(oldRootLoggerHandlers);
+ restoreRootLoggerHandlers(oldRootLoggerHandlers, oldRootLoggerLevel);
}
}
/**
- * Puts back the handlers the {@code java.util.logging} root logger had before the invocation. When Maven runs inside
- * another JVM, the root logger belongs to that JVM, and a {@code SLF4JBridgeHandler} left on it would route the
- * host's logging to Maven and fail once Maven's class realm is closed.
+ * Puts back the handlers and root level the {@code java.util.logging} root logger had before the invocation.
+ * When Maven runs inside another JVM, the root logger belongs to that JVM, and a {@code MavenJulHandler} left
+ * on it would route the host's logging to Maven and fail once Maven's class realm is closed. Similarly, the
+ * level change (e.g. {@code SEVERE} in quiet mode) must be reverted so the host's logging is not suppressed
+ * after the invocation completes.
*/
- private static void restoreRootLoggerHandlers(Handler[] handlers) {
+ private static void restoreRootLoggerHandlers(Handler[] handlers, java.util.logging.Level level) {
java.util.logging.Logger root = java.util.logging.Logger.getLogger("");
for (Handler handler : root.getHandlers()) {
root.removeHandler(handler);
@@ -167,6 +173,7 @@ private static void restoreRootLoggerHandlers(Handler[] handlers) {
for (Handler handler : handlers) {
root.addHandler(handler);
}
+ root.setLevel(level);
}
protected int doInvoke(C context) throws Exception {
@@ -314,6 +321,20 @@ protected void configureLogging(C context) throws Exception {
} else if (context.options().quiet().orElse(false)) {
context.loggerLevel = Slf4jConfiguration.Level.ERROR;
context.slf4jConfiguration.setRootLoggerLevel(context.loggerLevel);
+ // Install the JUL handler early and clamp the JUL root to SEVERE
+ // so that JLine terminal-init DEBUG events (emitted via java.util.logging
+ // before activateLogging() runs) are suppressed at source.
+ // createTerminal() executes after configureLogging() but before
+ // activateLogging(), so without this guard those DEBUG lines leak
+ // into the build output even when -q is in effect, causing
+ // MavenITmng4387QuietLoggingTest to fail on Windows.
+ // Setting SEVERE here is safe: it only blocks events, so it
+ // cannot trigger the SLF4J-bootstrap reentrancy flood that
+ // Level.ALL would cause.
+ if (!MavenJulHandler.isInstalled()) {
+ MavenJulHandler.install();
+ }
+ LogManager.getLogManager().getLogger("").setLevel(Level.SEVERE);
} else {
// fall back to default log level specified in conf
// see https://issues.apache.org/jira/browse/MNG-2570 and https://github.com/apache/maven/issues/11199
@@ -466,12 +487,33 @@ protected Consumer<String> doDetermineWriter(C context) {
}
protected void activateLogging(C context) throws Exception {
- if (!SLF4JBridgeHandler.isInstalled()) {
- SLF4JBridgeHandler.removeHandlersForRootLogger();
- SLF4JBridgeHandler.install();
+ if (!MavenJulHandler.isInstalled()) {
+ MavenJulHandler.install();
}
context.slf4jConfiguration.activate();
+
+ // Now that SLF4J is fully initialized, set the JUL root logger level
+ // to match the effective log level. This must happen AFTER install()
+ // + activate() to avoid flooding JUL events during SLF4J bootstrap
+ // (ConcurrentHashMap.computeIfAbsent reentrancy).
+ // In quiet mode keep the JUL root at SEVERE so that INFO/WARN/DEBUG JUL
+ // events are suppressed at source — relying solely on the SLF4J-level
+ // check in MavenJulHandler.isLevelEnabled() is racy: newly created
+ // SLF4J loggers may briefly see the default INFO level before
+ // quiet-mode propagation completes, leaking output that
+ // MavenITmng4387QuietLoggingTest detects as a flaky failure.
+ // SEVERE matches the SLF4J ERROR threshold exactly, blocking both
+ // INFO and WARNING JUL events at source during the race window.
+ Level julRootLevel;
+ if (context.options().quiet().orElse(false)) {
+ julRootLevel = Level.SEVERE;
+ } else if (context.invokerRequest.effectiveVerbose()) {
+ julRootLevel = Level.ALL;
+ } else {
+ julRootLevel = Level.INFO;
+ }
+ LogManager.getLogManager().getLogger("").setLevel(julRootLevel);
if (context.options().failOnSeverity().isPresent()) {
String logLevelThreshold = context.options().failOnSeverity().get();
if (context.loggerFactory instanceof LogLevelRecorder recorder) {
@@ -494,7 +536,12 @@ protected void activateLogging(C context) throws Exception {
}
}
- // at this point logging is set up, reply so far accumulated logs, if any and swap logger with real one
+ // At this point logging is set up and createTerminal() has already run
+ // (doInvoke calls createTerminal before activateLogging), so
+ // ProjectBuildLogAppender has already installed the MavenSimpleLogger
+ // logSink and wired up any -l log-file writer. We can drain the
+ // accumulated early log queue directly into the new Slf4jLogger —
+ // messages will be routed through the logSink to the correct output.
Logger logger =
new Slf4jLogger(context.loggerFactory.getLogger(getClass().getName()));
context.logger.drain().forEach(e -> logger.log(e.level(), e.message(), e.error()));
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java
deleted file mode 100644
index 67ee429d82..0000000000
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java
+++ /dev/null
@@ -1,46 +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.maven.cling.logging.impl;
-
-import org.apache.maven.cling.logging.BaseSlf4jConfiguration;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Configuration for slf4j-logback.
- *
- * @since 3.1.0
- */
-public class LogbackConfiguration extends BaseSlf4jConfiguration {
- @Override
- public void setRootLoggerLevel(Level level) {
- ch.qos.logback.classic.Level value =
- switch (level) {
- case DEBUG -> ch.qos.logback.classic.Level.DEBUG;
- case INFO -> ch.qos.logback.classic.Level.INFO;
- default -> ch.qos.logback.classic.Level.ERROR;
- };
- ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).setLevel(value);
- }
-
- @Override
- public void activate() {
- // no op
- }
-}
diff --git a/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties b/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties
index 369b0e6a26..9580d5071a 100644
--- a/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties
+++ b/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties
@@ -19,5 +19,3 @@
# value = corresponding o.a.m.cli.logging.Slf4jConfiguration class
org.slf4j.impl.SimpleLoggerFactory=org.apache.maven.cling.logging.impl.MavenSimpleConfiguration
org.apache.maven.slf4j.MavenLoggerFactory=org.apache.maven.cling.logging.impl.MavenSimpleConfiguration
-org.apache.logging.slf4j.Log4jLoggerFactory=org.apache.maven.cling.logging.impl.Log4j2Configuration
-ch.qos.logback.classic.LoggerContext=org.apache.maven.cling.logging.impl.LogbackConfiguration
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java
new file mode 100644
index 0000000000..7c6fcb93f7
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java
@@ -0,0 +1,81 @@
+/*
+ * 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.maven.internal.build;
+
+import java.time.Instant;
+
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.LogLevel;
+
+/**
+ * Immutable implementation of {@link LogEvent}.
+ * <p>
+ * Public to allow construction from other packages within the Maven
+ * implementation (e.g. {@code ProjectBuildLogAppender}).
+ *
+ * @param timestamp when the event was produced
+ * @param level the severity level
+ * @param message the clean log message (without level prefix or ANSI)
+ * @param loggerName the name of the logger, or {@code null}
+ * @param stackTrace the stack trace string, or {@code null}
+ * @param formattedMessage the fully formatted console line, or {@code null}
+ * @param sourceClassName the source class name (Log API mojo FQCN or JUL source), or {@code null}
+ * @param sourceMethodName the source method name (via StackWalker or JUL), or {@code null}
+ * @param threadId the originating thread ID, or {@code -1} if unavailable
+ * @param sequenceNumber the global sequence number for ordering, or {@code -1} if unavailable
+ * @param projectId the project this event belongs to, or {@code null}
+ * @param mojoId the mojo execution that produced this event, or {@code null}
+ */
+public record DefaultLogEvent(
+ Instant timestamp,
+ LogLevel level,
+ String message,
+ String loggerName,
+ String stackTrace,
+ String formattedMessage,
+ String sourceClassName,
+ String sourceMethodName,
+ long threadId,
+ long sequenceNumber,
+ String projectId,
+ String mojoId)
+ implements LogEvent {
+
+ /**
+ * Convenience constructor for events without source metadata
+ * (i.e. direct SLF4J events).
+ */
+ public DefaultLogEvent(
+ Instant timestamp,
+ LogLevel level,
+ String message,
+ String loggerName,
+ String stackTrace,
+ String formattedMessage) {
+ this(timestamp, level, message, loggerName, stackTrace, formattedMessage, null, null, -1, -1, null, null);
+ }
+
+ /**
+ * Convenience constructor for events created without a formatted message
+ * (e.g. in tests or programmatic construction).
+ */
+ DefaultLogEvent(Instant timestamp, LogLevel level, String message, String loggerName, String stackTrace) {
+ this(timestamp, level, message, loggerName, stackTrace, null, null, null, -1, -1, null, null);
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java
index b1cf40cc40..c97dad7582 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java
@@ -18,21 +18,70 @@
*/
package org.apache.maven.internal.impl;
+import java.lang.StackWalker.StackFrame;
import java.util.function.Supplier;
import org.apache.maven.api.plugin.Log;
+import org.apache.maven.slf4j.MavenSimpleLogger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.slf4j.spi.LocationAwareLogger;
import static java.util.Objects.requireNonNull;
public class DefaultLog implements Log {
+
+ private static final StackWalker WALKER = StackWalker.getInstance();
+ private static final String THIS_CLASS = DefaultLog.class.getName();
+
private final Logger logger;
public DefaultLog(Logger logger) {
this.logger = requireNonNull(logger);
}
+ @SuppressWarnings("deprecation") // Thread.getId() — threadId() requires Java 19+
+ private void doLog(int level, String message, Throwable throwable) {
+ if (logger instanceof MavenSimpleLogger msl) {
+ MavenSimpleLogger.SourceMetadata metadata = MavenSimpleLogger.isMetadataEnabled()
+ ? new MavenSimpleLogger.SourceMetadata(
+ logger.getName(),
+ resolveCallerMethod(),
+ Thread.currentThread().getId())
+ : null;
+ msl.logDirect(level, message, throwable, metadata);
+ } else {
+ logToSlf4j(level, message, throwable);
+ }
+ }
+
+ private String resolveCallerMethod() {
+ return WALKER.walk(frames -> frames.dropWhile(f -> THIS_CLASS.equals(f.getClassName()))
+ .findFirst()
+ .map(StackFrame::getMethodName)
+ .orElse(null));
+ }
+
+ private void logToSlf4j(int level, String message, Throwable throwable) {
+ if (throwable != null) {
+ switch (level) {
+ case LocationAwareLogger.TRACE_INT -> logger.trace(message, throwable);
+ case LocationAwareLogger.DEBUG_INT -> logger.debug(message, throwable);
+ case LocationAwareLogger.INFO_INT -> logger.info(message, throwable);
+ case LocationAwareLogger.WARN_INT -> logger.warn(message, throwable);
+ default -> logger.error(message, throwable);
+ }
+ } else {
+ switch (level) {
+ case LocationAwareLogger.TRACE_INT -> logger.trace(message);
+ case LocationAwareLogger.DEBUG_INT -> logger.debug(message);
+ case LocationAwareLogger.INFO_INT -> logger.info(message);
+ case LocationAwareLogger.WARN_INT -> logger.warn(message);
+ default -> logger.error(message);
+ }
+ }
+ }
+
@Override
public boolean isTraceEnabled() {
return logger.isTraceEnabled();
@@ -41,175 +90,175 @@ public boolean isTraceEnabled() {
@Override
public void trace(CharSequence content) {
if (isTraceEnabled()) {
- logger.trace(toString(content));
+ doLog(LocationAwareLogger.TRACE_INT, toString(content), null);
}
}
@Override
public void trace(CharSequence content, Throwable error) {
if (isTraceEnabled()) {
- logger.trace(toString(content), error);
+ doLog(LocationAwareLogger.TRACE_INT, toString(content), error);
}
}
@Override
public void trace(Throwable error) {
if (isTraceEnabled()) {
- logger.trace("", error);
+ doLog(LocationAwareLogger.TRACE_INT, "", error);
}
}
@Override
public void trace(Supplier<String> content) {
if (isTraceEnabled()) {
- logger.trace(content.get());
+ doLog(LocationAwareLogger.TRACE_INT, content.get(), null);
}
}
@Override
public void trace(Supplier<String> content, Throwable error) {
if (isTraceEnabled()) {
- logger.trace(content.get(), error);
+ doLog(LocationAwareLogger.TRACE_INT, content.get(), error);
}
}
@Override
public void debug(CharSequence content) {
if (isDebugEnabled()) {
- logger.debug(toString(content));
+ doLog(LocationAwareLogger.DEBUG_INT, toString(content), null);
}
}
@Override
public void debug(CharSequence content, Throwable error) {
if (isDebugEnabled()) {
- logger.debug(toString(content), error);
+ doLog(LocationAwareLogger.DEBUG_INT, toString(content), error);
}
}
@Override
public void debug(Throwable error) {
if (isDebugEnabled()) {
- logger.debug("", error);
+ doLog(LocationAwareLogger.DEBUG_INT, "", error);
}
}
@Override
public void debug(Supplier<String> content) {
if (isDebugEnabled()) {
- logger.debug(content.get());
+ doLog(LocationAwareLogger.DEBUG_INT, content.get(), null);
}
}
@Override
public void debug(Supplier<String> content, Throwable error) {
if (isDebugEnabled()) {
- logger.debug(content.get(), error);
+ doLog(LocationAwareLogger.DEBUG_INT, content.get(), error);
}
}
@Override
public void info(CharSequence content) {
if (isInfoEnabled()) {
- logger.info(toString(content));
+ doLog(LocationAwareLogger.INFO_INT, toString(content), null);
}
}
@Override
public void info(CharSequence content, Throwable error) {
if (isInfoEnabled()) {
- logger.info(toString(content), error);
+ doLog(LocationAwareLogger.INFO_INT, toString(content), error);
}
}
@Override
public void info(Throwable error) {
if (isInfoEnabled()) {
- logger.info("", error);
+ doLog(LocationAwareLogger.INFO_INT, "", error);
}
}
@Override
public void info(Supplier<String> content) {
if (isInfoEnabled()) {
- logger.info(content.get());
+ doLog(LocationAwareLogger.INFO_INT, content.get(), null);
}
}
@Override
public void info(Supplier<String> content, Throwable error) {
if (isInfoEnabled()) {
- logger.info(content.get(), error);
+ doLog(LocationAwareLogger.INFO_INT, content.get(), error);
}
}
@Override
public void warn(CharSequence content) {
if (isWarnEnabled()) {
- logger.warn(toString(content));
+ doLog(LocationAwareLogger.WARN_INT, toString(content), null);
}
}
@Override
public void warn(CharSequence content, Throwable error) {
if (isWarnEnabled()) {
- logger.warn(toString(content), error);
+ doLog(LocationAwareLogger.WARN_INT, toString(content), error);
}
}
@Override
public void warn(Throwable error) {
if (isWarnEnabled()) {
- logger.warn("", error);
+ doLog(LocationAwareLogger.WARN_INT, "", error);
}
}
@Override
public void warn(Supplier<String> content) {
if (isWarnEnabled()) {
- logger.warn(content.get());
+ doLog(LocationAwareLogger.WARN_INT, content.get(), null);
}
}
@Override
public void warn(Supplier<String> content, Throwable error) {
if (isWarnEnabled()) {
- logger.warn(content.get(), error);
+ doLog(LocationAwareLogger.WARN_INT, content.get(), error);
}
}
@Override
public void error(CharSequence content) {
if (isErrorEnabled()) {
- logger.error(toString(content));
+ doLog(LocationAwareLogger.ERROR_INT, toString(content), null);
}
}
@Override
public void error(CharSequence content, Throwable error) {
if (isErrorEnabled()) {
- logger.error(toString(content), error);
+ doLog(LocationAwareLogger.ERROR_INT, toString(content), error);
}
}
@Override
public void error(Throwable error) {
if (isErrorEnabled()) {
- logger.error("", error);
+ doLog(LocationAwareLogger.ERROR_INT, "", error);
}
}
@Override
public void error(Supplier<String> content) {
if (isErrorEnabled()) {
- logger.error(content.get());
+ doLog(LocationAwareLogger.ERROR_INT, content.get(), null);
}
}
@Override
public void error(Supplier<String> content, Throwable error) {
if (isErrorEnabled()) {
- logger.error(content.get(), error);
+ doLog(LocationAwareLogger.ERROR_INT, content.get(), error);
}
}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java
index 39573d061c..c1d771b5c1 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java
@@ -18,6 +18,7 @@
*/
package org.apache.maven.logging;
+import org.apache.maven.api.build.report.LogEvent;
import org.apache.maven.execution.ExecutionEvent;
import org.eclipse.aether.transfer.TransferEvent;
@@ -30,7 +31,7 @@ public interface BuildEventListener {
void projectStarted(String projectId);
- void projectLogMessage(String projectId, String event);
+ void projectLogMessage(String projectId, LogEvent event);
void projectFinished(String projectId);
diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java
index dc82a2f898..97a77ac796 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java
@@ -18,11 +18,27 @@
*/
package org.apache.maven.logging;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.time.Instant;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Consumer;
+
+import org.apache.maven.api.MonotonicClock;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.LogLevel;
+import org.apache.maven.internal.build.DefaultLogEvent;
import org.apache.maven.slf4j.MavenSimpleLogger;
import org.slf4j.MDC;
+import org.slf4j.spi.LocationAwareLogger;
/**
- * Forwards log messages to the client.
+ * Forwards log messages to the client as structured {@link LogEvent} objects.
+ * <p>
+ * Installs itself as a {@link MavenSimpleLogger.LogSink} to intercept all
+ * SLF4J log output, enrich it with structured metadata (level, logger name,
+ * clean message, formatted output), and forward to the active
+ * {@link BuildEventListener}.
*/
public class ProjectBuildLogAppender implements AutoCloseable {
@@ -118,20 +134,141 @@ public static void updateMdc() {
}
}
+ /**
+ * Global sequence counter for total ordering of log events across
+ * all sources (Log API, JUL, direct SLF4J). Incremented atomically
+ * in {@link #accept} which is called synchronously on the logging thread.
+ */
+ private static final AtomicLong SEQUENCE = new AtomicLong();
+
+ /**
+ * Callback for build report log capture. Receives the fully-formed
+ * {@link LogEvent} produced by {@link #accept}, eliminating the need
+ * for a second capture pipeline in {@code MavenSimpleLogger}.
+ * <p>
+ * Set by {@code BuildReportCollector} at session start, cleared at
+ * session end. The callback runs synchronously on the logging thread.
+ */
+ private static volatile Consumer<LogEvent> reportCapture;
+
+ /**
+ * Sets the report capture callback. When non-null, metadata is
+ * automatically enabled on the log sink so that source class name,
+ * method name and thread ID are captured.
+ *
+ * @param capture the callback, or {@code null} to remove
+ */
+ public static void setReportCapture(Consumer<LogEvent> capture) {
+ ProjectBuildLogAppender.reportCapture = capture;
+ }
+
private final BuildEventListener buildEventListener;
+ private boolean needsFormattedMessage = true;
+ private boolean needsMetadata;
public ProjectBuildLogAppender(BuildEventListener buildEventListener) {
this.buildEventListener = buildEventListener;
- MavenSimpleLogger.setLogSink(this::accept);
+ MavenSimpleLogger.setLogSink(new Sink());
+ }
+
+ /**
+ * Sets whether this appender's sink needs the formatted message.
+ * When {@code false}, the pipeline skips message formatting (timestamp,
+ * level prefix, logger name, ANSI styling). Default is {@code true}.
+ */
+ public void setNeedsFormattedMessage(boolean needsFormattedMessage) {
+ this.needsFormattedMessage = needsFormattedMessage;
+ }
+
+ /**
+ * Sets whether this appender's sink needs source metadata
+ * ({@code sourceClassName}, {@code sourceMethodName}, {@code threadId}).
+ * When {@code true}, the pipeline resolves caller info via
+ * {@link StackWalker} for Log API events. Default is {@code false}.
+ */
+ public void setNeedsMetadata(boolean needsMetadata) {
+ this.needsMetadata = needsMetadata;
+ }
+
+ private class Sink implements MavenSimpleLogger.LogSink {
+ @Override
+ public void accept(
+ int level,
+ String loggerName,
+ String cleanMessage,
+ String formattedMessage,
+ Throwable throwable,
+ MavenSimpleLogger.SourceMetadata metadata) {
+ ProjectBuildLogAppender.this.accept(level, loggerName, cleanMessage, formattedMessage, throwable, metadata);
+ }
+
+ @Override
+ public boolean needsFormattedMessage() {
+ return needsFormattedMessage;
+ }
+
+ @Override
+ public boolean needsMetadata() {
+ return needsMetadata || reportCapture != null;
+ }
}
- protected void accept(String message) {
+ protected void accept(
+ int level,
+ String loggerName,
+ String cleanMessage,
+ String formattedMessage,
+ Throwable throwable,
+ MavenSimpleLogger.SourceMetadata metadata) {
String projectId = MDC.get(KEY_PROJECT_ID);
- buildEventListener.projectLogMessage(projectId, message);
+ Instant timestamp = MonotonicClock.now();
+ LogLevel logLevel = toLogLevel(level);
+ long seq = SEQUENCE.getAndIncrement();
+ String stackTrace = throwable != null ? formatStackTrace(throwable) : null;
+
+ String sourceClassName = metadata != null ? metadata.sourceClassName() : null;
+ String sourceMethodName = metadata != null ? metadata.sourceMethodName() : null;
+ long threadId = metadata != null ? metadata.threadId() : -1;
+
+ LogEvent event = new DefaultLogEvent(
+ timestamp,
+ logLevel,
+ cleanMessage,
+ loggerName,
+ stackTrace,
+ formattedMessage,
+ sourceClassName,
+ sourceMethodName,
+ threadId,
+ seq,
+ projectId,
+ MOJO_ID.get());
+ buildEventListener.projectLogMessage(projectId, event);
+
+ Consumer<LogEvent> capture = reportCapture;
+ if (capture != null) {
+ capture.accept(event);
+ }
}
@Override
public void close() throws Exception {
MavenSimpleLogger.setLogSink(null);
}
+
+ private static LogLevel toLogLevel(int level) {
+ return switch (level) {
+ case LocationAwareLogger.TRACE_INT -> LogLevel.TRACE;
+ case LocationAwareLogger.DEBUG_INT -> LogLevel.DEBUG;
+ case LocationAwareLogger.INFO_INT -> LogLevel.INFO;
+ case LocationAwareLogger.WARN_INT -> LogLevel.WARN;
+ default -> LogLevel.ERROR;
+ };
+ }
+
+ private static String formatStackTrace(Throwable t) {
+ StringWriter sw = new StringWriter();
+ t.printStackTrace(new PrintWriter(sw));
+ return sw.toString();
+ }
}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java
index 87f7baa1fd..37c49a92c1 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java
@@ -20,6 +20,7 @@
import java.util.function.Consumer;
+import org.apache.maven.api.build.report.LogEvent;
import org.apache.maven.execution.ExecutionEvent;
import org.eclipse.aether.transfer.TransferEvent;
@@ -38,8 +39,9 @@ public void sessionStarted(ExecutionEvent event) {}
public void projectStarted(String projectId) {}
@Override
- public void projectLogMessage(String projectId, String event) {
- log(event);
+ public void projectLogMessage(String projectId, LogEvent event) {
+ String formatted = event.formattedMessage();
+ log(formatted != null ? formatted : event.message());
}
@Override
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java
index eb290734fb..96fa651b76 100644
--- a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java
@@ -31,7 +31,8 @@
import static org.mockito.Mockito.when;
/**
- * Tests for {@link DefaultLog}.
+ * Tests for {@link DefaultLog}, focused on verifying the bug fix for
+ * {@code warn(Supplier, Throwable)} and SLF4J delegation.
*/
class DefaultLogTest {
@@ -43,6 +44,7 @@ class DefaultLogTest {
void warnWithSupplierAndThrowableDelegatesToWarn() {
Logger mockLogger = mock(Logger.class);
when(mockLogger.isWarnEnabled()).thenReturn(true);
+ when(mockLogger.getName()).thenReturn("test.logger");
DefaultLog log = new DefaultLog(mockLogger);
RuntimeException ex = new RuntimeException("test");
@@ -58,6 +60,7 @@ void warnWithSupplierAndThrowableDelegatesToWarn() {
void traceMethodsDelegateToSlf4jTrace() {
Logger mockLogger = mock(Logger.class);
when(mockLogger.isTraceEnabled()).thenReturn(true);
+ when(mockLogger.getName()).thenReturn("test.logger");
DefaultLog log = new DefaultLog(mockLogger);
log.trace("trace message");
diff --git a/impl/maven-jline/src/main/java/org/apache/maven/jline/JLineMessageBuilderFactory.java b/impl/maven-jline/src/main/java/org/apache/maven/jline/JLineMessageBuilderFactory.java
index cfae3c000c..f02504809c 100644
--- a/impl/maven-jline/src/main/java/org/apache/maven/jline/JLineMessageBuilderFactory.java
+++ b/impl/maven-jline/src/main/java/org/apache/maven/jline/JLineMessageBuilderFactory.java
@@ -136,7 +136,18 @@ private MavenStyleResolver() {
@Override
public AttributedStyle resolve(String spec) {
- return styles.computeIfAbsent(spec, this::doResolve);
+ try {
+ return styles.computeIfAbsent(spec, this::doResolve);
+ } catch (IllegalStateException e) {
+ // ConcurrentHashMap.computeIfAbsent throws IllegalStateException("Recursive update")
+ // when the same map is re-entered from within a computeIfAbsent call on the same
+ // thread. This can happen during FastTerminal initialization: JLine's StyleResolver
+ // logs via JUL, MavenJulHandler routes to SLF4J, MavenSimpleLogger.renderLevel()
+ // lazily initialises styled level strings by calling style() → resolve() here,
+ // re-entering the same computeIfAbsent. Fall back to DEFAULT for this event;
+ // subsequent calls will hit the populated cache and succeed normally.
+ return AttributedStyle.DEFAULT;
+ }
}
@Override
diff --git a/impl/maven-logging/src/main/java/module-info.java b/impl/maven-logging/src/main/java/module-info.java
index 57e1df7014..2a0b7ddedb 100644
--- a/impl/maven-logging/src/main/java/module-info.java
+++ b/impl/maven-logging/src/main/java/module-info.java
@@ -21,6 +21,7 @@
requires org.apache.maven.api;
requires org.apache.maven.jline;
requires org.slf4j;
+ requires java.logging;
exports org.apache.maven.logging.api;
exports org.apache.maven.slf4j to
diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java
index 20a5e7ab66..8c8b9012fa 100644
--- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java
+++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java
@@ -232,6 +232,23 @@ protected void write(StringBuilder buf, Throwable t) {
}
}
+ /**
+ * Context-aware write that includes the log level, logger name, and
+ * clean message alongside the formatted output. Subclasses can override
+ * to forward structured data to a log sink.
+ * <p>
+ * The default implementation delegates to {@link #write(StringBuilder, Throwable)}.
+ *
+ * @param level the SLF4J log level constant
+ * @param loggerName the name of the logger
+ * @param cleanMessage the formatted message without level/timestamp prefix
+ * @param formattedBuf the fully formatted log line
+ * @param t the throwable, may be {@code null}
+ */
+ protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) {
+ write(formattedBuf, t);
+ }
+
protected void writeThrowable(Throwable t, PrintStream targetStream) {
if (t != null) {
t.printStackTrace(targetStream);
@@ -312,15 +329,34 @@ protected void handleNormalizedLoggingCall(
private void innerHandleNormalizedLoggingCall(
Level level, List<Marker> markers, String messagePattern, Object[] arguments, Throwable t) {
+ String formattedMessage = MessageFormatter.basicArrayFormat(messagePattern, arguments);
+ StringBuilder buf = formatLogPrefix(level.toInt());
+
+ if (markers != null) {
+ buf.append(SP);
+ for (Marker marker : markers) {
+ buf.append(marker.getName()).append(SP);
+ }
+ }
+
+ buf.append(formattedMessage);
+
+ write(level.toInt(), name, formattedMessage, buf, t);
+ }
+
+ /**
+ * Builds the log line prefix: date/time, thread info, level, and
+ * logger name. Used by both the SLF4J call chain and the direct
+ * logging path ({@code logDirect}).
+ */
+ protected StringBuilder formatLogPrefix(int levelInt) {
StringBuilder buf = new StringBuilder(32);
- // Append date-time if so configured
if (CONFIG_PARAMS.showDateTime) {
DateTimeFormatter formatter = CONFIG_PARAMS.dateFormatter;
if (formatter != null) {
ZonedDateTime zonedDateTime = MonotonicClock.now().atZone(ZoneId.systemDefault());
- String dateText = formatter.format(zonedDateTime);
- buf.append(dateText);
+ buf.append(formatter.format(zonedDateTime));
buf.append(SP);
} else {
buf.append(MonotonicClock.elapsed().toMillis());
@@ -328,7 +364,6 @@ private void innerHandleNormalizedLoggingCall(
}
}
- // Append current thread name if so configured
if (CONFIG_PARAMS.showThreadName) {
buf.append('[');
buf.append(Thread.currentThread().getName());
@@ -345,15 +380,12 @@ private void innerHandleNormalizedLoggingCall(
buf.append('[');
}
- // Append a readable representation of the log level
- String levelStr = renderLevel(level.toInt());
- buf.append(levelStr);
+ buf.append(renderLevel(levelInt));
if (CONFIG_PARAMS.levelInBrackets) {
buf.append(']');
}
buf.append(SP);
- // Append the name of the log instance if so configured
if (CONFIG_PARAMS.showShortLogName) {
if (shortLogName == null) {
shortLogName = computeShortName();
@@ -363,19 +395,7 @@ private void innerHandleNormalizedLoggingCall(
buf.append(name).append(" - ");
}
- if (markers != null) {
- buf.append(SP);
- for (Marker marker : markers) {
- buf.append(marker.getName()).append(SP);
- }
- }
-
- String formattedMessage = MessageFormatter.basicArrayFormat(messagePattern, arguments);
-
- // Append the message
- buf.append(formattedMessage);
-
- write(buf, t);
+ return buf;
}
protected String renderLevel(int levelInt) {
diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java
new file mode 100644
index 0000000000..652645d02c
--- /dev/null
+++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java
@@ -0,0 +1,262 @@
+/*
+ * 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.maven.slf4j;
+
+import java.text.MessageFormat;
+import java.util.MissingResourceException;
+import java.util.ResourceBundle;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogManager;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+
+import org.slf4j.LoggerFactory;
+import org.slf4j.spi.LocationAwareLogger;
+
+/**
+ * A JUL {@link Handler} that routes {@code java.util.logging} events into
+ * Maven's structured logging pipeline, preserving the rich {@link LogRecord}
+ * metadata that the standard {@code SLF4JBridgeHandler} silently drops
+ * (source class name, source method name, thread ID).
+ * <p>
+ * When the underlying SLF4J logger is a {@link MavenSimpleLogger} (the
+ * normal case inside Maven), events are delivered via
+ * {@link MavenSimpleLogger#logDirect} — metadata flows as method
+ * parameters instead of through ThreadLocals. For non-Maven SLF4J
+ * backends the handler falls back to standard {@code logger.info()} etc.
+ * <p>
+ * Usage — replace the standard SLF4J bridge in {@code LookupInvoker}:
+ * <pre>
+ * MavenJulHandler.install();
+ * </pre>
+ *
+ * @since 4.1.0
+ * @see #install()
+ */
+public class MavenJulHandler extends Handler {
+
+ /**
+ * Private SLF4J logger cache using {@link ConcurrentMap#putIfAbsent}
+ * instead of {@link ConcurrentMap#computeIfAbsent}. This avoids the
+ * {@code ConcurrentHashMap.computeIfAbsent} reentrancy bug
+ * ({@code IllegalStateException("Recursive update")}) that occurs
+ * when a JUL event fires during SLF4J logger initialization: the
+ * handler's {@code publish()} calls {@code LoggerFactory.getLogger()},
+ * which internally uses {@code computeIfAbsent}, and if that triggers
+ * another JUL event whose logger name hashes to the same bucket,
+ * {@code ConcurrentHashMap} throws. {@code putIfAbsent} is safe
+ * against reentrancy — worst case, two threads create the same
+ * logger and one is discarded.
+ * <p>
+ * The cache is intentionally unbounded: its size is naturally bounded
+ * by the number of distinct JUL logger names observed across all builds
+ * in a JVM lifetime, which is small in practice (a few dozen at most).
+ */
+ private static final ConcurrentMap<String, org.slf4j.Logger> LOGGER_CACHE = new ConcurrentHashMap<>();
+
+ /**
+ * Re-entrancy guard: set to {@code true} while {@link #publish} is routing
+ * a JUL event through SLF4J on this thread. Prevents recursive JUL events
+ * (e.g. JLine's {@code StyleResolver} calling {@code java.util.logging.Logger}
+ * while inside {@link MavenSimpleLogger#renderLevel} lazy-initialisation,
+ * which in turn is triggered by a JUL event during terminal construction)
+ * from re-entering {@code publish} and crashing with
+ * {@code ConcurrentHashMap.computeIfAbsent IllegalStateException("Recursive update")}.
+ */
+ private static final ThreadLocal<Boolean> IN_PUBLISH = new ThreadLocal<>();
+
+ /**
+ * Package-private test hook: sets or clears the re-entrancy flag on
+ * the current thread without reflection.
+ *
+ * @param inPublish {@code true} to simulate being inside {@link #publish},
+ * {@code false} to clear the flag
+ */
+ static void setInPublishForTest(boolean inPublish) {
+ if (inPublish) {
+ IN_PUBLISH.set(Boolean.TRUE);
+ } else {
+ IN_PUBLISH.remove();
+ }
+ }
+
+ /**
+ * Installs this handler on the JUL root logger, removing any
+ * previously installed handlers. This replaces the standard
+ * {@code SLF4JBridgeHandler.install()} call.
+ */
+ public static void install() {
+ Logger rootLogger = LogManager.getLogManager().getLogger("");
+ for (Handler handler : rootLogger.getHandlers()) {
+ rootLogger.removeHandler(handler);
+ }
+ rootLogger.addHandler(new MavenJulHandler());
+ // Note: we intentionally do NOT set rootLogger.setLevel(Level.ALL)
+ // here. Setting it eagerly floods JUL events during SLF4J bootstrap,
+ // triggering ConcurrentHashMap.computeIfAbsent reentrancy in the
+ // SLF4J logger factory ("Recursive update"). The JUL root default
+ // (INFO) is fine — callers that need FINE/FINEST events (e.g. -X
+ // debug mode) should set the JUL root level after SLF4J is fully
+ // initialized.
+ }
+
+ /**
+ * Returns {@code true} if a {@code MavenJulHandler} is installed
+ * on the JUL root logger.
+ */
+ public static boolean isInstalled() {
+ Logger rootLogger = LogManager.getLogManager().getLogger("");
+ for (Handler handler : rootLogger.getHandlers()) {
+ if (handler instanceof MavenJulHandler) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public void publish(LogRecord record) {
+ if (record == null) {
+ return;
+ }
+
+ if (!isLoggable(record)) {
+ return;
+ }
+
+ // Re-entrancy guard: drop recursive JUL events that originate from
+ // within SLF4J/JLine processing triggered by this very publish() call.
+ if (IN_PUBLISH.get() != null) {
+ return;
+ }
+
+ String loggerName = record.getLoggerName();
+ if (loggerName == null) {
+ loggerName = "";
+ }
+
+ org.slf4j.Logger slf4jLogger = LOGGER_CACHE.get(loggerName);
+ if (slf4jLogger == null) {
+ try {
+ slf4jLogger = LoggerFactory.getLogger(loggerName);
+ } catch (IllegalStateException e) {
+ return;
+ }
+ LOGGER_CACHE.putIfAbsent(loggerName, slf4jLogger);
+ }
+ int slf4jLevel = julLevelToSlf4j(record.getLevel());
+
+ if (!isLevelEnabled(slf4jLogger, slf4jLevel)) {
+ return;
+ }
+
+ String message = formatMessage(record);
+ Throwable throwable = record.getThrown();
+
+ IN_PUBLISH.set(Boolean.TRUE);
+ try {
+ if (slf4jLogger instanceof MavenSimpleLogger msl) {
+ MavenSimpleLogger.SourceMetadata metadata = MavenSimpleLogger.isMetadataEnabled()
+ ? new MavenSimpleLogger.SourceMetadata(
+ record.getSourceClassName(), record.getSourceMethodName(), record.getLongThreadID())
+ : null;
+ msl.logDirect(slf4jLevel, message, throwable, metadata);
+ } else {
+ logToSlf4j(slf4jLogger, slf4jLevel, message, throwable);
+ }
+ } finally {
+ IN_PUBLISH.remove();
+ }
+ }
+
+ @Override
+ public void flush() {
+ // nothing to flush
+ }
+
+ @Override
+ public void close() throws SecurityException {
+ // nothing to close
+ }
+
+ private static String formatMessage(LogRecord record) {
+ String message = record.getMessage();
+ if (message == null) {
+ return "";
+ }
+
+ ResourceBundle bundle = record.getResourceBundle();
+ if (bundle != null) {
+ try {
+ message = bundle.getString(message);
+ } catch (MissingResourceException e) {
+ // use raw message
+ }
+ }
+
+ Object[] params = record.getParameters();
+ if (params != null && params.length > 0) {
+ try {
+ message = MessageFormat.format(message, params);
+ } catch (IllegalArgumentException e) {
+ // use message as-is if formatting fails
+ }
+ }
+
+ return message;
+ }
+
+ private static int julLevelToSlf4j(Level julLevel) {
+ int value = julLevel.intValue();
+ if (value <= Level.FINEST.intValue()) {
+ return LocationAwareLogger.TRACE_INT;
+ } else if (value <= Level.FINE.intValue()) {
+ return LocationAwareLogger.DEBUG_INT;
+ } else if (value <= Level.INFO.intValue()) {
+ return LocationAwareLogger.INFO_INT;
+ } else if (value <= Level.WARNING.intValue()) {
+ return LocationAwareLogger.WARN_INT;
+ } else {
+ return LocationAwareLogger.ERROR_INT;
+ }
+ }
+
+ private static boolean isLevelEnabled(org.slf4j.Logger logger, int level) {
+ return switch (level) {
+ case LocationAwareLogger.TRACE_INT -> logger.isTraceEnabled();
+ case LocationAwareLogger.DEBUG_INT -> logger.isDebugEnabled();
+ case LocationAwareLogger.INFO_INT -> logger.isInfoEnabled();
+ case LocationAwareLogger.WARN_INT -> logger.isWarnEnabled();
+ default -> logger.isErrorEnabled();
+ };
+ }
+
+ private static void logToSlf4j(org.slf4j.Logger logger, int level, String message, Throwable throwable) {
+ switch (level) {
+ case LocationAwareLogger.TRACE_INT -> logger.trace(message, throwable);
+ case LocationAwareLogger.DEBUG_INT -> logger.debug(message, throwable);
+ case LocationAwareLogger.INFO_INT -> logger.info(message, throwable);
+ case LocationAwareLogger.WARN_INT -> logger.warn(message, throwable);
+ default -> logger.error(message, throwable);
+ }
+ }
+}
diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java
index 02767987e2..fb61ad200e 100644
--- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java
+++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java
@@ -39,14 +39,90 @@ public class MavenSimpleLogger extends MavenBaseLogger {
private String warnRenderedLevel;
private String errorRenderedLevel;
- static Consumer<String> logSink;
+ /**
+ * Source metadata captured from Log API or JUL events and passed
+ * inline to the {@link LogSink}. {@code null} for standard SLF4J events.
+ *
+ * @param sourceClassName source class name, or {@code null}
+ * @param sourceMethodName source method name, or {@code null}
+ * @param threadId originating thread ID
+ * @since 4.1.0
+ */
+ public record SourceMetadata(String sourceClassName, String sourceMethodName, long threadId) {}
+
+ /**
+ * Structured log sink that receives log events with optional formatting
+ * and source metadata.
+ * <p>
+ * Implementations declare their needs via {@link #needsFormattedMessage()}
+ * and {@link #needsMetadata()} so the upstream pipeline can skip work
+ * the downstream consumer does not use.
+ *
+ * @since 4.1.0
+ */
+ public interface LogSink {
+ void accept(
+ int level,
+ String loggerName,
+ String cleanMessage,
+ String formattedMessage,
+ Throwable throwable,
+ SourceMetadata metadata);
+
+ /**
+ * Whether this sink uses the {@code formattedMessage} parameter.
+ * When {@code false}, the logging pipeline skips message formatting
+ * (timestamp, level prefix, logger name, ANSI styling).
+ */
+ default boolean needsFormattedMessage() {
+ return true;
+ }
+
+ /**
+ * Whether this sink uses source metadata ({@code sourceClassName},
+ * {@code sourceMethodName}, {@code threadId}). When {@code false},
+ * callers skip the {@link StackWalker} cost and pass {@code null}.
+ */
+ default boolean needsMetadata() {
+ return true;
+ }
+ }
+
+ static volatile LogSink logSink;
public static final String DEFAULT_LOG_LEVEL_KEY = "org.slf4j.simpleLogger.defaultLogLevel";
- public static void setLogSink(Consumer<String> logSink) {
+ /**
+ * Sets the structured log sink.
+ *
+ * @param logSink the sink, or {@code null} to remove
+ * @since 4.1.0
+ */
+ public static void setLogSink(LogSink logSink) {
MavenSimpleLogger.logSink = logSink;
}
+ /**
+ * Returns the current log sink, or {@code null} if none is set.
+ *
+ * @return the current log sink, or {@code null}
+ * @since 4.1.0
+ */
+ public static LogSink getLogSink() {
+ return logSink;
+ }
+
+ /**
+ * Whether the current sink needs source metadata. Used by
+ * {@code DefaultLog} to skip the {@link StackWalker} cost and
+ * by {@code MavenJulHandler} to skip {@link SourceMetadata}
+ * allocation when no consumer reads those fields.
+ */
+ public static boolean isMetadataEnabled() {
+ LogSink sink = logSink;
+ return sink != null && sink.needsMetadata();
+ }
+
MavenSimpleLogger(String name) {
super(name);
}
@@ -69,17 +145,58 @@ protected String renderLevel(int level) {
};
}
- @Override
- protected void write(StringBuilder buf, Throwable t) {
- Consumer<String> sink = logSink;
+ /**
+ * Direct logging entry point that bypasses the SLF4J call chain.
+ * <p>
+ * Callers that already hold source metadata (Log API, JUL handler)
+ * can format + deliver in one step, eliminating the ThreadLocal
+ * round-trip that the standard SLF4J path requires.
+ *
+ * @param level SLF4J level constant
+ * @param message the log message (already formatted, no SLF4J placeholders)
+ * @param thrown the throwable, may be {@code null}
+ * @param metadata source metadata, or {@code null}
+ */
+ public void logDirect(int level, String message, Throwable thrown, SourceMetadata metadata) {
+ if (!isLevelEnabled(level)) {
+ return;
+ }
+ LogSink sink = logSink;
if (sink != null) {
- sink.accept(buf.toString());
- if (t != null) {
- writeThrowable(t, sink);
+ String formatted = null;
+ if (sink.needsFormattedMessage()) {
+ StringBuilder buf = formatLogPrefix(level);
+ buf.append(message);
+ formatted = renderFormattedMessage(buf, thrown);
}
+ sink.accept(level, name, message, formatted, thrown, metadata);
} else {
- super.write(buf, t);
+ StringBuilder buf = formatLogPrefix(level);
+ buf.append(message);
+ super.write(buf, thrown);
+ }
+ }
+
+ @Override
+ protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) {
+ LogSink sink = logSink;
+ if (sink != null) {
+ String formatted = sink.needsFormattedMessage() ? renderFormattedMessage(formattedBuf, t) : null;
+ sink.accept(level, loggerName, cleanMessage, formatted, t, null);
+ } else {
+ super.write(formattedBuf, t);
+ }
+ }
+
+ private String renderFormattedMessage(StringBuilder buf, Throwable t) {
+ String formatted = buf.toString();
+ if (t != null) {
+ StringBuilder full = new StringBuilder(formatted);
+ full.append(System.lineSeparator());
+ writeThrowable(t, line -> full.append(line).append(System.lineSeparator()));
+ formatted = full.toString();
}
+ return formatted;
}
@Override
diff --git a/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java
new file mode 100644
index 0000000000..f1b6533020
--- /dev/null
+++ b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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.maven.slf4j;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.slf4j.spi.LocationAwareLogger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests for {@link MavenJulHandler}, focused on the JUL→SLF4J level
+ * mapping and reentrancy safety.
+ */
+class MavenJulHandlerTest {
+
+ /**
+ * Table test for JUL level → SLF4J level mapping.
+ * Verifies the mapping documented in the class Javadoc.
+ */
+ @ParameterizedTest(name = "JUL {0} -> SLF4J level {1}")
+ @CsvSource({
+ "FINEST, 0", // TRACE_INT = 0
+ "FINER, 10", // DEBUG_INT = 10
+ "FINE, 10", // DEBUG_INT = 10
+ "CONFIG, 20", // INFO_INT = 20
+ "INFO, 20", // INFO_INT = 20
+ "WARNING, 30", // WARN_INT = 30
+ "SEVERE, 40", // ERROR_INT = 40
+ })
+ void julLevelMapsToCorrectSlf4jLevel(String julLevelName, int expectedSlf4jLevel) throws Exception {
+ Level julLevel = Level.parse(julLevelName);
+ int actual = invokeJulLevelToSlf4j(julLevel);
+ assertEquals(
+ expectedSlf4jLevel, actual, "JUL " + julLevelName + " should map to SLF4J level " + expectedSlf4jLevel);
+ }
+
+ /**
+ * Verify that FINEST maps to TRACE (not DEBUG) — this is the key
+ * distinction for the TRACE/DEBUG separation.
+ */
+ @Test
+ void finestMapsToTrace() throws Exception {
+ assertEquals(
+ LocationAwareLogger.TRACE_INT,
+ invokeJulLevelToSlf4j(Level.FINEST),
+ "FINEST should map to TRACE, not DEBUG");
+ }
+
+ /**
+ * Verify that CONFIG maps to INFO (not DEBUG) — CONFIG is JUL's
+ * informational level for static configuration, not a debug level.
+ */
+ @Test
+ void configMapsToInfo() throws Exception {
+ assertEquals(LocationAwareLogger.INFO_INT, invokeJulLevelToSlf4j(Level.CONFIG), "CONFIG should map to INFO");
+ }
+
+ /**
+ * Verify that a recursive call to {@link MavenJulHandler#publish} from
+ * within a {@code publish()} call on the same thread is silently dropped
+ * instead of crashing with {@code IllegalStateException("Recursive update")}.
+ * <p>
+ * This is the reentrancy scenario that occurs when JLine's
+ * {@code StyleResolver} logs a JUL DEBUG event while
+ * {@code MavenSimpleLogger.renderLevel()} is lazily initialising ANSI
+ * colour strings during terminal construction.
+ */
+ @Test
+ void publishIsReentrantSafe() {
+ MavenJulHandler handler = new MavenJulHandler();
+ LogRecord record = new LogRecord(Level.INFO, "nested");
+
+ // Simulate re-entrance: use the package-private test hook instead of
+ // reflection so the test does not break if IN_PUBLISH is renamed.
+ MavenJulHandler.setInPublishForTest(true);
+ try {
+ // A nested call must be silently dropped without throwing.
+ handler.publish(record);
+ } finally {
+ MavenJulHandler.setInPublishForTest(false);
+ }
+ }
+
+ /**
+ * Invoke the private julLevelToSlf4j method via reflection for testing.
+ */
+ private static int invokeJulLevelToSlf4j(Level julLevel) throws Exception {
+ try {
+ Method method = MavenJulHandler.class.getDeclaredMethod("julLevelToSlf4j", Level.class);
+ method.setAccessible(true);
+ return (int) method.invoke(null, julLevel);
+ } catch (InvocationTargetException e) {
+ throw (Exception) e.getCause();
+ }
+ }
+}
diff --git a/pom.xml b/pom.xml
index df43a4105e..e445900dd7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -508,17 +508,6 @@ under the License.
<version>${slf4jVersion}</version>
<optional>true</optional>
</dependency>
- <dependency>
- <groupId>org.slf4j</groupId>
- <artifactId>jul-to-slf4j</artifactId>
- <version>${slf4jVersion}</version>
- </dependency>
- <dependency>
- <groupId>ch.qos.logback</groupId>
- <artifactId>logback-classic</artifactId>
- <version>${logbackClassicVersion}</version>
- <optional>true</optional>
- </dependency>
<!-- Wagon -->
<dependency>
<groupId>org.apache.maven.wagon</groupId>