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>