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

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

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

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

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

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

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