You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/10/15 11:23:26 UTC

(maven) branch feature/logging-foundation updated (8b0748d8c3 -> d70fcc0f9e)

This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a change to branch feature/logging-foundation
in repository https://gitbox.apache.org/repos/asf/maven.git


    omit 8b0748d8c3 fix: restore JUL root logger level in invoke() finally block
    omit 051e0ec268 feat: logging foundation – structured LogEvent, JUL handler, Log API enhancements
     new d70fcc0f9e feat: logging foundation – structured LogEvent, JUL handler, Log API enhancements

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (8b0748d8c3)
            \
             N -- N -- N   refs/heads/feature/logging-foundation (d70fcc0f9e)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:


(maven) 01/01: feat: logging foundation – structured LogEvent, JUL handler, Log API enhancements

Posted by gn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a commit to branch feature/logging-foundation
in repository https://gitbox.apache.org/repos/asf/maven.git

commit d70fcc0f9e44bf4d0a104f9429f0b0dd882a45aa
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Thu Oct 15 13:23:13 2026 +0200

    feat: logging foundation – structured LogEvent, JUL handler, Log API enhancements
    
    Structured LogEvent (maven-api-core, maven-core):
    - LogEvent / LogLevel API for structured log event representation
    - Source metadata: sourceClassName(), sourceMethodName(), threadId()
    - Execution context: projectId() and mojoId()
    
    Custom JUL Handler (maven-logging):
    - MavenJulHandler replaces SLF4JBridgeHandler — all JUL events route
      through SLF4J for consistent formatting regardless of origin
    - JUL metadata flows as method parameters via logDirect() instead of
      ThreadLocals
    - Re-entrancy guard for JLine StyleResolver → JUL → SLF4J bootstrap cycle
    
    Direct logging path (maven-logging, maven-core):
    - MavenSimpleLogger.logDirect() bypasses the SLF4J call chain for Log API
      and JUL events, carrying source metadata as parameters
    - MavenBaseLogger.formatLogPrefix() extracted as shared formatting logic
    - Eliminates ThreadLocal round-trips, lambda allocations, and SLF4J
      dispatch overhead for Maven-controlled logging paths
    
    Capability-driven optimization (maven-logging):
    - LogSink.needsFormattedMessage() — skip formatting when consumer uses
      structured fields (e.g. machine/JSON output mode)
    - LogSink.needsMetadata() — skip StackWalker and SourceMetadata allocation
      when consumer doesn't read source info (plain/rich console modes)
    - ProjectBuildLogAppender wires flags: metadata auto-enabled when
      reportCapture is active
    
    Structured LogSink (maven-logging, maven-core):
    - MavenSimpleLogger.LogSink replaces Consumer<String> with structured
      callback including level, logger name, clean message, and metadata
    - ProjectBuildLogAppender produces LogEvent objects with source metadata
    - BuildEventListener.projectLogMessage() now takes LogEvent
    
    Log API metadata (maven-core):
    - DefaultLog resolves caller class/method via StackWalker, gated behind
      LogSink.needsMetadata() for zero overhead in plain/rich console modes
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
 apache-maven/pom.xml                               |   7 -
 api/maven-api-core/src/main/java/module-info.java  |   1 +
 .../apache/maven/api/build/report/LogEvent.java    | 189 +++++++++++++++
 .../apache/maven/api/build/report/LogLevel.java    |  34 ++-
 .../maven/api/build/report/package-info.java       |  26 +-
 compat/maven-embedder/pom.xml                      |   5 -
 impl/maven-cli/pom.xml                             |   9 -
 .../apache/maven/cling/invoker/LookupInvoker.java  |  71 +++++-
 .../cling/logging/impl/LogbackConfiguration.java   |  46 ----
 .../META-INF/maven/slf4j-configuration.properties  |   2 -
 .../maven/internal/build/DefaultLogEvent.java      |  81 +++++++
 .../org/apache/maven/internal/impl/DefaultLog.java |  99 ++++++--
 .../apache/maven/logging/BuildEventListener.java   |   3 +-
 .../maven/logging/ProjectBuildLogAppender.java     | 145 +++++++++++-
 .../maven/logging/SimpleBuildEventListener.java    |   6 +-
 .../apache/maven/internal/impl/DefaultLogTest.java |   5 +-
 .../maven/jline/JLineMessageBuilderFactory.java    |  13 +-
 impl/maven-logging/src/main/java/module-info.java  |   1 +
 .../org/apache/maven/slf4j/MavenBaseLogger.java    |  62 +++--
 .../org/apache/maven/slf4j/MavenJulHandler.java    | 262 +++++++++++++++++++++
 .../org/apache/maven/slf4j/MavenSimpleLogger.java  | 135 ++++++++++-
 .../apache/maven/slf4j/MavenJulHandlerTest.java    | 119 ++++++++++
 pom.xml                                            |  11 -
 23 files changed, 1143 insertions(+), 189 deletions(-)

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