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/08/28 10:21:56 UTC
(maven) 01/07: 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/mvnlog-web
in repository https://gitbox.apache.org/repos/asf/maven.git
commit 02ac85574a0e7f83696f2ab02dcb9de5ea931c84
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Sun Aug 9 10:10:32 2026 +0200
Logging foundation: structured LogEvent, JUL handler, Log API enhancements
- Log.trace() — 5 overloads + isTraceEnabled() for core-internal tracing
- Log.child(name) — hierarchical sub-loggers for plugins
- LogEvent / LogLevel API for structured log event representation
- MavenJulHandler replaces SLF4JBridgeHandler, preserving full LogRecord metadata
- MavenSimpleLogger.LogSink structured callback replaces Consumer<String>
- ProjectBuildLogAppender produces LogEvent objects instead of raw strings
- maven.mojo.id MDC key set during mojo execution
- Logger name changed to FQCN for proper hierarchical SLF4J level configuration
- Fix: DefaultLog.warn(Supplier, Throwable) was calling logger.info()
---
apache-maven/pom.xml | 7 -
.../apache/maven/api/build/report/LogEvent.java | 167 ++++++++++++++
.../apache/maven/api/build/report/LogLevel.java | 32 +--
.../maven/api/build/report/package-info.java | 31 +--
.../main/java/org/apache/maven/api/plugin/Log.java | 66 ++++++
compat/maven-embedder/pom.xml | 5 -
impl/maven-cli/pom.xml | 9 -
.../apache/maven/cling/invoker/LookupInvoker.java | 7 +-
.../cling/logging/impl/LogbackConfiguration.java | 46 ----
.../META-INF/maven/slf4j-configuration.properties | 2 -
.../maven/internal/build/DefaultLogEvent.java | 77 +++++++
.../org/apache/maven/internal/impl/DefaultLog.java | 148 ++++++++++--
.../apache/maven/logging/BuildEventListener.java | 3 +-
.../maven/logging/LoggingExecutionListener.java | 11 +
.../maven/logging/ProjectBuildLogAppender.java | 137 +++++++++++-
.../maven/logging/SimpleBuildEventListener.java | 6 +-
.../maven/plugin/DefaultBuildPluginManager.java | 2 +-
.../plugin/internal/DefaultMavenPluginManager.java | 2 +-
.../org/apache/maven/slf4j/MavenBaseLogger.java | 19 +-
.../org/apache/maven/slf4j/MavenJulHandler.java | 249 +++++++++++++++++++++
.../org/apache/maven/slf4j/MavenSimpleLogger.java | 104 ++++++++-
pom.xml | 12 -
22 files changed, 980 insertions(+), 162 deletions(-)
diff --git a/apache-maven/pom.xml b/apache-maven/pom.xml
index 13d809a7b4..55fb4e61bb 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/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..e8575ded3d
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.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.maven.api.build.report;
+
+import java.time.Instant;
+
+import org.apache.maven.api.annotations.Experimental;
+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
+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 formatted 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}
+ * @since 4.1.0
+ */
+ @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}
+ * @since 4.1.0
+ */
+ @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
+ * @since 4.1.0
+ */
+ 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, always non-negative
+ * @since 4.1.0
+ */
+ default long sequenceNumber() {
+ return -1;
+ }
+}
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%
copy from impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java
copy to api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java
index bbd487fa5f..684ea610a5 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,21 @@
* 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;
/**
- * 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
+public enum LogLevel {
+ TRACE,
+ DEBUG,
+ INFO,
+ WARN,
+ ERROR
}
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/package-info.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/package-info.java
index bbd487fa5f..8aee68ff90 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/package-info.java
@@ -16,29 +16,18 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.maven.cling.logging.impl;
-
-import org.apache.maven.cling.logging.BaseSlf4jConfiguration;
/**
- * Configuration for slf4j-log4j2.
+ * 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 3.1.0
+ * @since 4.1.0
*/
-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);
- }
+@Experimental
+package org.apache.maven.api.build.report;
- @Override
- public void activate() {
- // no op
- }
-}
+import org.apache.maven.api.annotations.Experimental;
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
index 50627efd86..9c020506f1 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
@@ -36,6 +36,51 @@
@Experimental
@Provider
public interface Log {
+ /**
+ * {@return true if the <b>trace</b> error level is enabled}
+ * @since 4.1.0
+ */
+ boolean isTraceEnabled();
+
+ /**
+ * Sends a message to the user in the <b>trace</b> error level.
+ * <p>
+ * Trace is intended for Maven core internals and low-level framework
+ * diagnostics. Plugin authors should normally use {@link #debug} for
+ * developer-facing diagnostic output.
+ *
+ * @param content the message to log
+ * @since 4.1.0
+ */
+ void trace(CharSequence content);
+
+ /**
+ * Sends a message (and accompanying exception) to the user at the <b>trace</b> error level.
+ *
+ * @param content the message to log
+ * @param error the error that caused this log
+ * @since 4.1.0
+ */
+ void trace(CharSequence content, Throwable error);
+
+ /**
+ * Sends an exception to the user in the <b>trace</b> error level.
+ *
+ * @param error the error that caused this log
+ * @since 4.1.0
+ */
+ void trace(Throwable error);
+
+ /**
+ * @since 4.1.0
+ */
+ void trace(Supplier<String> content);
+
+ /**
+ * @since 4.1.0
+ */
+ void trace(Supplier<String> content, Throwable error);
+
/**
* {@return true if the <b>debug</b> error level is enabled}
*/
@@ -43,6 +88,10 @@ public interface Log {
/**
* Sends a message to the user in the <b>debug</b> error level.
+ * <p>
+ * Debug is the recommended level for diagnostic output that helps
+ * plugin users troubleshoot build problems (e.g. resolved paths,
+ * computed values). For Maven core internals, prefer {@link #trace}.
*
* @param content the message to log
*/
@@ -167,4 +216,21 @@ public interface Log {
void error(Supplier<String> content);
void error(Supplier<String> content, Throwable error);
+
+ /**
+ * Returns a child logger whose name is derived from this logger's name
+ * by appending {@code "." + name}. This allows plugins to create
+ * sub-loggers for different concerns while keeping hierarchical level
+ * control (e.g. setting the level for the parent silences the children).
+ * <p>
+ * The default implementation returns {@code this} so that existing
+ * implementations continue to work without changes.
+ *
+ * @param name the child logger name segment (must not be {@code null})
+ * @return a child {@code Log}, never {@code null}
+ * @since 4.1.0
+ */
+ default Log child(String name) {
+ return this;
+ }
}
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 5d7304af5e..56ac62ed6f 100644
--- a/impl/maven-cli/pom.xml
+++ b/impl/maven-cli/pom.xml
@@ -195,19 +195,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 40d3bb4926..44c7f89a25 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
@@ -83,6 +83,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;
@@ -91,7 +92,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;
@@ -436,9 +436,8 @@ 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();
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..95951d8e7a
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java
@@ -0,0 +1,77 @@
+/*
+ * 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 JUL sequence number for ordering, or {@code -1} if unavailable
+ */
+public record DefaultLogEvent(
+ Instant timestamp,
+ LogLevel level,
+ String message,
+ String loggerName,
+ String stackTrace,
+ String formattedMessage,
+ String sourceClassName,
+ String sourceMethodName,
+ long threadId,
+ long sequenceNumber)
+ 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);
+ }
+
+ /**
+ * 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);
+ }
+}
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 1a11fe46fd..581227c76d 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,149 +18,251 @@
*/
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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import static java.util.Objects.requireNonNull;
public class DefaultLog implements Log {
+
+ /**
+ * Metadata captured from Log API calls, mirroring the JUL metadata
+ * pattern in {@code MavenJulHandler}.
+ *
+ * @param sourceClassName the fully qualified class name of the caller
+ * @param sourceMethodName the method that issued the log call
+ * @param threadId the originating thread ID
+ */
+ public record LogApiMetadata(String sourceClassName, String sourceMethodName, long threadId) {}
+
+ private static final ThreadLocal<LogApiMetadata> LOG_API_METADATA = new ThreadLocal<>();
+ private static final StackWalker WALKER = StackWalker.getInstance();
+ private static final String THIS_CLASS = DefaultLog.class.getName();
+
+ /**
+ * Returns the Log API metadata for the current log event being processed,
+ * or {@code null} if the current event did not originate from the Log API.
+ * <p>
+ * Called from {@code ProjectBuildLogAppender.accept()} to populate
+ * {@code LogEvent.sourceClassName()} and {@code LogEvent.sourceMethodName()}.
+ *
+ * @return the current Log API metadata, or {@code null}
+ */
+ public static LogApiMetadata getLogApiMetadata() {
+ return LOG_API_METADATA.get();
+ }
+
private final Logger logger;
public DefaultLog(Logger logger) {
this.logger = requireNonNull(logger);
}
+ /**
+ * Wraps a logging call with Log API metadata: captures the caller's
+ * method name via {@link StackWalker}, sets the ThreadLocal, executes
+ * the actual SLF4J call, and clears the ThreadLocal.
+ * <p>
+ * The source class name is taken from the SLF4J logger name (which
+ * is the mojo implementation FQCN, set at injection time). The
+ * source method name is resolved by walking the stack past this class
+ * to find the first external caller frame.
+ */
+ private void withMetadata(Runnable logAction) {
+ String callerMethodName = WALKER.walk(frames -> frames.dropWhile(f -> THIS_CLASS.equals(f.getClassName()))
+ .findFirst()
+ .map(StackFrame::getMethodName)
+ .orElse(null));
+ LOG_API_METADATA.set(new LogApiMetadata(
+ logger.getName(), callerMethodName, Thread.currentThread().getId()));
+ try {
+ logAction.run();
+ } finally {
+ LOG_API_METADATA.remove();
+ }
+ }
+
+ @Override
+ public boolean isTraceEnabled() {
+ return logger.isTraceEnabled();
+ }
+
+ @Override
+ public void trace(CharSequence content) {
+ if (isTraceEnabled()) {
+ withMetadata(() -> logger.trace(toString(content)));
+ }
+ }
+
+ @Override
+ public void trace(CharSequence content, Throwable error) {
+ if (isTraceEnabled()) {
+ withMetadata(() -> logger.trace(toString(content), error));
+ }
+ }
+
+ @Override
+ public void trace(Throwable error) {
+ if (isTraceEnabled()) {
+ withMetadata(() -> logger.trace("", error));
+ }
+ }
+
+ @Override
+ public void trace(Supplier<String> content) {
+ if (isTraceEnabled()) {
+ withMetadata(() -> logger.trace(content.get()));
+ }
+ }
+
+ @Override
+ public void trace(Supplier<String> content, Throwable error) {
+ if (isTraceEnabled()) {
+ withMetadata(() -> logger.trace(content.get(), error));
+ }
+ }
+
@Override
public void debug(CharSequence content) {
if (isDebugEnabled()) {
- logger.debug(toString(content));
+ withMetadata(() -> logger.debug(toString(content)));
}
}
@Override
public void debug(CharSequence content, Throwable error) {
if (isDebugEnabled()) {
- logger.debug(toString(content), error);
+ withMetadata(() -> logger.debug(toString(content), error));
}
}
@Override
public void debug(Throwable error) {
- logger.debug("", error);
+ if (isDebugEnabled()) {
+ withMetadata(() -> logger.debug("", error));
+ }
}
@Override
public void debug(Supplier<String> content) {
if (isDebugEnabled()) {
- logger.debug(content.get());
+ withMetadata(() -> logger.debug(content.get()));
}
}
@Override
public void debug(Supplier<String> content, Throwable error) {
if (isDebugEnabled()) {
- logger.debug(content.get(), error);
+ withMetadata(() -> logger.debug(content.get(), error));
}
}
@Override
public void info(CharSequence content) {
if (isInfoEnabled()) {
- logger.info(toString(content));
+ withMetadata(() -> logger.info(toString(content)));
}
}
@Override
public void info(CharSequence content, Throwable error) {
if (isInfoEnabled()) {
- logger.info(toString(content), error);
+ withMetadata(() -> logger.info(toString(content), error));
}
}
@Override
public void info(Throwable error) {
- logger.info("", error);
+ if (isInfoEnabled()) {
+ withMetadata(() -> logger.info("", error));
+ }
}
@Override
public void info(Supplier<String> content) {
if (isInfoEnabled()) {
- logger.info(content.get());
+ withMetadata(() -> logger.info(content.get()));
}
}
@Override
public void info(Supplier<String> content, Throwable error) {
if (isInfoEnabled()) {
- logger.info(content.get(), error);
+ withMetadata(() -> logger.info(content.get(), error));
}
}
@Override
public void warn(CharSequence content) {
if (isWarnEnabled()) {
- logger.warn(toString(content));
+ withMetadata(() -> logger.warn(toString(content)));
}
}
@Override
public void warn(CharSequence content, Throwable error) {
if (isWarnEnabled()) {
- logger.warn(toString(content), error);
+ withMetadata(() -> logger.warn(toString(content), error));
}
}
@Override
public void warn(Throwable error) {
- logger.warn("", error);
+ if (isWarnEnabled()) {
+ withMetadata(() -> logger.warn("", error));
+ }
}
@Override
public void warn(Supplier<String> content) {
if (isWarnEnabled()) {
- logger.warn(content.get());
+ withMetadata(() -> logger.warn(content.get()));
}
}
@Override
public void warn(Supplier<String> content, Throwable error) {
if (isWarnEnabled()) {
- logger.info(content.get(), error);
+ withMetadata(() -> logger.warn(content.get(), error));
}
}
@Override
public void error(CharSequence content) {
if (isErrorEnabled()) {
- logger.error(toString(content));
+ withMetadata(() -> logger.error(toString(content)));
}
}
@Override
public void error(CharSequence content, Throwable error) {
if (isErrorEnabled()) {
- logger.error(toString(content), error);
+ withMetadata(() -> logger.error(toString(content), error));
}
}
@Override
public void error(Throwable error) {
- logger.error("", error);
+ if (isErrorEnabled()) {
+ withMetadata(() -> logger.error("", error));
+ }
}
@Override
public void error(Supplier<String> content) {
if (isErrorEnabled()) {
- logger.error(content.get());
+ withMetadata(() -> logger.error(content.get()));
}
}
@Override
public void error(Supplier<String> content, Throwable error) {
if (isErrorEnabled()) {
- logger.error(content.get(), error);
+ withMetadata(() -> logger.error(content.get(), error));
}
}
@@ -184,6 +286,12 @@ public boolean isErrorEnabled() {
return logger.isErrorEnabled();
}
+ @Override
+ public Log child(String name) {
+ requireNonNull(name, "name");
+ return new DefaultLog(LoggerFactory.getLogger(logger.getName() + "." + name));
+ }
+
private String toString(CharSequence content) {
return content != null ? content.toString() : "";
}
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/LoggingExecutionListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java
index 040a481455..6685351b36 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java
@@ -121,6 +121,7 @@ public void projectSkipped(ExecutionEvent event) {
@Override
public void mojoStarted(ExecutionEvent event) {
setMdc(event);
+ setMojoMdc(event);
buildEventListener.mojoStarted(event);
delegate.mojoStarted(event);
}
@@ -128,12 +129,14 @@ public void mojoStarted(ExecutionEvent event) {
@Override
public void mojoSucceeded(ExecutionEvent event) {
setMdc(event);
+ ProjectBuildLogAppender.setMojoId(null);
delegate.mojoSucceeded(event);
}
@Override
public void mojoFailed(ExecutionEvent event) {
setMdc(event);
+ ProjectBuildLogAppender.setMojoId(null);
delegate.mojoFailed(event);
}
@@ -187,4 +190,12 @@ private void setMdc(ExecutionEvent event) {
ProjectBuildLogAppender.setProjectId(event.getProject().getArtifactId());
}
}
+
+ private void setMojoMdc(ExecutionEvent event) {
+ if (event.getMojoExecution() != null) {
+ String mojoId = event.getMojoExecution().getMojoDescriptor().getFullGoalName() + "@"
+ + event.getMojoExecution().getExecutionId();
+ ProjectBuildLogAppender.setMojoId(mojoId);
+ }
+ }
}
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 8465df0cf0..03485b8759 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,16 +18,36 @@
*/
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.internal.impl.DefaultLog;
+import org.apache.maven.slf4j.MavenJulHandler;
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 {
private static final String KEY_PROJECT_ID = "maven.project.id";
+ private static final String KEY_MOJO_ID = "maven.mojo.id";
private static final ThreadLocal<String> PROJECT_ID = new InheritableThreadLocal<>();
+ private static final ThreadLocal<String> MOJO_ID = new InheritableThreadLocal<>();
private static final ThreadLocal<String> FORKING_PROJECT_ID = new InheritableThreadLocal<>();
public static String getProjectId() {
@@ -52,6 +72,31 @@ public static void setProjectId(String projectId) {
}
}
+ public static String getMojoId() {
+ return MOJO_ID.get();
+ }
+
+ /**
+ * Sets or clears the mojo execution identifier in both the thread-local
+ * and the SLF4J MDC. The value is available to any SLF4J appender via
+ * the MDC key {@code maven.mojo.id} and to JUL-bridged messages through
+ * the same MDC path.
+ * <p>
+ * Format: {@code "prefix:goal@executionId"}
+ * (e.g. {@code "compiler:compile@default-compile"}).
+ *
+ * @param mojoId the mojo identifier, or {@code null} to clear
+ */
+ public static void setMojoId(String mojoId) {
+ if (mojoId != null) {
+ MOJO_ID.set(mojoId);
+ MDC.put(KEY_MOJO_ID, mojoId);
+ } else {
+ MOJO_ID.remove();
+ MDC.remove(KEY_MOJO_ID);
+ }
+ }
+
public static void setForkingProjectId(String forkingProjectId) {
if (forkingProjectId != null) {
FORKING_PROJECT_ID.set(forkingProjectId);
@@ -69,6 +114,32 @@ 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.
+ *
+ * @param capture the callback, or {@code null} to remove
+ */
+ public static void setReportCapture(Consumer<LogEvent> capture) {
+ ProjectBuildLogAppender.reportCapture = capture;
+ }
+
private final BuildEventListener buildEventListener;
public ProjectBuildLogAppender(BuildEventListener buildEventListener) {
@@ -76,13 +147,73 @@ public ProjectBuildLogAppender(BuildEventListener buildEventListener) {
MavenSimpleLogger.setLogSink(this::accept);
}
- protected void accept(String message) {
+ protected void accept(
+ int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable) {
String projectId = MDC.get(KEY_PROJECT_ID);
- buildEventListener.projectLogMessage(projectId, message);
+ Instant timestamp = MonotonicClock.now();
+ LogLevel logLevel = toLogLevel(level);
+ String stackTrace = throwable != null ? formatStackTrace(throwable) : null;
+
+ long seq = SEQUENCE.getAndIncrement();
+
+ // Read source metadata: JUL events carry it via MavenJulHandler,
+ // Log API events carry it via DefaultLog's ThreadLocal.
+ MavenJulHandler.JulMetadata julMeta = MavenJulHandler.getJulMetadata();
+ DefaultLog.LogApiMetadata logApiMeta = DefaultLog.getLogApiMetadata();
+ String sourceClassName;
+ String sourceMethodName;
+ long threadId;
+ if (julMeta != null) {
+ sourceClassName = julMeta.sourceClassName();
+ sourceMethodName = julMeta.sourceMethodName();
+ threadId = julMeta.threadId();
+ } else if (logApiMeta != null) {
+ sourceClassName = logApiMeta.sourceClassName();
+ sourceMethodName = logApiMeta.sourceMethodName();
+ threadId = logApiMeta.threadId();
+ } else {
+ sourceClassName = null;
+ sourceMethodName = null;
+ threadId = -1;
+ }
+ LogEvent event = new DefaultLogEvent(
+ timestamp,
+ logLevel,
+ cleanMessage,
+ loggerName,
+ stackTrace,
+ formattedMessage,
+ sourceClassName,
+ sourceMethodName,
+ threadId,
+ seq);
+ buildEventListener.projectLogMessage(projectId, event);
+
+ // Forward to build report collector (if active)
+ 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/main/java/org/apache/maven/plugin/DefaultBuildPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultBuildPluginManager.java
index e395d1ed00..476f2aadcd 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultBuildPluginManager.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultBuildPluginManager.java
@@ -125,7 +125,7 @@ public void executeMojo(MavenSession session, MojoExecution mojoExecution)
scope.seed(
org.apache.maven.api.plugin.Log.class,
new DefaultLog(LoggerFactory.getLogger(
- mojoExecution.getMojoDescriptor().getFullGoalName())));
+ mojoExecution.getMojoDescriptor().getImplementation())));
InternalMavenSession sessionV4 = InternalMavenSession.from(session.getSession());
scope.seed(Project.class, sessionV4.getProject(project));
scope.seed(org.apache.maven.api.MojoExecution.class, new DefaultMojoExecution(sessionV4, mojoExecution));
diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java
index 15f3d8df7b..62a39f72fb 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java
@@ -555,7 +555,7 @@ private <T> T loadV4Mojo(
org.apache.maven.api.MojoExecution execution = new DefaultMojoExecution(sessionV4, mojoExecution);
org.apache.maven.api.plugin.Log log = new DefaultLog(
- LoggerFactory.getLogger(mojoExecution.getMojoDescriptor().getFullGoalName()));
+ LoggerFactory.getLogger(mojoExecution.getMojoDescriptor().getImplementation()));
try {
Injector injector = Injector.create();
injector.discover(pluginRealm);
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..6faeba5fea 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);
@@ -375,7 +392,7 @@ private void innerHandleNormalizedLoggingCall(
// Append the message
buf.append(formattedMessage);
- write(buf, t);
+ write(level.toInt(), name, formattedMessage, buf, t);
}
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..9be299faf0
--- /dev/null
+++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java
@@ -0,0 +1,249 @@
+/*
+ * 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.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogManager;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+
+import org.apache.maven.api.services.MessageBuilder;
+import org.slf4j.LoggerFactory;
+import org.slf4j.spi.LocationAwareLogger;
+
+import static org.apache.maven.jline.MessageUtils.builder;
+
+/**
+ * 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 a {@link MavenSimpleLogger.LogSink LogSink} is installed (i.e. during
+ * a build), JUL events are sent directly to the sink — bypassing SLF4J
+ * entirely. The JUL metadata is stashed in a thread-local so downstream
+ * consumers (e.g. {@code ProjectBuildLogAppender}) can read it when
+ * constructing a structured {@code LogEvent}.
+ * <p>
+ * When no LogSink is installed (e.g. during early bootstrap), the handler
+ * falls back to routing through SLF4J for console output.
+ * <p>
+ * Usage — replace the standard SLF4J bridge in {@code LookupInvoker}:
+ * <pre>
+ * MavenJulHandler.install();
+ * </pre>
+ *
+ * @since 4.1.0
+ * @see #install()
+ * @see #getJulMetadata()
+ */
+public class MavenJulHandler extends Handler {
+
+ /**
+ * JUL metadata captured from a {@link LogRecord} that would otherwise
+ * be lost when bridging to SLF4J.
+ *
+ * @param sourceClassName the source class, or {@code null}
+ * @param sourceMethodName the source method, or {@code null}
+ * @param threadId the originating thread ID
+ */
+ public record JulMetadata(String sourceClassName, String sourceMethodName, long threadId) {}
+
+ private static final ThreadLocal<JulMetadata> METADATA = new ThreadLocal<>();
+
+ /**
+ * Returns the JUL metadata for the current log event being processed,
+ * or {@code null} if the current log event did not originate from JUL.
+ * <p>
+ * This method is intended to be called from within a
+ * {@link MavenSimpleLogger.LogSink} callback (e.g. in
+ * {@code ProjectBuildLogAppender.accept()}).
+ *
+ * @return the current JUL metadata, or {@code null}
+ */
+ public static JulMetadata getJulMetadata() {
+ return METADATA.get();
+ }
+
+ /**
+ * 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("");
+ // Remove all existing handlers (including any SLF4JBridgeHandler)
+ for (Handler handler : rootLogger.getHandlers()) {
+ rootLogger.removeHandler(handler);
+ }
+ rootLogger.addHandler(new MavenJulHandler());
+ // Accept all levels — filtering is done by SLF4J
+ rootLogger.setLevel(Level.ALL);
+ }
+
+ /**
+ * 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;
+ }
+
+ String loggerName = record.getLoggerName();
+ org.slf4j.Logger slf4jLogger = LoggerFactory.getLogger(loggerName);
+ int slf4jLevel = julLevelToSlf4j(record.getLevel());
+
+ // Quick exit if this level is not enabled
+ if (!isLevelEnabled(slf4jLogger, slf4jLevel)) {
+ return;
+ }
+
+ String message = formatMessage(record);
+ Throwable throwable = record.getThrown();
+
+ // If a LogSink is installed, bypass SLF4J entirely: call the sink
+ // directly with the JUL metadata so no information is lost in transit.
+ MavenSimpleLogger.LogSink sink = MavenSimpleLogger.getLogSink();
+ if (sink != null) {
+ METADATA.set(new JulMetadata(
+ record.getSourceClassName(), record.getSourceMethodName(), record.getLongThreadID()));
+ try {
+ String formatted = formatForConsole(slf4jLevel, message);
+ sink.accept(slf4jLevel, loggerName, message, formatted, throwable);
+ } finally {
+ METADATA.remove();
+ }
+ } else {
+ // No LogSink — fall through to SLF4J for console output
+ logToSlf4j(slf4jLogger, slf4jLevel, message, throwable);
+ }
+ }
+
+ @Override
+ public void flush() {
+ // nothing to flush
+ }
+
+ @Override
+ public void close() throws SecurityException {
+ // nothing to close
+ }
+
+ /**
+ * Formats the log message, applying i18n resource bundle lookup and
+ * {@link MessageFormat} parameter substitution, matching the behavior
+ * of {@code SLF4JBridgeHandler}.
+ */
+ private static String formatMessage(LogRecord record) {
+ String message = record.getMessage();
+ if (message == null) {
+ return "";
+ }
+
+ // Try resource bundle lookup
+ ResourceBundle bundle = record.getResourceBundle();
+ if (bundle != null) {
+ try {
+ message = bundle.getString(message);
+ } catch (MissingResourceException e) {
+ // use raw message
+ }
+ }
+
+ // Apply MessageFormat parameters
+ 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;
+ }
+
+ /**
+ * Formats a JUL message for console output, matching the {@code [LEVEL] message}
+ * style used by MavenSimpleLogger with ANSI coloring when available.
+ */
+ private static String formatForConsole(int level, String message) {
+ MessageBuilder mb = builder();
+ String levelStr =
+ switch (level) {
+ case LocationAwareLogger.TRACE_INT -> mb.trace("TRACE").build();
+ case LocationAwareLogger.DEBUG_INT -> mb.debug("DEBUG").build();
+ case LocationAwareLogger.INFO_INT -> mb.info("INFO").build();
+ case LocationAwareLogger.WARN_INT -> mb.warning("WARNING").build();
+ default -> mb.error("ERROR").build();
+ };
+ return "[" + levelStr + "] " + 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..77fc8b1eb8 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,45 @@ public class MavenSimpleLogger extends MavenBaseLogger {
private String warnRenderedLevel;
private String errorRenderedLevel;
- static Consumer<String> logSink;
+ /**
+ * Structured log sink that receives level, logger name, clean message,
+ * formatted console output, and throwable for each log event.
+ * <p>
+ * This replaces the previous {@code Consumer<String>} sink to enable
+ * console renderers (e.g. rich mode) to filter by log level and access
+ * the clean message independently of ANSI formatting.
+ *
+ * @since 4.1.0
+ */
+ @FunctionalInterface
+ public interface LogSink {
+ void accept(int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable);
+ }
+
+ 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;
+ }
+
MavenSimpleLogger(String name) {
super(name);
}
@@ -70,15 +101,74 @@ protected String renderLevel(int level) {
}
@Override
- protected void write(StringBuilder buf, Throwable t) {
- Consumer<String> sink = logSink;
+ protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) {
+ LogSink sink = logSink;
if (sink != null) {
- sink.accept(buf.toString());
+ // Build the full formatted output including throwable rendering
+ String formatted = formattedBuf.toString();
if (t != null) {
- writeThrowable(t, sink);
+ StringBuilder full = new StringBuilder(formatted);
+ full.append(System.lineSeparator());
+ appendFormattedThrowable(full, t, "");
+ formatted = full.toString();
}
+ sink.accept(level, loggerName, cleanMessage, formatted, t);
} else {
- super.write(buf, t);
+ super.write(formattedBuf, t);
+ }
+ }
+
+ /**
+ * Append a colorized throwable rendering to the given builder.
+ * Reuses the existing formatting logic for consistency with console output.
+ */
+ private void appendFormattedThrowable(StringBuilder sb, Throwable t, String prefix) {
+ MessageBuilder builder = builder().a(prefix).failure(t.getClass().getName());
+ if (t.getMessage() != null) {
+ builder.a(": ").failure(t.getMessage());
+ }
+ sb.append(builder.toString()).append(System.lineSeparator());
+ appendStackTrace(sb, t, prefix);
+ }
+
+ private void appendStackTrace(StringBuilder sb, Throwable t, String prefix) {
+ MessageBuilder builder = builder();
+ for (StackTraceElement e : t.getStackTrace()) {
+ builder.a(prefix);
+ builder.a(" ");
+ builder.strong("at");
+ builder.a(" ");
+ builder.a(e.getClassName());
+ builder.a(".");
+ builder.a(e.getMethodName());
+ builder.a("(");
+ builder.strong(getLocation(e));
+ builder.a(")");
+ sb.append(builder.toString()).append(System.lineSeparator());
+ builder.setLength(0);
+ }
+ for (Throwable se : t.getSuppressed()) {
+ builder.a(prefix)
+ .a(" ")
+ .strong("Suppressed")
+ .a(": ")
+ .a(se.getClass().getName());
+ if (se.getMessage() != null) {
+ builder.a(": ").failure(se.getMessage());
+ }
+ sb.append(builder.toString()).append(System.lineSeparator());
+ builder.setLength(0);
+ appendStackTrace(sb, se, prefix + " ");
+ }
+ Throwable cause = t.getCause();
+ if (cause != null && t != cause) {
+ builder.a(prefix).strong("Caused by").a(": ").a(cause.getClass().getName());
+ if (cause.getMessage() != null) {
+ builder.a(": ").failure(cause.getMessage());
+ }
+ sb.append(builder.toString()).append(System.lineSeparator());
+ builder.setLength(0);
+ appendStackTrace(sb, cause, prefix);
}
}
diff --git a/pom.xml b/pom.xml
index b7c907d764..35f23020dc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -157,7 +157,6 @@ under the License.
<jmhVersion>1.37</jmhVersion>
<junitVersion>6.1.2</junitVersion>
<jxpathVersion>1.4.0</jxpathVersion>
- <logbackClassicVersion>1.6.1</logbackClassicVersion>
<mockitoVersion>5.23.0</mockitoVersion>
<plexusInteractivityVersion>1.5.1</plexusInteractivityVersion>
<plexusInterpolationVersion>1.29</plexusInterpolationVersion>
@@ -508,17 +507,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>