You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/08/31 08:50:35 UTC

(maven) branch master updated: Forward-port: Log API enhancements and mojo MDC (#12929)

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

gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven.git


The following commit(s) were added to refs/heads/master by this push:
     new 08c6ce2462 Forward-port: Log API enhancements and mojo MDC (#12929)
08c6ce2462 is described below

commit 08c6ce2462089471f24b5ae69d36bcb29d224c91
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Mon Aug 31 10:50:28 2026 +0200

    Forward-port: Log API enhancements and mojo MDC (#12929)
    
    * Log API enhancements and mojo MDC (#12690)
    
    * Backport Log API enhancements and mojo MDC to 4.0.x
    
    Backport four Log-related improvements from master to the 4.0.x branch
    for inclusion in rc-7:
    
    1. Log.trace() — new trace level (maps to SLF4J TRACE / JUL FINEST)
       to separate Maven core internals from user-facing debug messages.
       Currently -X floods debug output with resolver/interpolation details
       that drown user-relevant diagnostics.
    
    2. Log.child(name) — creates a sub-logger with an independently
       filterable name (e.g. "CompilerMojo.diagnostics"), letting plugin
       sub-components log under their own namespace.
    
    3. Logger name alignment — Maven 4 Log now uses the mojo implementation
       class name (e.g. "org.apache.maven.plugins.compiler.CompilerMojo")
       instead of the goal name ("compiler:compile"). This matches what
       Maven 3 mojos already use and enables standard SLF4J hierarchical
       level configuration.
    
    4. Mojo MDC propagation — sets "maven.mojo.id" (prefix:goal@executionId)
       in the SLF4J MDC during mojo execution. All log messages — including
       those arriving through the JUL-to-SLF4J bridge — now carry mojo
       context, available to any SLF4J appender via %X{maven.mojo.id}.
    
    Also fixes a pre-existing bug in DefaultLog where warn(Supplier, Throwable)
    incorrectly delegated to logger.info() instead of logger.warn().
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * Add isXxxEnabled() guards to Throwable-only log overloads
    
    Align with master by wrapping the five xxx(Throwable) overloads
    in level-enabled checks, avoiding unnecessary method calls and
    empty string construction when the level is disabled.
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * Address review: default trace methods and fork-aware mojoId
    
    Apply review fixes from #12694 to align the backport:
    
    - Log.java: make all 6 trace methods default (no-ops) to prevent
      AbstractMethodError for existing third-party Log implementors.
      isTraceEnabled() returns false by default.
    
    - ProjectBuildLogAppender: add FORKING_MOJO_ID ThreadLocal mirroring
      the existing FORKING_PROJECT_ID pattern. When setMojoId(null) is
      called, the forking mojo's ID is restored instead of clearing.
    
    - LoggingExecutionListener: save current mojoId in forkStarted(),
      clear forking mojoId in forkSucceeded/forkFailed. Fix cleanup
      ordering in mojoSucceeded/mojoFailed — delegate runs first, then
      MDC is cleared.
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    * Address review: add DefaultLogTest and clear MDC on mojoSkipped
    
    - Add DefaultLogTest with 5 tests: warn/supplier regression,
      trace delegation, trace no-op guard, child() sub-logger,
      and default trace methods (AbstractMethodError prevention).
    
    - Clear mojo MDC in mojoSkipped() to prevent stale mojo context
      from leaking into subsequent log messages.
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
    
    * Address review: javadoc wording in trace methods
    
    - "in the trace error level" → "at the trace error level"
    - "e.g." → "for instance,"
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
---
 .../main/java/org/apache/maven/api/plugin/Log.java |  95 ++++++++++
 .../org/apache/maven/internal/impl/DefaultLog.java |  65 ++++++-
 .../maven/logging/LoggingExecutionListener.java    |  16 ++
 .../maven/logging/ProjectBuildLogAppender.java     |  49 ++++++
 .../maven/plugin/DefaultBuildPluginManager.java    |   2 +-
 .../plugin/internal/DefaultMavenPluginManager.java |   2 +-
 .../apache/maven/internal/impl/DefaultLogTest.java | 196 +++++++++++++++++++++
 7 files changed, 418 insertions(+), 7 deletions(-)

diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
index 50627efd86..23175d07b1 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
@@ -36,6 +36,73 @@
 @Experimental
 @Provider
 public interface Log {
+    /**
+     * {@return true if the <b>trace</b> error level is enabled}
+     * <p>
+     * The default implementation returns {@code false} for backward
+     * compatibility with existing {@code Log} implementations.
+     */
+    default boolean isTraceEnabled() {
+        return false;
+    }
+
+    /**
+     * Sends a message to the user at the <b>trace</b> error level.
+     * <p>
+     * Trace is the most verbose level, intended for Maven core internals
+     * such as resolver negotiation, model interpolation, and lifecycle
+     * ordering details.  Use {@link #debug(CharSequence)} instead for
+     * messages that help <em>users</em> investigate their build
+     * (for instance, why a module was recompiled).
+     * <p>
+     * The default implementation is a no-op for backward compatibility.
+     *
+     * @param content the message to log
+     */
+    default void trace(CharSequence content) {}
+
+    /**
+     * Sends a message (and accompanying exception) to the user at the <b>trace</b> error level.
+     * The error's stacktrace will be output when this error level is enabled.
+     * <p>
+     * The default implementation is a no-op for backward compatibility.
+     *
+     * @param content the message to log
+     * @param error the error that caused this log
+     */
+    default void trace(CharSequence content, Throwable error) {}
+
+    /**
+     * Sends an exception to the user at the <b>trace</b> error level.
+     * The stack trace for this exception will be output when this error level is enabled.
+     * <p>
+     * The default implementation is a no-op for backward compatibility.
+     *
+     * @param error the error that caused this log
+     */
+    default void trace(Throwable error) {}
+
+    /**
+     * Sends a lazily-computed message at the <b>trace</b> error level.
+     * The supplier is only evaluated if trace is enabled.
+     * <p>
+     * The default implementation is a no-op for backward compatibility.
+     *
+     * @param content the message supplier
+     */
+    default void trace(Supplier<String> content) {}
+
+    /**
+     * Sends a lazily-computed message (and accompanying exception) at the <b>trace</b> error level.
+     * The supplier is only evaluated if trace is enabled.
+     * <p>
+     * The default implementation is a no-op for backward compatibility.
+     *
+     * @param content the message supplier
+     * @param error the error that caused this log
+     */
+    default void trace(Supplier<String> content, Throwable error) {}
+
     /**
      * {@return true if the <b>debug</b> error level is enabled}
      */
@@ -43,6 +110,11 @@ public interface Log {
 
     /**
      * Sends a message to the user in the <b>debug</b> error level.
+     * <p>
+     * Debug is intended for messages that help <em>users</em> investigate
+     * their build — for example, why a module was recompiled or what
+     * classpath was resolved.  For Maven core internals, use
+     * {@link #trace(CharSequence)} instead.
      *
      * @param content the message to log
      */
@@ -167,4 +239,27 @@ public interface Log {
     void error(Supplier<String> content);
 
     void error(Supplier<String> content, Throwable error);
+
+    /**
+     * Returns a child logger whose name is derived from this logger's name
+     * by appending a dot and the given suffix.
+     *
+     * <p>For example, if a plugin's logger is named
+     * {@code "org.apache.maven.plugins.compiler.CompilerMojo"},
+     * then {@code child("diagnostics")} returns a logger named
+     * {@code "org.apache.maven.plugins.compiler.CompilerMojo.diagnostics"}.
+     * This lets sub-components log under an independently filterable name
+     * without requiring a separate injection point.</p>
+     *
+     * <p>The default implementation returns {@code this}, so existing
+     * {@code Log} implementations continue to work without changes.
+     * Implementations that wrap a hierarchical logging backend (such as
+     * SLF4J) should override this to create a real child logger.</p>
+     *
+     * @param name the suffix to append (must not be {@code null} or blank)
+     * @return a child logger — never {@code null}
+     */
+    default Log child(String name) {
+        return this;
+    }
 }
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java
index 1a11fe46fd..b1cf40cc40 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java
@@ -22,6 +22,7 @@
 
 import org.apache.maven.api.plugin.Log;
 import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import static java.util.Objects.requireNonNull;
 
@@ -32,6 +33,46 @@ public DefaultLog(Logger logger) {
         this.logger = requireNonNull(logger);
     }
 
+    @Override
+    public boolean isTraceEnabled() {
+        return logger.isTraceEnabled();
+    }
+
+    @Override
+    public void trace(CharSequence content) {
+        if (isTraceEnabled()) {
+            logger.trace(toString(content));
+        }
+    }
+
+    @Override
+    public void trace(CharSequence content, Throwable error) {
+        if (isTraceEnabled()) {
+            logger.trace(toString(content), error);
+        }
+    }
+
+    @Override
+    public void trace(Throwable error) {
+        if (isTraceEnabled()) {
+            logger.trace("", error);
+        }
+    }
+
+    @Override
+    public void trace(Supplier<String> content) {
+        if (isTraceEnabled()) {
+            logger.trace(content.get());
+        }
+    }
+
+    @Override
+    public void trace(Supplier<String> content, Throwable error) {
+        if (isTraceEnabled()) {
+            logger.trace(content.get(), error);
+        }
+    }
+
     @Override
     public void debug(CharSequence content) {
         if (isDebugEnabled()) {
@@ -48,7 +89,9 @@ public void debug(CharSequence content, Throwable error) {
 
     @Override
     public void debug(Throwable error) {
-        logger.debug("", error);
+        if (isDebugEnabled()) {
+            logger.debug("", error);
+        }
     }
 
     @Override
@@ -81,7 +124,9 @@ public void info(CharSequence content, Throwable error) {
 
     @Override
     public void info(Throwable error) {
-        logger.info("", error);
+        if (isInfoEnabled()) {
+            logger.info("", error);
+        }
     }
 
     @Override
@@ -114,7 +159,9 @@ public void warn(CharSequence content, Throwable error) {
 
     @Override
     public void warn(Throwable error) {
-        logger.warn("", error);
+        if (isWarnEnabled()) {
+            logger.warn("", error);
+        }
     }
 
     @Override
@@ -127,7 +174,7 @@ public void warn(Supplier<String> content) {
     @Override
     public void warn(Supplier<String> content, Throwable error) {
         if (isWarnEnabled()) {
-            logger.info(content.get(), error);
+            logger.warn(content.get(), error);
         }
     }
 
@@ -147,7 +194,9 @@ public void error(CharSequence content, Throwable error) {
 
     @Override
     public void error(Throwable error) {
-        logger.error("", error);
+        if (isErrorEnabled()) {
+            logger.error("", error);
+        }
     }
 
     @Override
@@ -184,6 +233,12 @@ public boolean isErrorEnabled() {
         return logger.isErrorEnabled();
     }
 
+    @Override
+    public Log child(String name) {
+        requireNonNull(name, "name");
+        return new DefaultLog(LoggerFactory.getLogger(logger.getName() + "." + name));
+    }
+
     private String toString(CharSequence content) {
         return content != null ? content.toString() : "";
     }
diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java
index 040a481455..700834eb80 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java
@@ -121,6 +121,7 @@ public void projectSkipped(ExecutionEvent event) {
     @Override
     public void mojoStarted(ExecutionEvent event) {
         setMdc(event);
+        setMojoMdc(event);
         buildEventListener.mojoStarted(event);
         delegate.mojoStarted(event);
     }
@@ -129,18 +130,21 @@ public void mojoStarted(ExecutionEvent event) {
     public void mojoSucceeded(ExecutionEvent event) {
         setMdc(event);
         delegate.mojoSucceeded(event);
+        ProjectBuildLogAppender.setMojoId(null);
     }
 
     @Override
     public void mojoFailed(ExecutionEvent event) {
         setMdc(event);
         delegate.mojoFailed(event);
+        ProjectBuildLogAppender.setMojoId(null);
     }
 
     @Override
     public void mojoSkipped(ExecutionEvent event) {
         setMdc(event);
         delegate.mojoSkipped(event);
+        ProjectBuildLogAppender.setMojoId(null);
     }
 
     @Override
@@ -148,18 +152,22 @@ public void forkStarted(ExecutionEvent event) {
         setMdc(event);
         delegate.forkStarted(event);
         ProjectBuildLogAppender.setForkingProjectId(event.getProject().getArtifactId());
+        // Save the forking mojo's ID so it can be restored when the fork completes
+        ProjectBuildLogAppender.setForkingMojoId(ProjectBuildLogAppender.getMojoId());
     }
 
     @Override
     public void forkSucceeded(ExecutionEvent event) {
         delegate.forkSucceeded(event);
         ProjectBuildLogAppender.setForkingProjectId(null);
+        ProjectBuildLogAppender.setForkingMojoId(null);
     }
 
     @Override
     public void forkFailed(ExecutionEvent event) {
         delegate.forkFailed(event);
         ProjectBuildLogAppender.setForkingProjectId(null);
+        ProjectBuildLogAppender.setForkingMojoId(null);
     }
 
     @Override
@@ -187,4 +195,12 @@ private void setMdc(ExecutionEvent event) {
             ProjectBuildLogAppender.setProjectId(event.getProject().getArtifactId());
         }
     }
+
+    private void setMojoMdc(ExecutionEvent event) {
+        if (event.getMojoExecution() != null) {
+            String mojoId = event.getMojoExecution().getMojoDescriptor().getFullGoalName() + "@"
+                    + event.getMojoExecution().getExecutionId();
+            ProjectBuildLogAppender.setMojoId(mojoId);
+        }
+    }
 }
diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java
index 8465df0cf0..dc82a2f898 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
@@ -27,8 +27,11 @@
 public class ProjectBuildLogAppender implements AutoCloseable {
 
     private static final String KEY_PROJECT_ID = "maven.project.id";
+    private static final String KEY_MOJO_ID = "maven.mojo.id";
     private static final ThreadLocal<String> PROJECT_ID = new InheritableThreadLocal<>();
+    private static final ThreadLocal<String> MOJO_ID = new InheritableThreadLocal<>();
     private static final ThreadLocal<String> FORKING_PROJECT_ID = new InheritableThreadLocal<>();
+    private static final ThreadLocal<String> FORKING_MOJO_ID = new InheritableThreadLocal<>();
 
     public static String getProjectId() {
         return PROJECT_ID.get();
@@ -52,6 +55,37 @@ public static void setProjectId(String projectId) {
         }
     }
 
+    public static String getMojoId() {
+        return MOJO_ID.get();
+    }
+
+    /**
+     * Sets or clears the mojo execution identifier in both the thread-local
+     * and the SLF4J MDC.  When clearing ({@code null}), if a forking mojo ID
+     * was saved, it is restored — mirroring the fork-aware project ID pattern.
+     * <p>
+     * Format: {@code "prefix:goal@executionId"}
+     * (e.g. {@code "compiler:compile@default-compile"}).
+     *
+     * @param mojoId the mojo identifier, or {@code null} to clear
+     */
+    public static void setMojoId(String mojoId) {
+        if (mojoId != null) {
+            MOJO_ID.set(mojoId);
+            MDC.put(KEY_MOJO_ID, mojoId);
+        } else {
+            // Restore the forking mojo's ID if one was saved
+            String forkingMojoId = FORKING_MOJO_ID.get();
+            if (forkingMojoId != null) {
+                MOJO_ID.set(forkingMojoId);
+                MDC.put(KEY_MOJO_ID, forkingMojoId);
+            } else {
+                MOJO_ID.remove();
+                MDC.remove(KEY_MOJO_ID);
+            }
+        }
+    }
+
     public static void setForkingProjectId(String forkingProjectId) {
         if (forkingProjectId != null) {
             FORKING_PROJECT_ID.set(forkingProjectId);
@@ -60,6 +94,21 @@ public static void setForkingProjectId(String forkingProjectId) {
         }
     }
 
+    /**
+     * Saves or clears the mojo ID of the forking mojo, so it can be
+     * restored when the fork completes. Mirrors the {@link #setForkingProjectId}
+     * pattern for project IDs.
+     *
+     * @param forkingMojoId the forking mojo identifier, or {@code null} to clear
+     */
+    public static void setForkingMojoId(String forkingMojoId) {
+        if (forkingMojoId != null) {
+            FORKING_MOJO_ID.set(forkingMojoId);
+        } else {
+            FORKING_MOJO_ID.remove();
+        }
+    }
+
     public static void updateMdc() {
         String id = getProjectId();
         if (id != null) {
diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultBuildPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultBuildPluginManager.java
index e395d1ed00..476f2aadcd 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultBuildPluginManager.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultBuildPluginManager.java
@@ -125,7 +125,7 @@ public void executeMojo(MavenSession session, MojoExecution mojoExecution)
             scope.seed(
                     org.apache.maven.api.plugin.Log.class,
                     new DefaultLog(LoggerFactory.getLogger(
-                            mojoExecution.getMojoDescriptor().getFullGoalName())));
+                            mojoExecution.getMojoDescriptor().getImplementation())));
             InternalMavenSession sessionV4 = InternalMavenSession.from(session.getSession());
             scope.seed(Project.class, sessionV4.getProject(project));
             scope.seed(org.apache.maven.api.MojoExecution.class, new DefaultMojoExecution(sessionV4, mojoExecution));
diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java
index 1af5e5effd..915d3d9968 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java
@@ -556,7 +556,7 @@ private <T> T loadV4Mojo(
 
         org.apache.maven.api.MojoExecution execution = new DefaultMojoExecution(sessionV4, mojoExecution);
         org.apache.maven.api.plugin.Log log = new DefaultLog(
-                LoggerFactory.getLogger(mojoExecution.getMojoDescriptor().getFullGoalName()));
+                LoggerFactory.getLogger(mojoExecution.getMojoDescriptor().getImplementation()));
         try {
             Injector injector = Injector.create();
             injector.discover(pluginRealm);
diff --git a/impl/maven-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
new file mode 100644
index 0000000000..eb290734fb
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java
@@ -0,0 +1,196 @@
+/*
+ * 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.impl;
+
+import org.apache.maven.api.plugin.Log;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link DefaultLog}.
+ */
+class DefaultLogTest {
+
+    /**
+     * Regression test: {@code warn(Supplier, Throwable)} was incorrectly
+     * calling {@code logger.info()} instead of {@code logger.warn()}.
+     */
+    @Test
+    void warnWithSupplierAndThrowableDelegatesToWarn() {
+        Logger mockLogger = mock(Logger.class);
+        when(mockLogger.isWarnEnabled()).thenReturn(true);
+
+        DefaultLog log = new DefaultLog(mockLogger);
+        RuntimeException ex = new RuntimeException("test");
+        log.warn(() -> "warning message", ex);
+
+        verify(mockLogger).warn("warning message", ex);
+    }
+
+    /**
+     * Verify trace methods delegate to the SLF4J logger correctly.
+     */
+    @Test
+    void traceMethodsDelegateToSlf4jTrace() {
+        Logger mockLogger = mock(Logger.class);
+        when(mockLogger.isTraceEnabled()).thenReturn(true);
+
+        DefaultLog log = new DefaultLog(mockLogger);
+        log.trace("trace message");
+
+        verify(mockLogger).trace("trace message");
+    }
+
+    /**
+     * Verify that trace methods are no-ops when trace is disabled.
+     */
+    @Test
+    void traceIsNoOpWhenDisabled() {
+        Logger mockLogger = mock(Logger.class);
+        when(mockLogger.isTraceEnabled()).thenReturn(false);
+
+        DefaultLog log = new DefaultLog(mockLogger);
+        log.trace("should not be logged");
+
+        verify(mockLogger).isTraceEnabled();
+        verifyNoMoreInteractions(mockLogger);
+    }
+
+    /**
+     * Verify that {@code child()} creates a new logger with the
+     * expected hierarchical name.
+     */
+    @Test
+    void childCreatesSubLogger() {
+        Logger mockLogger = mock(Logger.class);
+        when(mockLogger.getName()).thenReturn("org.apache.maven.plugins.compiler.CompilerMojo");
+
+        DefaultLog parent = new DefaultLog(mockLogger);
+        Log child = parent.child("diagnostics");
+
+        assertNotSame(parent, child);
+        assertTrue(child instanceof DefaultLog, "child should be a DefaultLog");
+    }
+
+    /**
+     * Verify that the default {@code Log.isTraceEnabled()} returns false,
+     * preventing {@code AbstractMethodError} for third-party implementors.
+     */
+    @Test
+    void defaultTraceIsDisabled() {
+        // Use a minimal Log implementation that relies on defaults
+        Log minimal = new Log() {
+            @Override
+            public boolean isDebugEnabled() {
+                return false;
+            }
+
+            @Override
+            public void debug(CharSequence c) {}
+
+            @Override
+            public void debug(CharSequence c, Throwable e) {}
+
+            @Override
+            public void debug(Throwable e) {}
+
+            @Override
+            public void debug(java.util.function.Supplier<String> c) {}
+
+            @Override
+            public void debug(java.util.function.Supplier<String> c, Throwable e) {}
+
+            @Override
+            public boolean isInfoEnabled() {
+                return false;
+            }
+
+            @Override
+            public void info(CharSequence c) {}
+
+            @Override
+            public void info(CharSequence c, Throwable e) {}
+
+            @Override
+            public void info(Throwable e) {}
+
+            @Override
+            public void info(java.util.function.Supplier<String> c) {}
+
+            @Override
+            public void info(java.util.function.Supplier<String> c, Throwable e) {}
+
+            @Override
+            public boolean isWarnEnabled() {
+                return false;
+            }
+
+            @Override
+            public void warn(CharSequence c) {}
+
+            @Override
+            public void warn(CharSequence c, Throwable e) {}
+
+            @Override
+            public void warn(Throwable e) {}
+
+            @Override
+            public void warn(java.util.function.Supplier<String> c) {}
+
+            @Override
+            public void warn(java.util.function.Supplier<String> c, Throwable e) {}
+
+            @Override
+            public boolean isErrorEnabled() {
+                return false;
+            }
+
+            @Override
+            public void error(CharSequence c) {}
+
+            @Override
+            public void error(CharSequence c, Throwable e) {}
+
+            @Override
+            public void error(Throwable e) {}
+
+            @Override
+            public void error(java.util.function.Supplier<String> c) {}
+
+            @Override
+            public void error(java.util.function.Supplier<String> c, Throwable e) {}
+        };
+
+        // These should NOT throw AbstractMethodError — they use defaults
+        assertEquals(false, minimal.isTraceEnabled());
+        minimal.trace("should be a no-op");
+        minimal.trace("no-op", new RuntimeException());
+        minimal.trace(new RuntimeException());
+        minimal.trace(() -> "no-op");
+        minimal.trace(() -> "no-op", new RuntimeException());
+    }
+}