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/07 17:13:47 UTC
(maven) 01/01: Logging foundation: structured LogEvent, JUL handler, Log.trace/child
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 937f4ade26ff849af79ae2fe4bec0100e787ad49
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Fri Aug 7 19:13:33 2026 +0200
Logging foundation: structured LogEvent, JUL handler, Log.trace/child
Lay the groundwork for structured build reporting and richer
log capture by introducing:
- Log.trace() level (5 overloads + isTraceEnabled) to separate
Maven core internals from user-facing debug output
- Log.child(name) for hierarchical sub-loggers
- LogEvent/LogLevel API for structured log event representation,
including optional JUL metadata (sourceClassName, sourceMethodName,
threadId) that the standard SLF4J bridge silently drops
- MavenJulHandler: a custom java.util.logging Handler that replaces
SLF4JBridgeHandler, preserving full LogRecord metadata through a
ThreadLocal read by ProjectBuildLogAppender
- Structured LogSink in MavenSimpleLogger (level, logger name, clean
message, formatted output, throwable) replacing the old string sink
- mojoId MDC (maven.mojo.id) set during mojo execution so all log
messages carry mojo context
- Logger name changed from getFullGoalName() to getImplementation()
(FQCN) for proper hierarchical SLF4J level control
- Fix: DefaultLog.warn(Supplier, Throwable) was calling logger.info()
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../apache/maven/api/build/report/LogEvent.java | 150 ++++++++++++++
.../apache/maven/api/build/report/LogLevel.java | 38 ++--
.../maven/api/build/report/package-info.java | 37 ++--
.../main/java/org/apache/maven/api/plugin/Log.java | 66 +++++++
.../apache/maven/cling/invoker/LookupInvoker.java | 7 +-
.../maven/internal/build/DefaultLogEvent.java | 75 +++++++
.../org/apache/maven/internal/impl/DefaultLog.java | 47 ++++-
.../apache/maven/logging/BuildEventListener.java | 3 +-
.../maven/logging/LoggingExecutionListener.java | 11 ++
.../maven/logging/ProjectBuildLogAppender.java | 86 +++++++-
.../maven/logging/SimpleBuildEventListener.java | 6 +-
.../maven/plugin/DefaultBuildPluginManager.java | 2 +-
.../plugin/internal/DefaultMavenPluginManager.java | 2 +-
.../org/apache/maven/slf4j/MavenBaseLogger.java | 36 +++-
.../org/apache/maven/slf4j/MavenJulHandler.java | 216 +++++++++++++++++++++
.../org/apache/maven/slf4j/MavenSimpleLogger.java | 132 ++++++++++++-
16 files changed, 842 insertions(+), 72 deletions(-)
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..08bb7e190d
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java
@@ -0,0 +1,150 @@
+/*
+ * 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 JUL ({@code java.util.logging}) may carry
+ * additional metadata that is normally lost when routed through the
+ * SLF4J bridge: the source class name, source method name, and thread
+ * identifier. These fields are populated by Maven's custom JUL
+ * {@link java.util.logging.Handler} and are {@code null} for events
+ * from other logging paths (Log API, SLF4J direct).
+ *
+ * @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();
+
+ // ---- JUL metadata (populated only for events from java.util.logging) ----
+
+ /**
+ * The fully qualified class name of the source that issued the log call,
+ * as reported by JUL's {@code LogRecord.getSourceClassName()}.
+ * <p>
+ * This field is only populated for events originating from JUL. For
+ * events from the Maven Log API or SLF4J, 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,
+ * as reported by JUL's {@code LogRecord.getSourceMethodName()}.
+ *
+ * @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,
+ * as reported by JUL's {@code LogRecord.getLongThreadID()}.
+ * <p>
+ * Returns {@code -1} if the thread ID is not available (i.e. for
+ * non-JUL events).
+ *
+ * @return the thread ID, or {@code -1} if unavailable
+ * @since 4.1.0
+ */
+ default long threadId() {
+ return -1;
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java
similarity index 53%
copy from impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java
copy to api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java
index 39573d061c..684ea610a5 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java
@@ -16,33 +16,21 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.maven.logging;
+package org.apache.maven.api.build.report;
-import org.apache.maven.execution.ExecutionEvent;
-import org.eclipse.aether.transfer.TransferEvent;
+import org.apache.maven.api.annotations.Experimental;
/**
- * An abstract build event sink.
+ * Log severity levels, mirroring the standard SLF4J levels.
+ *
+ * @since 4.1.0
+ * @see LogEvent#level()
*/
-public interface BuildEventListener {
-
- void sessionStarted(ExecutionEvent event);
-
- void projectStarted(String projectId);
-
- void projectLogMessage(String projectId, String event);
-
- void projectFinished(String projectId);
-
- void executionFailure(String projectId, boolean halted, String exception);
-
- void mojoStarted(ExecutionEvent event);
-
- void finish(int exitCode) throws Exception;
-
- void fail(Throwable t) throws Exception;
-
- void log(String msg);
-
- void transfer(String projectId, TransferEvent e);
+@Experimental
+public enum LogLevel {
+ TRACE,
+ DEBUG,
+ INFO,
+ WARN,
+ ERROR
}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
similarity index 53%
copy from impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java
copy to api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
index 39573d061c..8aee68ff90 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
@@ -16,33 +16,18 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.maven.logging;
-
-import org.apache.maven.execution.ExecutionEvent;
-import org.eclipse.aether.transfer.TransferEvent;
/**
- * An abstract build event sink.
+ * 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
*/
-public interface BuildEventListener {
-
- void sessionStarted(ExecutionEvent event);
-
- void projectStarted(String projectId);
-
- void projectLogMessage(String projectId, String event);
-
- void projectFinished(String projectId);
-
- void executionFailure(String projectId, boolean halted, String exception);
-
- void mojoStarted(ExecutionEvent event);
-
- void finish(int exitCode) throws Exception;
-
- void fail(Throwable t) throws Exception;
-
- void log(String msg);
+@Experimental
+package org.apache.maven.api.build.report;
- void transfer(String projectId, TransferEvent e);
-}
+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/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-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..47139ff15e
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java
@@ -0,0 +1,75 @@
+/*
+ * 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 JUL source class name, or {@code null}
+ * @param sourceMethodName the JUL source method name, or {@code null}
+ * @param threadId the JUL thread ID, 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)
+ implements LogEvent {
+
+ /**
+ * Convenience constructor for events without JUL metadata
+ * (i.e. events from the SLF4J pipeline).
+ */
+ public DefaultLogEvent(
+ Instant timestamp,
+ LogLevel level,
+ String message,
+ String loggerName,
+ String stackTrace,
+ String formattedMessage) {
+ this(timestamp, level, message, loggerName, stackTrace, formattedMessage, null, null, -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);
+ }
+}
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..a70c2adb29 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
@@ -22,6 +22,7 @@
import org.apache.maven.api.plugin.Log;
import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import static java.util.Objects.requireNonNull;
@@ -32,6 +33,44 @@ public DefaultLog(Logger logger) {
this.logger = requireNonNull(logger);
}
+ @Override
+ public boolean isTraceEnabled() {
+ return logger.isTraceEnabled();
+ }
+
+ @Override
+ public void trace(CharSequence content) {
+ if (isTraceEnabled()) {
+ logger.trace(toString(content));
+ }
+ }
+
+ @Override
+ public void trace(CharSequence content, Throwable error) {
+ if (isTraceEnabled()) {
+ logger.trace(toString(content), error);
+ }
+ }
+
+ @Override
+ public void trace(Throwable error) {
+ logger.trace("", error);
+ }
+
+ @Override
+ public void trace(Supplier<String> content) {
+ if (isTraceEnabled()) {
+ logger.trace(content.get());
+ }
+ }
+
+ @Override
+ public void trace(Supplier<String> content, Throwable error) {
+ if (isTraceEnabled()) {
+ logger.trace(content.get(), error);
+ }
+ }
+
@Override
public void debug(CharSequence content) {
if (isDebugEnabled()) {
@@ -127,7 +166,7 @@ public void warn(Supplier<String> content) {
@Override
public void warn(Supplier<String> content, Throwable error) {
if (isWarnEnabled()) {
- logger.info(content.get(), error);
+ logger.warn(content.get(), error);
}
}
@@ -184,6 +223,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..e08976927f 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,33 @@
*/
package org.apache.maven.logging;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.time.Instant;
+
+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.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 +69,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);
@@ -76,13 +118,51 @@ 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;
+
+ // Read JUL metadata if this event originated from java.util.logging
+ MavenJulHandler.JulMetadata julMeta = MavenJulHandler.getJulMetadata();
+ LogEvent event;
+ if (julMeta != null) {
+ event = new DefaultLogEvent(
+ timestamp,
+ logLevel,
+ cleanMessage,
+ loggerName,
+ stackTrace,
+ formattedMessage,
+ julMeta.sourceClassName(),
+ julMeta.sourceMethodName(),
+ julMeta.threadId());
+ } else {
+ event = new DefaultLogEvent(timestamp, logLevel, cleanMessage, loggerName, stackTrace, formattedMessage);
+ }
+ buildEventListener.projectLogMessage(projectId, 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..d14167dfea 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,24 @@ private void innerHandleNormalizedLoggingCall(
// Append the message
buf.append(formattedMessage);
- write(buf, t);
+ write(level.toInt(), name, formattedMessage, buf, t);
+
+ // Notify structured log event listener (for build report capture)
+ onLogEvent(level.toInt(), name, formattedMessage, t);
+ }
+
+ /**
+ * Hook called after every log event with structured data.
+ * Subclasses can override to forward to a structured event sink
+ * (e.g. for build report log capture).
+ *
+ * @param level the log level (one of the {@code LOG_LEVEL_*} constants)
+ * @param loggerName the name of the logger that produced the event
+ * @param message the formatted log message (without level/timestamp prefix)
+ * @param throwable the associated throwable, or {@code null}
+ */
+ protected void onLogEvent(int level, String loggerName, String message, Throwable throwable) {
+ // no-op — overridden by MavenSimpleLogger
}
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..b4358670a6
--- /dev/null
+++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java
@@ -0,0 +1,216 @@
+/*
+ * 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.slf4j.LoggerFactory;
+import org.slf4j.spi.LocationAwareLogger;
+
+/**
+ * A JUL {@link Handler} that bridges {@code java.util.logging} to SLF4J
+ * while preserving the rich {@link LogRecord} metadata that the standard
+ * {@code SLF4JBridgeHandler} silently drops (source class name, source
+ * method name, thread ID).
+ * <p>
+ * The metadata is stashed in a thread-local before the SLF4J logger is
+ * called, so downstream consumers (e.g. {@code ProjectBuildLogAppender})
+ * can read it when constructing a structured {@code LogEvent}.
+ * <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;
+ }
+
+ org.slf4j.Logger slf4jLogger = LoggerFactory.getLogger(record.getLoggerName());
+ 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();
+
+ // Stash JUL metadata before calling SLF4J so downstream consumers
+ // (ProjectBuildLogAppender) can read it synchronously on this thread.
+ METADATA.set(
+ new JulMetadata(record.getSourceClassName(), record.getSourceMethodName(), record.getLongThreadID()));
+ try {
+ logToSlf4j(slf4jLogger, slf4jLevel, message, throwable);
+ } finally {
+ METADATA.remove();
+ }
+ }
+
+ @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;
+ }
+
+ 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..5d44a06375 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,65 @@ 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;
+
+ /**
+ * Structured log event sink for build report capture.
+ * Receives level, logger name, clean message, and throwable
+ * independently of the formatted console output.
+ */
+ @FunctionalInterface
+ public interface LogEventSink {
+ void accept(int level, String loggerName, String message, Throwable throwable);
+ }
+
+ private static volatile LogEventSink logEventSink;
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;
}
+ public static void setLogEventSink(LogEventSink sink) {
+ MavenSimpleLogger.logEventSink = sink;
+ }
+
+ public static LogEventSink getLogEventSink() {
+ return logEventSink;
+ }
+
+ /**
+ * 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 +121,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);
}
}
@@ -167,4 +277,12 @@ public void configure(int defaultLogLevel) {
public void setLogLevel(int logLevel) {
this.currentLogLevel = logLevel;
}
+
+ @Override
+ protected void onLogEvent(int level, String loggerName, String message, Throwable throwable) {
+ LogEventSink sink = logEventSink;
+ if (sink != null) {
+ sink.accept(level, loggerName, message, throwable);
+ }
+ }
}