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/09/18 11:14:28 UTC

(maven) branch feature/build-report updated: Build report: structured JSON report with per-mojo log capture

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

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


The following commit(s) were added to refs/heads/feature/build-report by this push:
     new d63bba8c0b Build report: structured JSON report with per-mojo log capture
d63bba8c0b is described below

commit d63bba8c0b7790b3bf51c5d64054b8bbbdee0cf2
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Fri Aug 28 11:27:40 2026 +0200

    Build report: structured JSON report with per-mojo log capture
    
    - BuildReport/ModuleReport/MojoReport/FailureReport API interfaces
    - BuildReportCollector EventSpy tracks lifecycle events and captures log output
    - BuildReportJsonWriter zero-dependency JSON serializer with stable field order
    - Thread-based log routing for parallel builds via ConcurrentHashMap
    - Atomic writes with timestamped filename and build-report-latest.json symlink
---
 .../apache/maven/api/build/report/BuildReport.java | 195 +++++++
 .../report/{package-info.java => BuildStatus.java} |  27 +-
 .../maven/api/build/report/FailureReport.java      |  89 ++++
 .../maven/api/build/report/ModuleReport.java       | 134 +++++
 .../apache/maven/api/build/report/MojoReport.java  | 138 +++++
 .../maven/api/build/report/package-info.java       |  13 +-
 .../maven/internal/build/BuildReportCollector.java | 564 +++++++++++++++++++++
 .../internal/build/BuildReportJsonWriter.java      | 387 ++++++++++++++
 .../maven/internal/build/DefaultBuildReport.java   |  82 +++
 .../maven/internal/build/DefaultFailureReport.java |  21 +-
 .../maven/internal/build/DefaultModuleReport.java  |  53 ++
 .../maven/internal/build/DefaultMojoReport.java    |  38 +-
 .../org/apache/maven/internal/impl/DefaultLog.java |  29 +-
 .../maven/logging/LoggingExecutionListener.java    |   8 +-
 .../maven/logging/ProjectBuildLogAppender.java     |  43 +-
 .../internal/build/BuildReportCollectorTest.java   | 313 ++++++++++++
 .../internal/build/BuildReportIntegrationTest.java | 313 ++++++++++++
 .../internal/build/BuildReportJsonWriterTest.java  | 423 ++++++++++++++++
 .../org/apache/maven/slf4j/MavenJulHandler.java    |  55 +-
 .../org/apache/maven/slf4j/MavenSimpleLogger.java  |  60 ++-
 .../apache/maven/slf4j/MavenJulHandlerTest.java    | 151 ------
 21 files changed, 2871 insertions(+), 265 deletions(-)

diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java
new file mode 100644
index 0000000000..7cd7975b88
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java
@@ -0,0 +1,195 @@
+/*
+ * 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.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.services.BuilderProblem;
+
+/**
+ * A structured report of a Maven build execution, persisted to
+ * {@code target/build-report.json} at the end of every build.
+ * <p>
+ * The report captures metadata, per-module results (including mojo execution
+ * timings), and any failures. It is intended to be consumed by tools, IDEs,
+ * CI systems, and LLM agents without having to re-run the build or parse
+ * console output.
+ *
+ * @since 4.1.0
+ * @see ModuleReport
+ * @see FailureReport
+ */
+@Experimental
+public interface BuildReport {
+
+    /**
+     * Schema version of the report format. Consumers should check this
+     * to handle forward compatibility.
+     *
+     * @return the format version, currently {@code 1}
+     */
+    int formatVersion();
+
+    /**
+     * The overall build status.
+     *
+     * @return the build outcome, never {@code null}
+     */
+    @Nonnull
+    BuildStatus status();
+
+    /**
+     * Wall-clock duration of the entire build.
+     *
+     * @return the total duration, never {@code null}
+     */
+    @Nonnull
+    Duration duration();
+
+    /**
+     * When the build started (wall-clock time).
+     *
+     * @return the start instant, never {@code null}
+     */
+    @Nonnull
+    Instant startTime();
+
+    /**
+     * The Maven version that produced this report.
+     *
+     * @return the Maven version string, never {@code null}
+     */
+    @Nonnull
+    String mavenVersion();
+
+    /**
+     * The Java version used for the build.
+     *
+     * @return the Java version string, never {@code null}
+     */
+    @Nonnull
+    String javaVersion();
+
+    /**
+     * The goals or phases that were requested.
+     *
+     * @return the list of goals, never {@code null}
+     */
+    @Nonnull
+    List<String> goals();
+
+    /**
+     * The GAV of the top-level project ({@code groupId:artifactId:version}).
+     *
+     * @return the project identifier, never {@code null}
+     */
+    @Nonnull
+    String project();
+
+    /**
+     * Whether this was a multi-module (reactor) build.
+     *
+     * @return {@code true} for multi-module builds
+     */
+    boolean multiModule();
+
+    /**
+     * The degree of concurrency ({@code -T} flag), or 1 for sequential builds.
+     *
+     * @return the thread count
+     */
+    int threads();
+
+    /**
+     * Per-module build results, in reactor execution order.
+     *
+     * @return the module reports, never {@code null}
+     */
+    @Nonnull
+    List<ModuleReport> modules();
+
+    /**
+     * Failures that occurred during the build, if any.
+     *
+     * @return the failure reports, never {@code null}; empty if the build succeeded
+     */
+    @Nonnull
+    List<FailureReport> failures();
+
+    /**
+     * Structured problems (warnings, errors) reported during the build by
+     * Maven itself or by plugins.
+     *
+     * @return the problems, never {@code null}; empty if none were reported
+     * @since 4.1.0
+     */
+    @Nonnull
+    List<BuilderProblem> problems();
+
+    /**
+     * Structured log events captured outside of any module's lifecycle —
+     * Maven startup messages, reactor ordering, and the final reactor summary.
+     * <p>
+     * For per-module events see {@link ModuleReport#output()}, and for
+     * per-mojo events see {@link MojoReport#output()}.
+     * <p>
+     * Together, {@code BuildReport.output()}, {@code ModuleReport.output()},
+     * and {@code MojoReport.output()} form a non-overlapping partition of
+     * the full build log.
+     *
+     * @return the captured log events, never {@code null}; may be empty
+     */
+    @Nonnull
+    List<LogEvent> output();
+
+    /**
+     * Find a module report by its GAV identifier.
+     * <p>
+     * The identifier format is {@code "groupId:artifactId:version"}, matching
+     * the format returned by {@link ModuleReport#id()} and used in
+     * {@link FailureReport#module()}.
+     *
+     * @param moduleId the module GAV string
+     *     (e.g. {@code "org.apache.maven:maven-core:4.1.0-SNAPSHOT"})
+     * @return the matching module report, or empty if not found
+     */
+    @Nonnull
+    default Optional<ModuleReport> findModule(String moduleId) {
+        Objects.requireNonNull(moduleId);
+        return modules().stream().filter(m -> moduleId.equals(m.id())).findFirst();
+    }
+
+    /**
+     * Find the module report that corresponds to a given failure.
+     *
+     * @param failure the failure report
+     * @return the matching module report, or empty if not found
+     */
+    @Nonnull
+    default Optional<ModuleReport> findModule(FailureReport failure) {
+        Objects.requireNonNull(failure);
+        return findModule(failure.module());
+    }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java
similarity index 73%
copy from api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
copy to api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java
index 8aee68ff90..1ee25f8cb3 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java
@@ -16,18 +16,29 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+package org.apache.maven.api.build.report;
+
+import org.apache.maven.api.annotations.Experimental;
 
 /**
- * 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.
+ * The outcome of a build, module, or mojo execution.
  *
  * @since 4.1.0
  */
 @Experimental
-package org.apache.maven.api.build.report;
+public enum BuildStatus {
+    /**
+     * Completed successfully.
+     */
+    SUCCESS,
 
-import org.apache.maven.api.annotations.Experimental;
+    /**
+     * Failed with an error.
+     */
+    FAILURE,
+
+    /**
+     * Skipped (e.g. because a dependency failed).
+     */
+    SKIPPED
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java
new file mode 100644
index 0000000000..20e5d95b8a
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api.build.report;
+
+import java.time.Instant;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+
+/**
+ * Details about a build failure.
+ *
+ * @since 4.1.0
+ * @see BuildReport#failures()
+ */
+@Experimental
+public interface FailureReport {
+
+    /**
+     * The GAV of the module where the failure occurred
+     * ({@code groupId:artifactId:version}).
+     *
+     * @return the module identifier, never {@code null}
+     */
+    @Nonnull
+    String module();
+
+    /**
+     * The mojo that failed, formatted as {@code artifactId:version:goal}
+     * (e.g. {@code "maven-compiler-plugin:3.15.0:compile"}).
+     *
+     * @return the mojo identifier, or {@code null} if the failure was not mojo-specific
+     */
+    @Nullable
+    String mojo();
+
+    /**
+     * When the failure occurred (wall-clock time).
+     *
+     * @return the failure instant, never {@code null}
+     */
+    @Nonnull
+    Instant timestamp();
+
+    /**
+     * The simple class name of the root cause exception
+     * (e.g. {@code "MojoFailureException"}, {@code "LifecycleExecutionException"}).
+     * <p>
+     * Useful for programmatic triage — tools can pattern-match on known
+     * exception types without parsing the message.
+     *
+     * @return the exception type name, or {@code null} if unavailable
+     */
+    @Nullable
+    String exceptionType();
+
+    /**
+     * The exception message.
+     *
+     * @return the error message, never {@code null}
+     */
+    @Nonnull
+    String message();
+
+    /**
+     * The exception stack trace, truncated to a reasonable length.
+     *
+     * @return the stack trace string, or {@code null} if unavailable
+     */
+    @Nullable
+    String stackTrace();
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java
new file mode 100644
index 0000000000..7746545a42
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java
@@ -0,0 +1,134 @@
+/*
+ * 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.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+
+/**
+ * Build results for a single module in a reactor build.
+ *
+ * @since 4.1.0
+ * @see BuildReport#modules()
+ */
+@Experimental
+public interface ModuleReport {
+
+    /**
+     * The module's group ID.
+     *
+     * @return the group ID, never {@code null}
+     */
+    @Nonnull
+    String groupId();
+
+    /**
+     * The module's artifact ID.
+     *
+     * @return the artifact ID, never {@code null}
+     */
+    @Nonnull
+    String artifactId();
+
+    /**
+     * The module's version.
+     *
+     * @return the version string, never {@code null}
+     */
+    @Nonnull
+    String version();
+
+    /**
+     * The build outcome for this module.
+     *
+     * @return the status, never {@code null}
+     */
+    @Nonnull
+    BuildStatus status();
+
+    /**
+     * When this module started building (wall-clock time).
+     *
+     * @return the start instant, never {@code null}
+     */
+    @Nonnull
+    Instant startTime();
+
+    /**
+     * How long this module took to build.
+     *
+     * @return the duration, never {@code null}
+     */
+    @Nonnull
+    Duration duration();
+
+    /**
+     * The mojo executions that ran within this module, in execution order.
+     *
+     * @return the mojo reports, never {@code null}
+     */
+    @Nonnull
+    List<MojoReport> mojos();
+
+    /**
+     * Structured log events captured during this module's build lifecycle
+     * but outside any mojo execution — dependency resolution messages,
+     * resource copying, and other Maven infrastructure output.
+     * <p>
+     * For per-mojo events see {@link MojoReport#output()}.
+     *
+     * @return the captured log events, never {@code null}; may be empty
+     */
+    @Nonnull
+    List<LogEvent> output();
+
+    /**
+     * The module identifier formatted as {@code "groupId:artifactId:version"}.
+     * <p>
+     * This matches the format used by {@link FailureReport#module()}, allowing
+     * direct lookup from a failure report.
+     *
+     * @return the GAV string, never {@code null}
+     */
+    @Nonnull
+    default String id() {
+        return groupId() + ":" + artifactId() + ":" + version();
+    }
+
+    /**
+     * Find a mojo execution by its identifier string.
+     * <p>
+     * The identifier format is {@code "artifactId:version:goal"}, matching
+     * the format used by {@link FailureReport#mojo()}.
+     *
+     * @param mojoId the mojo identifier (e.g. {@code "maven-compiler-plugin:3.15.0:compile"})
+     * @return the matching mojo report, or empty if not found
+     */
+    @Nonnull
+    default Optional<MojoReport> findMojo(String mojoId) {
+        Objects.requireNonNull(mojoId);
+        return mojos().stream().filter(m -> mojoId.equals(m.id())).findFirst();
+    }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java
new file mode 100644
index 0000000000..76001babbd
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java
@@ -0,0 +1,138 @@
+/*
+ * 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.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+
+/**
+ * Report for a single mojo (plugin goal) execution within a module.
+ *
+ * @since 4.1.0
+ * @see ModuleReport#mojos()
+ */
+@Experimental
+public interface MojoReport {
+
+    /**
+     * The plugin's group ID.
+     *
+     * @return the group ID, never {@code null}
+     */
+    @Nonnull
+    String groupId();
+
+    /**
+     * The plugin's artifact ID.
+     *
+     * @return the artifact ID, never {@code null}
+     */
+    @Nonnull
+    String artifactId();
+
+    /**
+     * The plugin version.
+     *
+     * @return the version string, never {@code null}
+     */
+    @Nonnull
+    String version();
+
+    /**
+     * The goal that was executed (e.g. {@code "compile"}, {@code "test"}).
+     *
+     * @return the goal name, never {@code null}
+     */
+    @Nonnull
+    String goal();
+
+    /**
+     * The execution ID (e.g. {@code "default-compile"}).
+     *
+     * @return the execution ID, or {@code null} if not set
+     */
+    @Nullable
+    String executionId();
+
+    /**
+     * The lifecycle phase this mojo was bound to (e.g. {@code "compile"}, {@code "test"}).
+     *
+     * @return the phase name, or {@code null} if invoked directly
+     */
+    @Nullable
+    String phase();
+
+    /**
+     * The outcome of this mojo execution.
+     *
+     * @return the status, never {@code null}
+     */
+    @Nonnull
+    BuildStatus status();
+
+    /**
+     * When this mojo execution started (wall-clock time).
+     *
+     * @return the start instant, never {@code null}
+     */
+    @Nonnull
+    Instant startTime();
+
+    /**
+     * How long this mojo execution took.
+     *
+     * @return the duration, never {@code null}
+     */
+    @Nonnull
+    Duration duration();
+
+    /**
+     * Structured log events captured during this mojo's execution.
+     * <p>
+     * The list may be truncated if the mojo produced excessive output.
+     * <p>
+     * This captures all SLF4J output that occurred on the mojo's execution
+     * thread between the mojo's start and finish events, regardless of
+     * whether the mojo used the legacy {@code Mojo.getLog()}, the Maven 4
+     * injected {@code Log}, or plain SLF4J.
+     *
+     * @return the captured log events, never {@code null}; may be empty
+     * @since 4.1.0
+     */
+    @Nonnull
+    List<LogEvent> output();
+
+    /**
+     * The mojo identifier formatted as {@code "artifactId:version:goal"}.
+     * <p>
+     * This matches the format used by {@link FailureReport#mojo()}, allowing
+     * direct lookup via {@link ModuleReport#findMojo(String)}.
+     *
+     * @return the mojo identifier string, never {@code null}
+     */
+    @Nonnull
+    default String id() {
+        return artifactId() + ":" + version() + ":" + goal();
+    }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
index 8aee68ff90..dd7d0572dd 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
@@ -20,10 +20,15 @@
 /**
  * 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.
+ * The {@link org.apache.maven.api.build.report.BuildReport} is the root of a structured
+ * representation of a Maven build execution. It is persisted to
+ * {@code target/build-report.json} at the end of every build and can be consumed
+ * by tools, CI systems, IDEs, and LLM agents without re-running the build or
+ * parsing console output.
+ * <p>
+ * Build problems (warnings, errors) are represented as
+ * {@link org.apache.maven.api.services.BuilderProblem} instances and included
+ * in the report for downstream analysis.
  *
  * @since 4.1.0
  */
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java
new file mode 100644
index 0000000000..89970a2f0e
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java
@@ -0,0 +1,564 @@
+/*
+ * 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 javax.inject.Named;
+import javax.inject.Singleton;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.maven.api.MonotonicClock;
+import org.apache.maven.api.build.report.BuildReport;
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.api.build.report.FailureReport;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.ModuleReport;
+import org.apache.maven.api.build.report.MojoReport;
+import org.apache.maven.eventspy.AbstractEventSpy;
+import org.apache.maven.execution.BuildFailure;
+import org.apache.maven.execution.BuildSuccess;
+import org.apache.maven.execution.BuildSummary;
+import org.apache.maven.execution.ExecutionEvent;
+import org.apache.maven.execution.MavenExecutionResult;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.logging.ProjectBuildLogAppender;
+import org.apache.maven.plugin.MojoExecution;
+import org.apache.maven.project.MavenProject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Collects build lifecycle events and produces a structured {@link BuildReport}
+ * at the end of the session.
+ * <p>
+ * Registered as an {@link org.apache.maven.eventspy.EventSpy} via {@code @Named}/{@code @Singleton},
+ * following the same pattern as {@code DefaultPluginValidationManager}.
+ * <p>
+ * Thread-safe: concurrent module builds (with {@code -T}) each write to their
+ * own entry in a {@link ConcurrentHashMap}.
+ * <p>
+ * Log capture: registers a callback on {@link ProjectBuildLogAppender} to
+ * receive the already-formed {@link LogEvent} objects produced by the main
+ * logging pipeline. Uses thread-based tracking to associate events with
+ * the currently-executing mojo or module.
+ *
+ * @since 4.1.0
+ */
+@Singleton
+@Named
+public final class BuildReportCollector extends AbstractEventSpy {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(BuildReportCollector.class);
+
+    static final String REPORT_DIR = "build-reports";
+    static final String REPORT_LATEST = "build-report-latest.json";
+
+    private static final int MAX_STACKTRACE_LINES = 30;
+
+    /**
+     * Maximum number of log events captured per scope (mojo, module, or build).
+     * Beyond this, events are dropped and a truncation notice is appended.
+     */
+    static final int MAX_LOG_EVENTS_PER_SCOPE = 500;
+
+    // ---- Mutable state, populated during the build ----
+
+    /** Per-project mojo tracking: project key -> list of in-flight/completed mojos. */
+    private final Map<String, List<MojoTiming>> mojoTimings = new ConcurrentHashMap<>();
+
+    /** Per-project start instants for duration computation. */
+    private final Map<String, Instant> projectStartTimes = new ConcurrentHashMap<>();
+
+    /** Per-mojo start instants for duration computation. */
+    private final Map<String, Instant> mojoStartTimes = new ConcurrentHashMap<>();
+
+    /** Session-level state - set once on SessionStarted. */
+    private volatile MavenSession session;
+
+    // ---- Log capture state ----
+
+    /**
+     * Maps thread ID -> mojo key for the currently-executing mojo on that thread.
+     * Lifecycle events and mojo execution run on the same thread, so this is safe
+     * for parallel builds with {@code -T}.
+     */
+    private final Map<Long, String> currentMojoByThread = new ConcurrentHashMap<>();
+
+    /** Per-mojo log buffers: mojo key -> captured log events. */
+    private final Map<String, List<LogEvent>> mojoLogBuffers = new ConcurrentHashMap<>();
+
+    /**
+     * Maps thread ID -> project key for the currently-building project on that thread.
+     * Used to route log events that occur between mojo executions to the module-level buffer.
+     */
+    private final Map<Long, String> currentProjectByThread = new ConcurrentHashMap<>();
+
+    /** Per-module log buffers: project key -> events captured outside any mojo. */
+    private final Map<String, List<LogEvent>> moduleLogBuffers = new ConcurrentHashMap<>();
+
+    /** Build-level log buffer: events captured outside any module lifecycle. */
+    private final List<LogEvent> buildLogBuffer = Collections.synchronizedList(new ArrayList<>());
+
+    @Override
+    public void onEvent(Object event) {
+        if (event instanceof ExecutionEvent executionEvent) {
+            switch (executionEvent.getType()) {
+                case SessionStarted:
+                    onSessionStarted(executionEvent);
+                    break;
+                case SessionEnded:
+                    onSessionEnded(executionEvent);
+                    break;
+                case ProjectStarted:
+                    onProjectStarted(executionEvent);
+                    break;
+                case ProjectSucceeded:
+                case ProjectFailed:
+                case ProjectSkipped:
+                    onProjectFinished(executionEvent);
+                    break;
+                case MojoStarted:
+                    onMojoStarted(executionEvent);
+                    break;
+                case MojoSucceeded:
+                case MojoFailed:
+                    onMojoFinished(executionEvent);
+                    break;
+                default:
+                    break;
+            }
+        }
+    }
+
+    // ---- Event handlers ----
+
+    private void onSessionStarted(ExecutionEvent event) {
+        this.session = event.getSession();
+        installLogCapture();
+    }
+
+    private void onSessionEnded(ExecutionEvent event) {
+        removeLogCapture();
+
+        MavenSession endSession = event.getSession();
+        if (endSession == null) {
+            return;
+        }
+
+        try {
+            BuildReport report = buildReport(endSession);
+            writeReport(report, endSession);
+        } catch (Exception e) {
+            // Never let the report collector crash the build
+            LOGGER.debug("Failed to produce build report: {}", e.getMessage(), e);
+        }
+    }
+
+    private void onProjectStarted(ExecutionEvent event) {
+        String key = projectKey(event.getProject());
+        projectStartTimes.put(key, MonotonicClock.now());
+        mojoTimings.putIfAbsent(key, Collections.synchronizedList(new ArrayList<>()));
+        moduleLogBuffers.put(key, Collections.synchronizedList(new ArrayList<>()));
+        currentProjectByThread.put(Thread.currentThread().getId(), key);
+    }
+
+    private void onProjectFinished(ExecutionEvent event) {
+        // Unregister the project from this thread so subsequent log events
+        // fall through to the build-level buffer
+        currentProjectByThread.remove(Thread.currentThread().getId());
+    }
+
+    private void onMojoStarted(ExecutionEvent event) {
+        String mKey = mojoKey(event.getProject(), event.getMojoExecution());
+        mojoStartTimes.put(mKey, MonotonicClock.now());
+
+        // Register the current mojo for this thread so the log event sink
+        // can associate events with this mojo execution
+        currentMojoByThread.put(Thread.currentThread().getId(), mKey);
+        mojoLogBuffers.put(mKey, Collections.synchronizedList(new ArrayList<>()));
+    }
+
+    private void onMojoFinished(ExecutionEvent event) {
+        MojoExecution mojo = event.getMojoExecution();
+        MavenProject project = event.getProject();
+        String mKey = mojoKey(project, mojo);
+        String pKey = projectKey(project);
+
+        // Unregister the mojo from this thread
+        currentMojoByThread.remove(Thread.currentThread().getId());
+
+        Instant now = MonotonicClock.now();
+        Instant startInstant = mojoStartTimes.remove(mKey);
+        if (startInstant == null) {
+            startInstant = now;
+        }
+        Duration duration = Duration.between(startInstant, now);
+
+        BuildStatus status =
+                event.getType() == ExecutionEvent.Type.MojoSucceeded ? BuildStatus.SUCCESS : BuildStatus.FAILURE;
+
+        // Drain the log buffer for this mojo
+        List<LogEvent> logBuffer = mojoLogBuffers.remove(mKey);
+        List<LogEvent> output = logBuffer != null ? List.copyOf(logBuffer) : List.of();
+
+        MojoTiming timing = new MojoTiming(
+                mojo.getGroupId(),
+                mojo.getArtifactId(),
+                mojo.getVersion(),
+                mojo.getGoal(),
+                mojo.getExecutionId(),
+                mojo.getLifecyclePhase(),
+                status,
+                startInstant,
+                duration,
+                output);
+
+        mojoTimings
+                .computeIfAbsent(pKey, k -> Collections.synchronizedList(new ArrayList<>()))
+                .add(timing);
+    }
+
+    // ---- Structured log capture ----
+
+    /**
+     * Registers a callback on {@link ProjectBuildLogAppender} to receive the
+     * already-formed {@link LogEvent} objects from the main logging pipeline.
+     * This eliminates the need for a separate capture path and ensures the
+     * build report captures the same enriched events (with sequence number,
+     * source metadata) as the console output.
+     */
+    private void installLogCapture() {
+        ProjectBuildLogAppender.setReportCapture(this::captureLogEvent);
+    }
+
+    private void removeLogCapture() {
+        ProjectBuildLogAppender.setReportCapture(null);
+    }
+
+    /**
+     * Routes a pre-formed {@link LogEvent} to the appropriate buffer
+     * (mojo, module, or build-level) based on the current thread's
+     * lifecycle context.
+     */
+    private void captureLogEvent(LogEvent event) {
+        long threadId = Thread.currentThread().getId();
+
+        // 1. Mojo-level: event belongs to the currently-executing mojo on this thread
+        String mKey = currentMojoByThread.get(threadId);
+        if (mKey != null) {
+            List<LogEvent> buffer = mojoLogBuffers.get(mKey);
+            if (buffer != null && buffer.size() < MAX_LOG_EVENTS_PER_SCOPE) {
+                buffer.add(event);
+            }
+            return;
+        }
+
+        // 2. Module-level: project is active but no mojo is running
+        String pKey = currentProjectByThread.get(threadId);
+        if (pKey != null) {
+            List<LogEvent> buffer = moduleLogBuffers.get(pKey);
+            if (buffer != null && buffer.size() < MAX_LOG_EVENTS_PER_SCOPE) {
+                buffer.add(event);
+            }
+            return;
+        }
+
+        // 3. Build-level: no project active (startup, reactor summary, post-build)
+        if (buildLogBuffer.size() < MAX_LOG_EVENTS_PER_SCOPE) {
+            buildLogBuffer.add(event);
+        }
+    }
+
+    // ---- Report assembly ----
+
+    BuildReport buildReport(MavenSession endSession) {
+        Instant now = MonotonicClock.now();
+        Instant startInstant = endSession.getRequest().getStartInstant();
+        if (startInstant == null) {
+            startInstant = now;
+        }
+        Duration totalDuration = Duration.between(startInstant, now);
+
+        MavenExecutionResult result = endSession.getResult();
+        boolean hasFailures = result != null && result.hasExceptions();
+        BuildStatus overallStatus = hasFailures ? BuildStatus.FAILURE : BuildStatus.SUCCESS;
+
+        // Collect module reports
+        List<ModuleReport> moduleReports = new ArrayList<>();
+        for (MavenProject project : endSession.getProjects()) {
+            moduleReports.add(buildModuleReport(project, endSession));
+        }
+
+        // Collect failures
+        List<FailureReport> failureReports = new ArrayList<>();
+        if (result != null) {
+            for (MavenProject project : endSession.getProjects()) {
+                BuildSummary summary = result.getBuildSummary(project);
+                if (summary instanceof BuildFailure buildFailure) {
+                    failureReports.add(buildFailureReport(project, buildFailure));
+                }
+            }
+        }
+
+        // Metadata
+        String mavenVersion = endSession.getSystemProperties().getProperty("maven.version", "unknown");
+        String javaVersion = System.getProperty("java.version", "unknown");
+        List<String> goals = endSession.getGoals();
+        MavenProject topProject = endSession.getTopLevelProject();
+        String projectId = topProject != null
+                ? topProject.getGroupId() + ":" + topProject.getArtifactId() + ":" + topProject.getVersion()
+                : "unknown";
+        boolean multiModule = endSession.getProjects().size() > 1;
+        int threads = endSession.getRequest().getDegreeOfConcurrency();
+
+        // Build-level log events (outside any module lifecycle)
+        List<LogEvent> buildOutput = List.copyOf(buildLogBuffer);
+
+        return new DefaultBuildReport(
+                overallStatus,
+                totalDuration,
+                startInstant,
+                mavenVersion,
+                javaVersion,
+                goals,
+                projectId,
+                multiModule,
+                threads,
+                moduleReports,
+                failureReports,
+                List.of(),
+                buildOutput);
+    }
+
+    private ModuleReport buildModuleReport(MavenProject project, MavenSession endSession) {
+        String key = projectKey(project);
+
+        // Duration from BuildSummary (preferred) or fallback to our own tracking
+        MavenExecutionResult result = endSession.getResult();
+        Duration duration = Duration.ZERO;
+        BuildStatus status = BuildStatus.SKIPPED;
+        Instant moduleStartTime =
+                projectStartTimes.getOrDefault(key, endSession.getRequest().getStartInstant());
+
+        if (result != null) {
+            BuildSummary summary = result.getBuildSummary(project);
+            if (summary instanceof BuildSuccess) {
+                status = BuildStatus.SUCCESS;
+                duration = summary.getExecTime();
+            } else if (summary instanceof BuildFailure) {
+                status = BuildStatus.FAILURE;
+                duration = summary.getExecTime();
+            } else if (summary != null) {
+                // Unknown summary type - use its timing
+                duration = summary.getExecTime();
+            } else {
+                // No summary means skipped
+                Instant start = projectStartTimes.get(key);
+                if (start != null) {
+                    duration = Duration.between(start, MonotonicClock.now());
+                }
+            }
+        }
+
+        // Mojo reports
+        List<MojoTiming> timings = mojoTimings.getOrDefault(key, Collections.emptyList());
+        List<MojoReport> mojoReports;
+        synchronized (timings) {
+            mojoReports = timings.stream()
+                    .map(t -> (MojoReport) new DefaultMojoReport(
+                            t.groupId,
+                            t.artifactId,
+                            t.version,
+                            t.goal,
+                            t.executionId,
+                            t.phase,
+                            t.status,
+                            t.startTime,
+                            t.duration,
+                            t.output))
+                    .toList();
+        }
+
+        // Module-level log events (between mojos)
+        List<LogEvent> moduleLogBuffer = moduleLogBuffers.getOrDefault(key, Collections.emptyList());
+        List<LogEvent> moduleOutput;
+        synchronized (moduleLogBuffer) {
+            moduleOutput = List.copyOf(moduleLogBuffer);
+        }
+
+        return new DefaultModuleReport(
+                project.getGroupId(),
+                project.getArtifactId(),
+                project.getVersion(),
+                status,
+                moduleStartTime,
+                duration,
+                mojoReports,
+                moduleOutput);
+    }
+
+    private FailureReport buildFailureReport(MavenProject project, BuildFailure buildFailure) {
+        String module = project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion();
+
+        // Try to find which mojo failed
+        String mojoId = null;
+        List<MojoTiming> timings = mojoTimings.getOrDefault(projectKey(project), Collections.emptyList());
+        synchronized (timings) {
+            for (MojoTiming t : timings) {
+                if (t.status == BuildStatus.FAILURE) {
+                    mojoId = t.artifactId + ":" + t.version + ":" + t.goal;
+                    break;
+                }
+            }
+        }
+
+        Throwable cause = buildFailure.getCause();
+        String message = cause != null ? cause.getMessage() : "Unknown error";
+        String stackTrace = cause != null ? truncateStackTrace(cause) : null;
+
+        Instant failureTimestamp = MonotonicClock.now();
+        String exceptionType = cause != null ? cause.getClass().getSimpleName() : null;
+
+        return new DefaultFailureReport(
+                module,
+                mojoId,
+                failureTimestamp,
+                exceptionType,
+                message != null ? message : "Unknown error",
+                stackTrace);
+    }
+
+    // ---- JSON persistence ----
+
+    void writeReport(BuildReport report, MavenSession endSession) {
+        Path topDirectory = endSession.getTopDirectory();
+        if (topDirectory == null) {
+            LOGGER.debug("No top directory available, skipping build report");
+            return;
+        }
+
+        Path reportsDir = topDirectory.resolve("target").resolve(REPORT_DIR);
+
+        try {
+            Files.createDirectories(reportsDir);
+            String json = BuildReportJsonWriter.toJson(report);
+
+            // Timestamped file: build-report-20250729T143000Z.json
+            String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
+                    .withZone(ZoneOffset.UTC)
+                    .format(report.startTime());
+            Path timestampedFile = reportsDir.resolve("build-report-" + timestamp + ".json");
+
+            // Write to a temp file, then atomic-move into place so a crash
+            // never leaves a half-written report on disk.
+            Path tmpFile = Files.createTempFile(reportsDir, ".build-report-", ".tmp");
+            try {
+                Files.writeString(tmpFile, json);
+                atomicMove(tmpFile, timestampedFile);
+            } catch (IOException e) {
+                Files.deleteIfExists(tmpFile);
+                throw e;
+            }
+
+            // Latest symlink (or copy on filesystems that don't support symlinks)
+            Path latestFile = reportsDir.resolve(REPORT_LATEST);
+            try {
+                // Atomic symlink swap: create new link, then rename over the old one
+                Path tmpLink = Files.createTempFile(reportsDir, ".latest-", ".tmp");
+                Files.delete(tmpLink); // createTempFile creates a regular file
+                Files.createSymbolicLink(tmpLink, timestampedFile.getFileName());
+                atomicMove(tmpLink, latestFile);
+            } catch (UnsupportedOperationException | IOException symEx) {
+                // Windows or restricted filesystem - fall back to a plain copy
+                Files.writeString(latestFile, json);
+            }
+
+            LOGGER.debug("Build report written to {}", timestampedFile);
+        } catch (IOException e) {
+            LOGGER.warn("Failed to write build report to {}: {}", reportsDir, e.getMessage());
+        }
+    }
+
+    /**
+     * Attempts an atomic move; falls back to a plain move if the filesystem
+     * does not support {@code ATOMIC_MOVE}.
+     */
+    private static void atomicMove(Path source, Path target) throws IOException {
+        try {
+            Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+        } catch (AtomicMoveNotSupportedException e) {
+            Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
+        }
+    }
+
+    // ---- Utility methods ----
+
+    private static String projectKey(MavenProject project) {
+        return project.getGroupId() + ":" + project.getArtifactId();
+    }
+
+    private static String mojoKey(MavenProject project, MojoExecution mojo) {
+        return projectKey(project) + "#" + mojo.getGoal() + "@" + mojo.getExecutionId();
+    }
+
+    static String truncateStackTrace(Throwable t) {
+        StringWriter sw = new StringWriter();
+        t.printStackTrace(new PrintWriter(sw));
+        String full = sw.toString();
+        String[] lines = full.split("\n");
+        if (lines.length <= MAX_STACKTRACE_LINES) {
+            return full;
+        }
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < MAX_STACKTRACE_LINES; i++) {
+            sb.append(lines[i]).append('\n');
+        }
+        sb.append("... ").append(lines.length - MAX_STACKTRACE_LINES).append(" more lines truncated\n");
+        return sb.toString();
+    }
+
+    // ---- Internal records ----
+
+    record MojoTiming(
+            String groupId,
+            String artifactId,
+            String version,
+            String goal,
+            String executionId,
+            String phase,
+            BuildStatus status,
+            Instant startTime,
+            Duration duration,
+            List<LogEvent> output) {}
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java
new file mode 100644
index 0000000000..2eeb7c744f
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java
@@ -0,0 +1,387 @@
+/*
+ * 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 org.apache.maven.api.build.report.BuildReport;
+import org.apache.maven.api.build.report.FailureReport;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.ModuleReport;
+import org.apache.maven.api.build.report.MojoReport;
+import org.apache.maven.api.services.BuilderProblem;
+
+/**
+ * Serializes a {@link BuildReport} to JSON without any external library dependency.
+ * <p>
+ * The output is human-readable (indented with 2 spaces) and designed to be
+ * stable across Maven versions — field order is fixed, and new fields are
+ * appended at the end of each object.
+ */
+final class BuildReportJsonWriter {
+
+    private BuildReportJsonWriter() {}
+
+    /**
+     * Serialize the given report to a pretty-printed JSON string.
+     */
+    static String toJson(BuildReport report) {
+        StringBuilder sb = new StringBuilder(4096);
+        writeReport(sb, report, 0);
+        sb.append('\n');
+        return sb.toString();
+    }
+
+    private static void writeReport(StringBuilder sb, BuildReport report, int indent) {
+        sb.append("{\n");
+        writeField(sb, indent + 1, "formatVersion", report.formatVersion());
+        writeField(sb, indent + 1, "status", report.status().name());
+        writeField(sb, indent + 1, "duration", report.duration().toString());
+        writeField(sb, indent + 1, "startTime", report.startTime().toString());
+        writeField(sb, indent + 1, "mavenVersion", report.mavenVersion());
+        writeField(sb, indent + 1, "javaVersion", report.javaVersion());
+        writeStringArray(sb, indent + 1, "goals", report.goals());
+        writeField(sb, indent + 1, "project", report.project());
+        writeField(sb, indent + 1, "multiModule", report.multiModule());
+        writeField(sb, indent + 1, "threads", report.threads());
+
+        // modules array
+        writeIndent(sb, indent + 1);
+        sb.append("\"modules\": ");
+        if (report.modules().isEmpty()) {
+            sb.append("[]");
+        } else {
+            sb.append("[\n");
+            for (int i = 0; i < report.modules().size(); i++) {
+                writeIndent(sb, indent + 2);
+                writeModule(sb, report.modules().get(i), indent + 2);
+                if (i < report.modules().size() - 1) {
+                    sb.append(',');
+                }
+                sb.append('\n');
+            }
+            writeIndent(sb, indent + 1);
+            sb.append(']');
+        }
+        sb.append(",\n");
+
+        // failures array
+        writeIndent(sb, indent + 1);
+        sb.append("\"failures\": ");
+        if (report.failures().isEmpty()) {
+            sb.append("[]");
+        } else {
+            sb.append("[\n");
+            for (int i = 0; i < report.failures().size(); i++) {
+                writeIndent(sb, indent + 2);
+                writeFailure(sb, report.failures().get(i), indent + 2);
+                if (i < report.failures().size() - 1) {
+                    sb.append(',');
+                }
+                sb.append('\n');
+            }
+            writeIndent(sb, indent + 1);
+            sb.append(']');
+        }
+        sb.append(",\n");
+
+        // problems array
+        writeIndent(sb, indent + 1);
+        sb.append("\"problems\": ");
+        if (report.problems().isEmpty()) {
+            sb.append("[]");
+        } else {
+            sb.append("[\n");
+            for (int i = 0; i < report.problems().size(); i++) {
+                writeIndent(sb, indent + 2);
+                writeProblem(sb, report.problems().get(i), indent + 2);
+                if (i < report.problems().size() - 1) {
+                    sb.append(',');
+                }
+                sb.append('\n');
+            }
+            writeIndent(sb, indent + 1);
+            sb.append(']');
+        }
+        sb.append(",\n");
+
+        // output array — build-level log lines (outside any module)
+        writeOutputArray(sb, indent + 1, report.output());
+        sb.append('\n');
+
+        writeIndent(sb, indent);
+        sb.append('}');
+    }
+
+    private static void writeProblem(StringBuilder sb, BuilderProblem problem, int indent) {
+        sb.append("{\n");
+        writeField(sb, indent + 1, "severity", problem.getSeverity().name());
+        writeField(sb, indent + 1, "message", problem.getMessage());
+        String source = problem.getSource();
+        if (source != null && !source.isEmpty()) {
+            writeField(sb, indent + 1, "source", source);
+        }
+        if (problem.getLineNumber() > 0) {
+            writeField(sb, indent + 1, "line", problem.getLineNumber());
+        }
+        if (problem.getColumnNumber() > 0) {
+            writeField(sb, indent + 1, "column", problem.getColumnNumber());
+        }
+        // Remove the trailing comma from the last written field
+        int lastComma = sb.lastIndexOf(",\n");
+        if (lastComma > 0) {
+            sb.replace(lastComma, lastComma + 1, "");
+        }
+        writeIndent(sb, indent);
+        sb.append('}');
+    }
+
+    private static void writeModule(StringBuilder sb, ModuleReport module, int indent) {
+        sb.append("{\n");
+        writeField(sb, indent + 1, "groupId", module.groupId());
+        writeField(sb, indent + 1, "artifactId", module.artifactId());
+        writeField(sb, indent + 1, "version", module.version());
+        writeField(sb, indent + 1, "status", module.status().name());
+        writeField(sb, indent + 1, "startTime", module.startTime().toString());
+        writeField(sb, indent + 1, "duration", module.duration().toString());
+
+        // mojos array
+        writeIndent(sb, indent + 1);
+        sb.append("\"mojos\": ");
+        if (module.mojos().isEmpty()) {
+            sb.append("[]");
+        } else {
+            sb.append("[\n");
+            for (int i = 0; i < module.mojos().size(); i++) {
+                writeIndent(sb, indent + 2);
+                writeMojo(sb, module.mojos().get(i), indent + 2);
+                if (i < module.mojos().size() - 1) {
+                    sb.append(',');
+                }
+                sb.append('\n');
+            }
+            writeIndent(sb, indent + 1);
+            sb.append(']');
+        }
+        sb.append(",\n");
+
+        // output array — module-level log lines (between mojos)
+        writeOutputArray(sb, indent + 1, module.output());
+        sb.append('\n');
+
+        writeIndent(sb, indent);
+        sb.append('}');
+    }
+
+    private static void writeMojo(StringBuilder sb, MojoReport mojo, int indent) {
+        sb.append("{\n");
+        writeField(sb, indent + 1, "groupId", mojo.groupId());
+        writeField(sb, indent + 1, "artifactId", mojo.artifactId());
+        writeField(sb, indent + 1, "version", mojo.version());
+        writeField(sb, indent + 1, "goal", mojo.goal());
+        writeNullableField(sb, indent + 1, "executionId", mojo.executionId(), true);
+        writeNullableField(sb, indent + 1, "phase", mojo.phase(), true);
+        writeField(sb, indent + 1, "status", mojo.status().name());
+        writeField(sb, indent + 1, "startTime", mojo.startTime().toString());
+        writeField(sb, indent + 1, "duration", mojo.duration().toString());
+
+        // output array — captured log lines
+        writeOutputArray(sb, indent + 1, mojo.output());
+        sb.append('\n');
+
+        writeIndent(sb, indent);
+        sb.append('}');
+    }
+
+    private static void writeFailure(StringBuilder sb, FailureReport failure, int indent) {
+        sb.append("{\n");
+        writeField(sb, indent + 1, "module", failure.module());
+        writeNullableField(sb, indent + 1, "mojo", failure.mojo(), true);
+        writeField(sb, indent + 1, "timestamp", failure.timestamp().toString());
+        writeNullableField(sb, indent + 1, "exceptionType", failure.exceptionType(), true);
+        if (failure.stackTrace() != null) {
+            writeField(sb, indent + 1, "message", failure.message());
+            writeLastField(sb, indent + 1, "stackTrace", failure.stackTrace());
+        } else {
+            writeLastField(sb, indent + 1, "message", failure.message());
+        }
+        writeIndent(sb, indent);
+        sb.append('}');
+    }
+
+    /**
+     * Writes an {@code "output": [...]} array of structured log events
+     * (used by report, module, and mojo).
+     * This is always the last field in its object, so no trailing comma.
+     */
+    private static void writeOutputArray(StringBuilder sb, int indent, java.util.List<LogEvent> events) {
+        writeIndent(sb, indent);
+        sb.append("\"output\": ");
+        if (events.isEmpty()) {
+            sb.append("[]");
+        } else {
+            sb.append("[\n");
+            for (int i = 0; i < events.size(); i++) {
+                writeIndent(sb, indent + 1);
+                writeLogEvent(sb, events.get(i), indent + 1);
+                if (i < events.size() - 1) {
+                    sb.append(',');
+                }
+                sb.append('\n');
+            }
+            writeIndent(sb, indent);
+            sb.append(']');
+        }
+    }
+
+    private static void writeLogEvent(StringBuilder sb, LogEvent event, int indent) {
+        sb.append("{\n");
+        writeField(sb, indent + 1, "timestamp", event.timestamp().toString());
+        writeField(sb, indent + 1, "level", event.level().name());
+        if (event.loggerName() != null) {
+            writeField(sb, indent + 1, "loggerName", event.loggerName());
+        }
+        writeField(sb, indent + 1, "message", event.message());
+        if (event.stackTrace() != null) {
+            writeField(sb, indent + 1, "stackTrace", event.stackTrace());
+        }
+        // Source metadata — present for Log API and JUL events
+        if (event.sourceClassName() != null) {
+            writeField(sb, indent + 1, "sourceClassName", event.sourceClassName());
+        }
+        if (event.sourceMethodName() != null) {
+            writeField(sb, indent + 1, "sourceMethodName", event.sourceMethodName());
+        }
+        if (event.threadId() >= 0) {
+            writeField(sb, indent + 1, "threadId", event.threadId());
+        }
+        if (event.sequenceNumber() >= 0) {
+            writeField(sb, indent + 1, "sequenceNumber", event.sequenceNumber());
+        }
+        removeTrailingComma(sb);
+        writeIndent(sb, indent);
+        sb.append('}');
+    }
+
+    // ---- Low-level JSON writing helpers ----
+
+    private static void writeField(StringBuilder sb, int indent, String key, String value) {
+        writeIndent(sb, indent);
+        sb.append('"').append(key).append("\": ");
+        writeJsonString(sb, value);
+        sb.append(",\n");
+    }
+
+    private static void writeField(StringBuilder sb, int indent, String key, int value) {
+        writeIndent(sb, indent);
+        sb.append('"').append(key).append("\": ").append(value).append(",\n");
+    }
+
+    private static void writeField(StringBuilder sb, int indent, String key, long value) {
+        writeIndent(sb, indent);
+        sb.append('"').append(key).append("\": ").append(value).append(",\n");
+    }
+
+    private static void writeField(StringBuilder sb, int indent, String key, boolean value) {
+        writeIndent(sb, indent);
+        sb.append('"').append(key).append("\": ").append(value).append(",\n");
+    }
+
+    /**
+     * Removes the trailing comma from the last field in a JSON object.
+     * Turns {@code "field": value,\n} into {@code "field": value\n}.
+     */
+    private static void removeTrailingComma(StringBuilder sb) {
+        int len = sb.length();
+        if (len >= 2 && sb.charAt(len - 2) == ',' && sb.charAt(len - 1) == '\n') {
+            sb.deleteCharAt(len - 2);
+        }
+    }
+
+    private static void writeLastField(StringBuilder sb, int indent, String key, String value) {
+        writeIndent(sb, indent);
+        sb.append('"').append(key).append("\": ");
+        writeJsonString(sb, value);
+        sb.append('\n');
+    }
+
+    private static void writeNullableField(
+            StringBuilder sb, int indent, String key, String value, @SuppressWarnings("unused") boolean hasMore) {
+        writeIndent(sb, indent);
+        sb.append('"').append(key).append("\": ");
+        if (value != null) {
+            writeJsonString(sb, value);
+        } else {
+            sb.append("null");
+        }
+        sb.append(",\n");
+    }
+
+    private static void writeStringArray(StringBuilder sb, int indent, String key, java.util.List<String> values) {
+        writeIndent(sb, indent);
+        sb.append('"').append(key).append("\": [");
+        for (int i = 0; i < values.size(); i++) {
+            writeJsonString(sb, values.get(i));
+            if (i < values.size() - 1) {
+                sb.append(", ");
+            }
+        }
+        sb.append("],\n");
+    }
+
+    private static void writeJsonString(StringBuilder sb, String value) {
+        sb.append('"');
+        for (int i = 0; i < value.length(); i++) {
+            char c = value.charAt(i);
+            switch (c) {
+                case '"':
+                    sb.append("\\\"");
+                    break;
+                case '\\':
+                    sb.append("\\\\");
+                    break;
+                case '\n':
+                    sb.append("\\n");
+                    break;
+                case '\r':
+                    sb.append("\\r");
+                    break;
+                case '\t':
+                    sb.append("\\t");
+                    break;
+                case '\b':
+                    sb.append("\\b");
+                    break;
+                case '\f':
+                    sb.append("\\f");
+                    break;
+                default:
+                    if (c < 0x20) {
+                        sb.append("\\u");
+                        sb.append(String.format("%04x", (int) c));
+                    } else {
+                        sb.append(c);
+                    }
+            }
+        }
+        sb.append('"');
+    }
+
+    private static void writeIndent(StringBuilder sb, int level) {
+        sb.append("  ".repeat(level));
+    }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java
new file mode 100644
index 0000000000..c0522681a5
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java
@@ -0,0 +1,82 @@
+/*
+ * 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.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.maven.api.build.report.BuildReport;
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.api.build.report.FailureReport;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.ModuleReport;
+import org.apache.maven.api.services.BuilderProblem;
+
+/**
+ * Internal immutable implementation of {@link BuildReport}.
+ */
+record DefaultBuildReport(
+        BuildStatus status,
+        Duration duration,
+        Instant startTime,
+        String mavenVersion,
+        String javaVersion,
+        List<String> goals,
+        String project,
+        boolean multiModule,
+        int threads,
+        List<ModuleReport> modules,
+        List<FailureReport> failures,
+        List<BuilderProblem> problems,
+        List<LogEvent> output)
+        implements BuildReport {
+
+    private static final int FORMAT_VERSION = 1;
+
+    @Override
+    public int formatVersion() {
+        return FORMAT_VERSION;
+    }
+
+    @Override
+    public List<ModuleReport> modules() {
+        return List.copyOf(modules);
+    }
+
+    @Override
+    public List<FailureReport> failures() {
+        return List.copyOf(failures);
+    }
+
+    @Override
+    public List<BuilderProblem> problems() {
+        return List.copyOf(problems);
+    }
+
+    @Override
+    public List<String> goals() {
+        return List.copyOf(goals);
+    }
+
+    @Override
+    public List<LogEvent> output() {
+        return List.copyOf(output);
+    }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java
similarity index 63%
copy from api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
copy to impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java
index 8aee68ff90..b0bc075d34 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java
@@ -16,18 +16,15 @@
  * 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.FailureReport;
 
 /**
- * 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
+ * Internal immutable implementation of {@link FailureReport}.
  */
-@Experimental
-package org.apache.maven.api.build.report;
-
-import org.apache.maven.api.annotations.Experimental;
+record DefaultFailureReport(
+        String module, String mojo, Instant timestamp, String exceptionType, String message, String stackTrace)
+        implements FailureReport {}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java
new file mode 100644
index 0000000000..64c79636eb
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java
@@ -0,0 +1,53 @@
+/*
+ * 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.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.ModuleReport;
+import org.apache.maven.api.build.report.MojoReport;
+
+/**
+ * Internal immutable implementation of {@link ModuleReport}.
+ */
+record DefaultModuleReport(
+        String groupId,
+        String artifactId,
+        String version,
+        BuildStatus status,
+        Instant startTime,
+        Duration duration,
+        List<MojoReport> mojos,
+        List<LogEvent> output)
+        implements ModuleReport {
+
+    @Override
+    public List<MojoReport> mojos() {
+        return List.copyOf(mojos);
+    }
+
+    @Override
+    public List<LogEvent> output() {
+        return List.copyOf(output);
+    }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java
similarity index 52%
copy from api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
copy to impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java
index 8aee68ff90..5270d9c11f 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java
@@ -16,18 +16,34 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+package org.apache.maven.internal.build;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.MojoReport;
 
 /**
- * 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
+ * Internal immutable implementation of {@link MojoReport}.
  */
-@Experimental
-package org.apache.maven.api.build.report;
+record DefaultMojoReport(
+        String groupId,
+        String artifactId,
+        String version,
+        String goal,
+        String executionId,
+        String phase,
+        BuildStatus status,
+        Instant startTime,
+        Duration duration,
+        List<LogEvent> output)
+        implements MojoReport {
 
-import org.apache.maven.api.annotations.Experimental;
+    @Override
+    public List<LogEvent> output() {
+        return List.copyOf(output);
+    }
+}
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 d3d03d73ed..581227c76d 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java
@@ -22,7 +22,6 @@
 import java.util.function.Supplier;
 
 import org.apache.maven.api.plugin.Log;
-import org.apache.maven.logging.ProjectBuildLogAppender;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -64,28 +63,22 @@ public DefaultLog(Logger logger) {
     }
 
     /**
-     * Wraps a logging call with Log API metadata: sets the ThreadLocal with
-     * source class name and thread ID, executes the actual SLF4J call, and
-     * clears the ThreadLocal.
+     * Wraps a logging call with Log API metadata: captures the caller's
+     * method name via {@link StackWalker}, sets the ThreadLocal, executes
+     * the actual SLF4J call, and clears the ThreadLocal.
      * <p>
      * The source class name is taken from the SLF4J logger name (which
      * is the mojo implementation FQCN, set at injection time).  The
-     * source method name is resolved via {@link StackWalker} only when
-     * build report capture is active (to avoid the ~1-5μs per-call cost
-     * on every enabled log statement during normal builds).
+     * source method name is resolved by walking the stack past this class
+     * to find the first external caller frame.
      */
     private void withMetadata(Runnable logAction) {
-        // Only pay the StackWalker cost when someone is actually capturing metadata
-        String callerMethodName = null;
-        if (ProjectBuildLogAppender.hasReportCapture()) {
-            callerMethodName = WALKER.walk(frames -> frames.dropWhile(f -> THIS_CLASS.equals(f.getClassName()))
-                    .findFirst()
-                    .map(StackFrame::getMethodName)
-                    .orElse(null));
-        }
-        @SuppressWarnings("deprecation") // Thread.getId() — threadId() requires Java 19+
-        long threadId = Thread.currentThread().getId();
-        LOG_API_METADATA.set(new LogApiMetadata(logger.getName(), callerMethodName, threadId));
+        String callerMethodName = WALKER.walk(frames -> frames.dropWhile(f -> THIS_CLASS.equals(f.getClassName()))
+                .findFirst()
+                .map(StackFrame::getMethodName)
+                .orElse(null));
+        LOG_API_METADATA.set(new LogApiMetadata(
+                logger.getName(), callerMethodName, Thread.currentThread().getId()));
         try {
             logAction.run();
         } finally {
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 700834eb80..62c387fb2f 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
@@ -129,15 +129,15 @@ public void mojoStarted(ExecutionEvent event) {
     @Override
     public void mojoSucceeded(ExecutionEvent event) {
         setMdc(event);
-        delegate.mojoSucceeded(event);
         ProjectBuildLogAppender.setMojoId(null);
+        delegate.mojoSucceeded(event);
     }
 
     @Override
     public void mojoFailed(ExecutionEvent event) {
         setMdc(event);
-        delegate.mojoFailed(event);
         ProjectBuildLogAppender.setMojoId(null);
+        delegate.mojoFailed(event);
     }
 
     @Override
@@ -152,22 +152,18 @@ 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
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 0bbd63ecc4..03485b8759 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java
@@ -49,7 +49,6 @@ public class ProjectBuildLogAppender implements AutoCloseable {
     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();
@@ -79,8 +78,9 @@ public static String getMojoId() {
 
     /**
      * 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.
+     * and the SLF4J MDC.  The value is available to any SLF4J appender via
+     * the MDC key {@code maven.mojo.id} and to JUL-bridged messages through
+     * the same MDC path.
      * <p>
      * Format: {@code "prefix:goal@executionId"}
      * (e.g. {@code "compiler:compile@default-compile"}).
@@ -92,15 +92,8 @@ public static void setMojoId(String mojoId) {
             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);
-            }
+            MOJO_ID.remove();
+            MDC.remove(KEY_MOJO_ID);
         }
     }
 
@@ -112,21 +105,6 @@ 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) {
@@ -162,17 +140,6 @@ public static void setReportCapture(Consumer<LogEvent> capture) {
         ProjectBuildLogAppender.reportCapture = capture;
     }
 
-    /**
-     * Returns {@code true} if a report capture callback is currently active.
-     * Used by {@link org.apache.maven.internal.impl.DefaultLog} to decide
-     * whether to pay the {@link StackWalker} cost for source method names.
-     *
-     * @return {@code true} if report capture is active
-     */
-    public static boolean hasReportCapture() {
-        return reportCapture != null;
-    }
-
     private final BuildEventListener buildEventListener;
 
     public ProjectBuildLogAppender(BuildEventListener buildEventListener) {
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java
new file mode 100644
index 0000000000..433c166e1e
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java
@@ -0,0 +1,313 @@
+/*
+ * 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.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Properties;
+
+import org.apache.maven.api.MonotonicClock;
+import org.apache.maven.api.build.report.BuildReport;
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.execution.BuildSuccess;
+import org.apache.maven.execution.DefaultMavenExecutionRequest;
+import org.apache.maven.execution.DefaultMavenExecutionResult;
+import org.apache.maven.execution.ExecutionEvent;
+import org.apache.maven.execution.MavenExecutionRequest;
+import org.apache.maven.execution.MavenExecutionResult;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.MojoExecution;
+import org.apache.maven.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.project.MavenProject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BuildReportCollectorTest {
+
+    @TempDir
+    Path tempDir;
+
+    private BuildReportCollector collector;
+
+    @BeforeEach
+    void setUp() {
+        collector = new BuildReportCollector();
+    }
+
+    @Test
+    void testBuildReportAssembly() {
+        MavenProject project = createProject("org.example", "my-app", "1.0.0");
+        MavenSession session = createSession(project);
+        MavenExecutionResult result = session.getResult();
+
+        // Simulate: session started -> project started -> mojo started -> mojo succeeded -> project succeeded ->
+        // session
+        // ended
+        collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null));
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null));
+
+        MojoExecution mojo = createMojoExecution(
+                "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile");
+        collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo));
+        collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, project, mojo));
+
+        // Record build success in the result
+        result.addBuildSummary(new BuildSuccess(project, 5000));
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null));
+
+        // Build the report
+        BuildReport report = collector.buildReport(session);
+
+        assertNotNull(report);
+        assertEquals(BuildStatus.SUCCESS, report.status());
+        assertEquals(1, report.formatVersion());
+        assertEquals("1.0.0", report.mavenVersion());
+        assertFalse(report.multiModule());
+        assertEquals(1, report.threads());
+        assertEquals(1, report.modules().size());
+        assertEquals("org.example", report.modules().get(0).groupId());
+        assertEquals("my-app", report.modules().get(0).artifactId());
+        assertEquals(BuildStatus.SUCCESS, report.modules().get(0).status());
+        assertEquals(1, report.modules().get(0).mojos().size());
+        assertEquals("compile", report.modules().get(0).mojos().get(0).goal());
+        assertEquals(BuildStatus.SUCCESS, report.modules().get(0).mojos().get(0).status());
+        assertTrue(report.failures().isEmpty());
+        assertTrue(report.problems().isEmpty());
+    }
+
+    @Test
+    void testBuildReportWithFailure() {
+        MavenProject project = createProject("org.example", "my-app", "1.0.0");
+        MavenSession session = createSession(project);
+        MavenExecutionResult result = session.getResult();
+
+        collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null));
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null));
+
+        MojoExecution mojo = createMojoExecution(
+                "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile");
+        collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo));
+        collector.onEvent(createEvent(ExecutionEvent.Type.MojoFailed, session, project, mojo));
+
+        RuntimeException failure = new RuntimeException("Compilation failure: 3 errors");
+        result.addBuildSummary(new org.apache.maven.execution.BuildFailure(project, 3000, failure));
+        result.addException(failure);
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectFailed, session, project, null));
+
+        BuildReport report = collector.buildReport(session);
+
+        assertEquals(BuildStatus.FAILURE, report.status());
+        assertEquals(1, report.failures().size());
+        assertEquals("org.example:my-app:1.0.0", report.failures().get(0).module());
+        assertTrue(report.failures().get(0).message().contains("Compilation failure"));
+        assertNotNull(report.failures().get(0).timestamp(), "failure should have a timestamp");
+        assertEquals("RuntimeException", report.failures().get(0).exceptionType(), "exceptionType from cause");
+
+        // Navigate from failure -> module -> mojo using lookup methods
+        var failureReport = report.failures().get(0);
+        var moduleOpt = report.findModule(failureReport);
+        assertTrue(moduleOpt.isPresent(), "findModule(FailureReport) should find the module");
+        assertEquals("my-app", moduleOpt.get().artifactId());
+        assertEquals("org.example:my-app:1.0.0", moduleOpt.get().id());
+
+        assertNotNull(failureReport.mojo(), "failure should reference a mojo");
+        var mojoOpt = moduleOpt.get().findMojo(failureReport.mojo());
+        assertTrue(mojoOpt.isPresent(), "findMojo should find the failed mojo");
+        assertEquals("compile", mojoOpt.get().goal());
+        assertEquals(BuildStatus.FAILURE, mojoOpt.get().status());
+        assertEquals("maven-compiler-plugin:3.15.0:compile", mojoOpt.get().id());
+    }
+
+    @Test
+    void testWriteReportToFile() throws IOException {
+        MavenProject project = createProject("org.example", "my-app", "1.0.0");
+        MavenSession session = createSession(project);
+        session.getResult().addBuildSummary(new BuildSuccess(project, 1000));
+
+        collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null));
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null));
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null));
+
+        BuildReport report = collector.buildReport(session);
+        collector.writeReport(report, session);
+
+        Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR);
+        Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST);
+        assertTrue(Files.exists(latestFile), "build-report-latest.json should exist");
+
+        String content = Files.readString(latestFile);
+        assertTrue(content.contains("\"formatVersion\": 1"));
+        assertTrue(content.contains("\"status\": \"SUCCESS\""));
+        assertTrue(content.contains("\"artifactId\": \"my-app\""));
+    }
+
+    @Test
+    void testMultiModuleBuild() {
+        MavenProject parent = createProject("org.example", "parent", "1.0.0");
+        MavenProject child1 = createProject("org.example", "child-api", "1.0.0");
+        MavenProject child2 = createProject("org.example", "child-impl", "1.0.0");
+
+        MavenSession session = createSession(parent, child1, child2);
+        MavenExecutionResult result = session.getResult();
+
+        collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, parent, null));
+
+        // Build each module
+        for (MavenProject p : List.of(parent, child1, child2)) {
+            collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, p, null));
+            result.addBuildSummary(new BuildSuccess(p, 1000));
+            collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, p, null));
+        }
+
+        BuildReport report = collector.buildReport(session);
+
+        assertEquals(BuildStatus.SUCCESS, report.status());
+        assertTrue(report.multiModule());
+        assertEquals(3, report.modules().size());
+        assertEquals("parent", report.modules().get(0).artifactId());
+        assertEquals("child-api", report.modules().get(1).artifactId());
+        assertEquals("child-impl", report.modules().get(2).artifactId());
+    }
+
+    @Test
+    void testStackTraceIsTruncated() {
+        // Build a throwable with a deep stack trace
+        RuntimeException deep = createDeepException(50);
+        String truncated = BuildReportCollector.truncateStackTrace(deep);
+
+        // Should contain the truncation notice
+        assertTrue(truncated.contains("more lines truncated"), "deep stack traces should be truncated");
+    }
+
+    @Test
+    void testShortStackTraceIsNotTruncated() {
+        RuntimeException shallow = new RuntimeException("short");
+        // Trim the stack to a known-small size so it's guaranteed under the limit
+        shallow.setStackTrace(
+                new StackTraceElement[] {new StackTraceElement("com.example.Foo", "bar", "Foo.java", 42)});
+        String result = BuildReportCollector.truncateStackTrace(shallow);
+
+        // Short stack traces should NOT contain the truncation notice
+        assertFalse(result.contains("more lines truncated"), "short stack traces should not be truncated");
+    }
+
+    // ---- Test helpers ----
+
+    private MavenProject createProject(String groupId, String artifactId, String version) {
+        MavenProject project = new MavenProject();
+        project.setGroupId(groupId);
+        project.setArtifactId(artifactId);
+        project.setVersion(version);
+        return project;
+    }
+
+    private MavenSession createSession(MavenProject... projects) {
+        MavenExecutionRequest request = new DefaultMavenExecutionRequest();
+        request.setStartInstant(MonotonicClock.now());
+        request.setGoals(List.of("clean", "install"));
+        request.setTopDirectory(tempDir);
+
+        Properties systemProperties = new Properties();
+        systemProperties.setProperty("maven.version", "1.0.0");
+        request.setSystemProperties(systemProperties);
+
+        MavenExecutionResult result = new DefaultMavenExecutionResult();
+
+        @SuppressWarnings("deprecation")
+        MavenSession session = new MavenSession(null, null, request, result);
+        session.setProjects(List.of(projects));
+        return session;
+    }
+
+    private MojoExecution createMojoExecution(
+            String groupId, String artifactId, String version, String goal, String executionId, String phase) {
+        @SuppressWarnings("deprecation")
+        PluginDescriptor pluginDescriptor = new PluginDescriptor();
+        pluginDescriptor.setGroupId(groupId);
+        pluginDescriptor.setArtifactId(artifactId);
+        pluginDescriptor.setVersion(version);
+
+        MojoDescriptor mojoDescriptor = new MojoDescriptor();
+        mojoDescriptor.setGoal(goal);
+        mojoDescriptor.setPluginDescriptor(pluginDescriptor);
+
+        MojoExecution execution = new MojoExecution(mojoDescriptor, executionId);
+        execution.setLifecyclePhase(phase);
+
+        return execution;
+    }
+
+    private ExecutionEvent createEvent(
+            ExecutionEvent.Type type, MavenSession session, MavenProject project, MojoExecution mojo) {
+        return new ExecutionEvent() {
+            @Override
+            public Type getType() {
+                return type;
+            }
+
+            @Override
+            public MavenSession getSession() {
+                return session;
+            }
+
+            @Override
+            public MavenProject getProject() {
+                return project;
+            }
+
+            @Override
+            public MojoExecution getMojoExecution() {
+                return mojo;
+            }
+
+            @Override
+            public Exception getException() {
+                return null;
+            }
+        };
+    }
+
+    /**
+     * Creates an exception with a stack trace of at least {@code depth} lines.
+     */
+    private static RuntimeException createDeepException(int depth) {
+        try {
+            throwDeep(depth);
+        } catch (RuntimeException e) {
+            return e;
+        }
+        throw new AssertionError("unreachable");
+    }
+
+    private static void throwDeep(int remaining) {
+        if (remaining <= 0) {
+            throw new RuntimeException("deep exception");
+        }
+        throwDeep(remaining - 1);
+    }
+}
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java
new file mode 100644
index 0000000000..cc48ed27d7
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java
@@ -0,0 +1,313 @@
+/*
+ * 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.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Properties;
+
+import org.apache.maven.api.MonotonicClock;
+import org.apache.maven.api.build.report.BuildReport;
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.execution.BuildSuccess;
+import org.apache.maven.execution.DefaultMavenExecutionRequest;
+import org.apache.maven.execution.DefaultMavenExecutionResult;
+import org.apache.maven.execution.ExecutionEvent;
+import org.apache.maven.execution.MavenExecutionRequest;
+import org.apache.maven.execution.MavenExecutionResult;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.MojoExecution;
+import org.apache.maven.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.project.MavenProject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration test that exercises the full build report pipeline:
+ * BuildReportCollector -> BuildReportJsonWriter -> file.
+ * <p>
+ * This test simulates a complete multi-module build lifecycle with mojo
+ * executions and failures, then verifies the resulting JSON report file
+ * contains all expected data.
+ */
+class BuildReportIntegrationTest {
+
+    @TempDir
+    Path tempDir;
+
+    private BuildReportCollector collector;
+
+    @BeforeEach
+    void setUp() {
+        collector = new BuildReportCollector();
+    }
+
+    /**
+     * Full lifecycle: multi-module build with mojos and JSON persistence.
+     */
+    @Test
+    void testFullMultiModuleBuild() throws IOException {
+        MavenProject parent = createProject("com.example", "parent", "2.0.0");
+        MavenProject api = createProject("com.example", "api", "2.0.0");
+        MavenProject impl = createProject("com.example", "impl", "2.0.0");
+
+        MavenSession session = createSession(parent, api, impl);
+        MavenExecutionResult result = session.getResult();
+
+        // Session starts
+        collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, parent, null));
+
+        // --- Module: parent ---
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, parent, null));
+        result.addBuildSummary(new BuildSuccess(parent, 500));
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, parent, null));
+
+        // --- Module: api ---
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, api, null));
+
+        MojoExecution compileApi = createMojoExecution(
+                "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile");
+        collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, api, compileApi));
+        collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, api, compileApi));
+
+        MojoExecution testApi = createMojoExecution(
+                "org.apache.maven.plugins", "maven-surefire-plugin", "3.5.0", "test", "default-test", "test");
+        collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, api, testApi));
+        collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, api, testApi));
+
+        result.addBuildSummary(new BuildSuccess(api, 3000));
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, api, null));
+
+        // --- Module: impl ---
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, impl, null));
+
+        MojoExecution compileImpl = createMojoExecution(
+                "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile");
+        collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, impl, compileImpl));
+        collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, impl, compileImpl));
+
+        result.addBuildSummary(new BuildSuccess(impl, 2000));
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, impl, null));
+
+        // --- Build report ---
+        BuildReport report = collector.buildReport(session);
+
+        // Basic assertions
+        assertNotNull(report);
+        assertEquals(BuildStatus.SUCCESS, report.status());
+        assertTrue(report.multiModule());
+        assertEquals(3, report.modules().size());
+
+        // Problems should always be empty in this simplified collector
+        assertTrue(report.problems().isEmpty());
+
+        // Module reports
+        assertEquals("parent", report.modules().get(0).artifactId());
+        assertEquals("api", report.modules().get(1).artifactId());
+        assertEquals(2, report.modules().get(1).mojos().size());
+        assertEquals("compile", report.modules().get(1).mojos().get(0).goal());
+        assertEquals("test", report.modules().get(1).mojos().get(1).goal());
+        assertEquals("impl", report.modules().get(2).artifactId());
+
+        // Write to JSON and verify
+        collector.writeReport(report, session);
+
+        Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR);
+        Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST);
+        assertTrue(Files.exists(latestFile), "build-report-latest.json should exist");
+
+        String json = Files.readString(latestFile);
+
+        // Verify JSON structure
+        assertTrue(json.contains("\"formatVersion\": 1"));
+        assertTrue(json.contains("\"status\": \"SUCCESS\""));
+        assertTrue(json.contains("\"multiModule\": true"));
+        assertTrue(json.contains("\"threads\": 1"));
+
+        // Modules in JSON
+        assertTrue(json.contains("\"artifactId\": \"parent\""));
+        assertTrue(json.contains("\"artifactId\": \"api\""));
+        assertTrue(json.contains("\"artifactId\": \"impl\""));
+
+        // Mojos in JSON
+        assertTrue(json.contains("\"goal\": \"compile\""));
+        assertTrue(json.contains("\"goal\": \"test\""));
+
+        // Problems and failures should be empty
+        assertTrue(json.contains("\"problems\": []"));
+        assertTrue(json.contains("\"failures\": []"));
+
+        // Output arrays should be present (even if empty in unit test -- no SLF4J sink)
+        assertTrue(json.contains("\"output\": ["));
+    }
+
+    /**
+     * Tests navigation methods on the built report.
+     */
+    @Test
+    void testReportNavigationMethods() {
+        MavenProject project = createProject("org.example", "my-app", "1.0.0");
+        MavenSession session = createSession(project);
+        MavenExecutionResult result = session.getResult();
+
+        collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null));
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null));
+
+        MojoExecution mojo = createMojoExecution(
+                "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile");
+        collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo));
+        collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, project, mojo));
+
+        result.addBuildSummary(new BuildSuccess(project, 5000));
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null));
+
+        BuildReport report = collector.buildReport(session);
+
+        // findModule by GAV
+        var moduleOpt = report.findModule("org.example:my-app:1.0.0");
+        assertTrue(moduleOpt.isPresent());
+        assertEquals("my-app", moduleOpt.get().artifactId());
+        assertEquals("org.example:my-app:1.0.0", moduleOpt.get().id());
+
+        // findMojo by id
+        var mojoOpt = moduleOpt.get().findMojo("maven-compiler-plugin:3.15.0:compile");
+        assertTrue(mojoOpt.isPresent());
+        assertEquals("compile", mojoOpt.get().goal());
+
+        // Not found
+        assertFalse(report.findModule("nonexistent:module:1.0").isPresent());
+    }
+
+    /**
+     * Tests that timestamped report files are written alongside the latest symlink.
+     */
+    @Test
+    void testTimestampedReportFile() throws IOException {
+        MavenProject project = createProject("org.example", "my-app", "1.0.0");
+        MavenSession session = createSession(project);
+        session.getResult().addBuildSummary(new BuildSuccess(project, 1000));
+
+        collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null));
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null));
+        collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null));
+
+        BuildReport report = collector.buildReport(session);
+        collector.writeReport(report, session);
+
+        Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR);
+        assertTrue(Files.exists(reportsDir), "reports directory should exist");
+
+        // Should have at least two files: the timestamped one and the latest link/copy
+        long fileCount = Files.list(reportsDir)
+                .filter(p -> p.getFileName().toString().startsWith("build-report-"))
+                .count();
+        assertTrue(fileCount >= 2, "should have both timestamped and latest report files, found " + fileCount);
+
+        // The latest file should contain valid JSON
+        Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST);
+        String json = Files.readString(latestFile);
+        assertTrue(json.startsWith("{"), "report should start with JSON object");
+        assertTrue(json.contains("\"formatVersion\": 1"), "report should contain format version");
+    }
+
+    // ---- Test helpers ----
+
+    private MavenProject createProject(String groupId, String artifactId, String version) {
+        MavenProject project = new MavenProject();
+        project.setGroupId(groupId);
+        project.setArtifactId(artifactId);
+        project.setVersion(version);
+        return project;
+    }
+
+    private MavenSession createSession(MavenProject... projects) {
+        MavenExecutionRequest request = new DefaultMavenExecutionRequest();
+        request.setStartInstant(MonotonicClock.now());
+        request.setGoals(List.of("clean", "install"));
+        request.setTopDirectory(tempDir);
+
+        Properties systemProperties = new Properties();
+        systemProperties.setProperty("maven.version", "4.1.0-SNAPSHOT");
+        request.setSystemProperties(systemProperties);
+
+        MavenExecutionResult result = new DefaultMavenExecutionResult();
+
+        @SuppressWarnings("deprecation")
+        MavenSession session = new MavenSession(null, null, request, result);
+        session.setProjects(List.of(projects));
+        return session;
+    }
+
+    private MojoExecution createMojoExecution(
+            String groupId, String artifactId, String version, String goal, String executionId, String phase) {
+        @SuppressWarnings("deprecation")
+        PluginDescriptor pluginDescriptor = new PluginDescriptor();
+        pluginDescriptor.setGroupId(groupId);
+        pluginDescriptor.setArtifactId(artifactId);
+        pluginDescriptor.setVersion(version);
+
+        MojoDescriptor mojoDescriptor = new MojoDescriptor();
+        mojoDescriptor.setGoal(goal);
+        mojoDescriptor.setPluginDescriptor(pluginDescriptor);
+
+        MojoExecution execution = new MojoExecution(mojoDescriptor, executionId);
+        execution.setLifecyclePhase(phase);
+
+        return execution;
+    }
+
+    private ExecutionEvent createEvent(
+            ExecutionEvent.Type type, MavenSession session, MavenProject project, MojoExecution mojo) {
+        return new ExecutionEvent() {
+            @Override
+            public Type getType() {
+                return type;
+            }
+
+            @Override
+            public MavenSession getSession() {
+                return session;
+            }
+
+            @Override
+            public MavenProject getProject() {
+                return project;
+            }
+
+            @Override
+            public MojoExecution getMojoExecution() {
+                return mojo;
+            }
+
+            @Override
+            public Exception getException() {
+                return null;
+            }
+        };
+    }
+}
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java
new file mode 100644
index 0000000000..119b191948
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java
@@ -0,0 +1,423 @@
+/*
+ * 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.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.maven.api.build.report.BuildReport;
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.api.build.report.FailureReport;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.LogLevel;
+import org.apache.maven.api.build.report.ModuleReport;
+import org.apache.maven.api.build.report.MojoReport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BuildReportJsonWriterTest {
+
+    private static final Instant BASE_TIME = Instant.parse("2025-01-15T10:30:00Z");
+
+    @Test
+    void testSuccessfulBuildReport() {
+        List<LogEvent> mojoOutput = List.of(
+                new DefaultLogEvent(
+                        BASE_TIME.plusSeconds(6), LogLevel.INFO, "Compiling 42 source files", "o.a.m.compiler", null),
+                new DefaultLogEvent(BASE_TIME.plusSeconds(7), LogLevel.INFO, "BUILD SUCCESS", "o.a.m.compiler", null));
+
+        MojoReport mojo = new DefaultMojoReport(
+                "org.apache.maven.plugins",
+                "maven-compiler-plugin",
+                "3.15.0",
+                "compile",
+                "default-compile",
+                "compile",
+                BuildStatus.SUCCESS,
+                BASE_TIME.plusSeconds(5),
+                Duration.ofMillis(2100),
+                mojoOutput);
+
+        List<LogEvent> moduleOutput = List.of(new DefaultLogEvent(
+                BASE_TIME.plusSeconds(2),
+                LogLevel.INFO,
+                "Resolving dependencies for maven-core",
+                "o.a.m.resolver",
+                null));
+
+        ModuleReport module = new DefaultModuleReport(
+                "org.apache.maven",
+                "maven-core",
+                "4.1.0-SNAPSHOT",
+                BuildStatus.SUCCESS,
+                BASE_TIME.plusSeconds(1),
+                Duration.ofMillis(12345),
+                List.of(mojo),
+                moduleOutput);
+
+        List<LogEvent> buildOutput = List.of(
+                new DefaultLogEvent(BASE_TIME, LogLevel.INFO, "Reactor Build Order:", "o.a.m.reactor", null),
+                new DefaultLogEvent(BASE_TIME, LogLevel.INFO, "Maven Core", "o.a.m.reactor", null));
+
+        BuildReport report = new DefaultBuildReport(
+                BuildStatus.SUCCESS,
+                Duration.ofMillis(30000),
+                BASE_TIME,
+                "4.1.0-SNAPSHOT",
+                "21.0.1",
+                List.of("clean", "install"),
+                "org.apache.maven:maven:4.1.0-SNAPSHOT",
+                true,
+                4,
+                List.of(module),
+                List.of(),
+                List.of(),
+                buildOutput);
+
+        String json = BuildReportJsonWriter.toJson(report);
+
+        assertTrue(json.contains("\"formatVersion\": 1"));
+        assertTrue(json.contains("\"status\": \"SUCCESS\""));
+        assertTrue(json.contains("\"mavenVersion\": \"4.1.0-SNAPSHOT\""));
+        assertTrue(json.contains("\"javaVersion\": \"21.0.1\""));
+        assertTrue(json.contains("\"goals\": [\"clean\", \"install\"]"));
+        assertTrue(json.contains("\"multiModule\": true"));
+        assertTrue(json.contains("\"threads\": 4"));
+        assertTrue(json.contains("\"groupId\": \"org.apache.maven\""));
+        assertTrue(json.contains("\"artifactId\": \"maven-core\""));
+        assertTrue(json.contains("\"goal\": \"compile\""));
+        assertTrue(json.contains("\"executionId\": \"default-compile\""));
+        assertTrue(json.contains("\"failures\": []"));
+        // Module and mojo start times
+        assertTrue(json.contains("\"startTime\": \"2025-01-15T10:30:01Z\""), "module startTime");
+        assertTrue(json.contains("\"startTime\": \"2025-01-15T10:30:05Z\""), "mojo startTime");
+        // Structured log events at all three levels
+        assertTrue(json.contains("\"message\": \"Compiling 42 source files\""), "mojo-level log event");
+        assertTrue(json.contains("\"message\": \"Resolving dependencies for maven-core\""), "module-level log event");
+        assertTrue(json.contains("\"message\": \"Reactor Build Order:\""), "build-level log event");
+        // Log event structure
+        assertTrue(json.contains("\"level\": \"INFO\""), "log level");
+        assertTrue(json.contains("\"loggerName\": \"o.a.m.compiler\""), "logger name");
+    }
+
+    @Test
+    void testFailedBuildReport() {
+        FailureReport failure = new DefaultFailureReport(
+                "org.apache.maven:maven-core:4.1.0-SNAPSHOT",
+                "maven-compiler-plugin:3.15.0:compile",
+                BASE_TIME.plusSeconds(3),
+                "CompilationFailureException",
+                "Compilation failure: 3 errors",
+                "org.apache.maven.plugin.compiler.CompilationFailureException: ...\n\tat ...\n");
+
+        BuildReport report = new DefaultBuildReport(
+                BuildStatus.FAILURE,
+                Duration.ofMillis(5000),
+                BASE_TIME,
+                "4.1.0-SNAPSHOT",
+                "21.0.1",
+                List.of("compile"),
+                "org.apache.maven:maven-core:4.1.0-SNAPSHOT",
+                false,
+                1,
+                List.of(),
+                List.of(failure),
+                List.of(),
+                List.of());
+
+        String json = BuildReportJsonWriter.toJson(report);
+
+        assertTrue(json.contains("\"status\": \"FAILURE\""));
+        assertTrue(json.contains("\"module\": \"org.apache.maven:maven-core:4.1.0-SNAPSHOT\""));
+        assertTrue(json.contains("\"mojo\": \"maven-compiler-plugin:3.15.0:compile\""));
+        assertTrue(json.contains("\"message\": \"Compilation failure: 3 errors\""));
+        assertTrue(json.contains("\"stackTrace\""));
+        // Enriched fields
+        assertTrue(json.contains("\"timestamp\": \"2025-01-15T10:30:03Z\""), "failure timestamp");
+        assertTrue(json.contains("\"exceptionType\": \"CompilationFailureException\""), "failure exceptionType");
+    }
+
+    @Test
+    void testJsonStringEscaping() {
+        FailureReport failure = new DefaultFailureReport(
+                "com.example:test:1.0",
+                null,
+                BASE_TIME,
+                null,
+                "Error: \"unexpected\" value\nwith newline\tand tab",
+                null);
+
+        BuildReport report = new DefaultBuildReport(
+                BuildStatus.FAILURE,
+                Duration.ofMillis(100),
+                BASE_TIME,
+                "4.1.0",
+                "21",
+                List.of(),
+                "com.example:test:1.0",
+                false,
+                1,
+                List.of(),
+                List.of(failure),
+                List.of(),
+                List.of());
+
+        String json = BuildReportJsonWriter.toJson(report);
+
+        // Check proper JSON escaping
+        assertTrue(json.contains("\\\"unexpected\\\""));
+        assertTrue(json.contains("\\n"));
+        assertTrue(json.contains("\\t"));
+    }
+
+    @Test
+    void testEmptyModulesAndFailures() {
+        BuildReport report = new DefaultBuildReport(
+                BuildStatus.SUCCESS,
+                Duration.ofMillis(100),
+                BASE_TIME,
+                "4.1.0",
+                "21",
+                List.of(),
+                "com.example:test:1.0",
+                false,
+                1,
+                List.of(),
+                List.of(),
+                List.of(),
+                List.of());
+
+        String json = BuildReportJsonWriter.toJson(report);
+
+        assertTrue(json.contains("\"modules\": []"));
+        assertTrue(json.contains("\"failures\": []"));
+    }
+
+    @Test
+    void testFormatVersion() {
+        BuildReport report = new DefaultBuildReport(
+                BuildStatus.SUCCESS,
+                Duration.ZERO,
+                BASE_TIME,
+                "4.1.0",
+                "21",
+                List.of(),
+                "test:test:1.0",
+                false,
+                1,
+                List.of(),
+                List.of(),
+                List.of(),
+                List.of());
+
+        assertEquals(1, report.formatVersion());
+    }
+
+    @Test
+    void testMultipleModules() {
+        ModuleReport mod1 = new DefaultModuleReport(
+                "com.example",
+                "api",
+                "1.0",
+                BuildStatus.SUCCESS,
+                BASE_TIME.plusSeconds(1),
+                Duration.ofSeconds(5),
+                List.of(),
+                List.of());
+        ModuleReport mod2 = new DefaultModuleReport(
+                "com.example",
+                "impl",
+                "1.0",
+                BuildStatus.SUCCESS,
+                BASE_TIME.plusSeconds(6),
+                Duration.ofSeconds(10),
+                List.of(),
+                List.of());
+        ModuleReport mod3 = new DefaultModuleReport(
+                "com.example",
+                "web",
+                "1.0",
+                BuildStatus.SKIPPED,
+                BASE_TIME.plusSeconds(16),
+                Duration.ZERO,
+                List.of(),
+                List.of());
+
+        BuildReport report = new DefaultBuildReport(
+                BuildStatus.SUCCESS,
+                Duration.ofSeconds(15),
+                BASE_TIME,
+                "4.1.0",
+                "21",
+                List.of("install"),
+                "com.example:parent:1.0",
+                true,
+                1,
+                List.of(mod1, mod2, mod3),
+                List.of(),
+                List.of(),
+                List.of());
+
+        String json = BuildReportJsonWriter.toJson(report);
+
+        // All modules present
+        assertTrue(json.contains("\"artifactId\": \"api\""));
+        assertTrue(json.contains("\"artifactId\": \"impl\""));
+        assertTrue(json.contains("\"artifactId\": \"web\""));
+        assertTrue(json.contains("\"status\": \"SKIPPED\""));
+    }
+
+    @Test
+    void testNullMojoFields() {
+        // mojo with null executionId and phase (direct invocation)
+        MojoReport mojo = new DefaultMojoReport(
+                "org.apache.maven.plugins",
+                "maven-help-plugin",
+                "3.4.1",
+                "effective-pom",
+                null,
+                null,
+                BuildStatus.SUCCESS,
+                BASE_TIME.plusSeconds(1),
+                Duration.ofMillis(500),
+                List.of());
+
+        String json = BuildReportJsonWriter.toJson(new DefaultBuildReport(
+                BuildStatus.SUCCESS,
+                Duration.ofSeconds(1),
+                BASE_TIME,
+                "4.1.0",
+                "21",
+                List.of("help:effective-pom"),
+                "test:test:1.0",
+                false,
+                1,
+                List.of(new DefaultModuleReport(
+                        "test",
+                        "test",
+                        "1.0",
+                        BuildStatus.SUCCESS,
+                        BASE_TIME,
+                        Duration.ofSeconds(1),
+                        List.of(mojo),
+                        List.of())),
+                List.of(),
+                List.of(),
+                List.of()));
+
+        assertTrue(json.contains("\"executionId\": null"));
+        assertTrue(json.contains("\"phase\": null"));
+        assertFalse(json.contains("\"executionId\": \"null\""));
+    }
+
+    @Test
+    void testLogEventWithJulMetadata() {
+        // LogEvent with JUL source class, method, thread ID, and sequence number
+        LogEvent julEvent = new DefaultLogEvent(
+                BASE_TIME.plusSeconds(1),
+                LogLevel.WARN,
+                "Unsupported class file major version 65",
+                "org.apache.maven.plugins.compiler",
+                null,
+                null,
+                "com.sun.tools.javac.processing.JavacProcessingEnvironment",
+                "doProcessing",
+                42L,
+                1001L);
+
+        // LogEvent without JUL metadata (from SLF4J)
+        LogEvent slf4jEvent = new DefaultLogEvent(
+                BASE_TIME.plusSeconds(2), LogLevel.INFO, "Compiling 10 files", "o.a.m.compiler", null);
+
+        MojoReport mojo = new DefaultMojoReport(
+                "org.apache.maven.plugins",
+                "maven-compiler-plugin",
+                "3.15.0",
+                "compile",
+                "default-compile",
+                "compile",
+                BuildStatus.SUCCESS,
+                BASE_TIME,
+                Duration.ofMillis(1000),
+                List.of(julEvent, slf4jEvent));
+
+        BuildReport report = new DefaultBuildReport(
+                BuildStatus.SUCCESS,
+                Duration.ofSeconds(1),
+                BASE_TIME,
+                "4.1.0",
+                "21",
+                List.of("compile"),
+                "test:test:1.0",
+                false,
+                1,
+                List.of(new DefaultModuleReport(
+                        "test",
+                        "test",
+                        "1.0",
+                        BuildStatus.SUCCESS,
+                        BASE_TIME,
+                        Duration.ofSeconds(1),
+                        List.of(mojo),
+                        List.of())),
+                List.of(),
+                List.of(),
+                List.of());
+
+        String json = BuildReportJsonWriter.toJson(report);
+
+        // JUL event should have sourceClassName, sourceMethodName, threadId, and sequenceNumber
+        assertTrue(
+                json.contains("\"sourceClassName\": \"com.sun.tools.javac.processing.JavacProcessingEnvironment\""),
+                "sourceClassName");
+        assertTrue(json.contains("\"sourceMethodName\": \"doProcessing\""), "sourceMethodName");
+        assertTrue(json.contains("\"threadId\": 42"), "threadId");
+        assertTrue(json.contains("\"sequenceNumber\": 1001"), "sequenceNumber");
+        // SLF4J event should NOT have JUL fields
+        // (the second log event in the output array has no sourceClassName)
+        assertFalse(
+                json.contains("\"sourceClassName\": \"o.a.m.compiler\""),
+                "SLF4J event should not have sourceClassName");
+    }
+
+    @Test
+    void testEmptyProblems() {
+        BuildReport report = new DefaultBuildReport(
+                BuildStatus.SUCCESS,
+                Duration.ofMillis(100),
+                BASE_TIME,
+                "4.1.0",
+                "21",
+                List.of(),
+                "com.example:test:1.0",
+                false,
+                1,
+                List.of(),
+                List.of(),
+                List.of(),
+                List.of());
+
+        String json = BuildReportJsonWriter.toJson(report);
+        assertTrue(json.contains("\"problems\": []"), "empty problems array");
+    }
+}
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
index 138a52c3f0..9ed93bfc66 100644
--- 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
@@ -29,22 +29,27 @@
 import java.util.logging.LogRecord;
 import java.util.logging.Logger;
 
+import org.apache.maven.api.services.MessageBuilder;
 import org.slf4j.LoggerFactory;
 import org.slf4j.spi.LocationAwareLogger;
 
+import static org.apache.maven.jline.MessageUtils.builder;
+
 /**
  * A JUL {@link Handler} that routes {@code java.util.logging} events into
  * Maven's structured logging pipeline, preserving the rich {@link LogRecord}
  * metadata that the standard {@code SLF4JBridgeHandler} silently drops
  * (source class name, source method name, thread ID).
  * <p>
- * All JUL events are routed through SLF4J so that {@link MavenSimpleLogger}
- * produces a consistent {@code formattedMessage} (with timestamp, logger name,
- * and ANSI styling) regardless of the event's origin.  The JUL metadata is
- * stashed in a thread-local <em>before</em> the SLF4J call so that downstream
+ * When a {@link MavenSimpleLogger.LogSink LogSink} is installed (i.e. during
+ * a build), JUL events are sent directly to the sink — bypassing SLF4J
+ * entirely.  The JUL metadata is stashed in a thread-local so downstream
  * consumers (e.g. {@code ProjectBuildLogAppender}) can read it when
  * constructing a structured {@code LogEvent}.
  * <p>
+ * When no LogSink is installed (e.g. during early bootstrap), the handler
+ * falls back to routing through SLF4J for console output.
+ * <p>
  * Usage — replace the standard SLF4J bridge in {@code LookupInvoker}:
  * <pre>
  *     MavenJulHandler.install();
@@ -197,18 +202,25 @@ public void publish(LogRecord record) {
         String message = formatMessage(record);
         Throwable throwable = record.getThrown();
 
-        // Set the JUL metadata before routing through SLF4J so that
-        // downstream consumers (e.g. ProjectBuildLogAppender) can read
-        // it when constructing a structured LogEvent.  By always going
-        // through SLF4J, the formattedMessage is produced by
-        // MavenSimpleLogger (with proper timestamp, logger name, and
-        // ANSI styling) regardless of whether the event originated from
-        // JUL or SLF4J — fixing the format inconsistency.
+        // Set the re-entrancy guard before routing through SLF4J.  This prevents
+        // recursive JUL events fired during SLF4J/JLine processing (e.g. from
+        // MavenSimpleLogger.renderLevel() -> StyleResolver) from re-entering publish()
+        // on the same thread.  The primary fix is in MavenStyleResolver.resolve() which
+        // uses get+putIfAbsent instead of computeIfAbsent; this guard is defense-in-depth.
+        IN_PUBLISH.set(Boolean.TRUE);
+        // If a LogSink is installed, bypass SLF4J entirely: call the sink
+        // directly with the JUL metadata so no information is lost in transit.
+        MavenSimpleLogger.LogSink sink = MavenSimpleLogger.getLogSink();
         METADATA.set(
                 new JulMetadata(record.getSourceClassName(), record.getSourceMethodName(), record.getLongThreadID()));
-        IN_PUBLISH.set(Boolean.TRUE);
         try {
-            logToSlf4j(slf4jLogger, slf4jLevel, message, throwable);
+            if (sink != null) {
+                String formatted = formatForConsole(slf4jLevel, message);
+                sink.accept(slf4jLevel, loggerName, message, formatted, throwable);
+            } else {
+                // No LogSink — fall through to SLF4J for console output
+                logToSlf4j(slf4jLogger, slf4jLevel, message, throwable);
+            }
         } finally {
             IN_PUBLISH.remove();
             METADATA.remove();
@@ -259,6 +271,23 @@ private static String formatMessage(LogRecord record) {
         return message;
     }
 
+    /**
+     * Formats a JUL message for console output, matching the {@code [LEVEL] message}
+     * style used by MavenSimpleLogger with ANSI coloring when available.
+     */
+    private static String formatForConsole(int level, String message) {
+        MessageBuilder mb = builder();
+        String levelStr =
+                switch (level) {
+                    case LocationAwareLogger.TRACE_INT -> mb.trace("TRACE").build();
+                    case LocationAwareLogger.DEBUG_INT -> mb.debug("DEBUG").build();
+                    case LocationAwareLogger.INFO_INT -> mb.info("INFO").build();
+                    case LocationAwareLogger.WARN_INT -> mb.warning("WARNING").build();
+                    default -> mb.error("ERROR").build();
+                };
+        return "[" + levelStr + "] " + message;
+    }
+
     private static int julLevelToSlf4j(Level julLevel) {
         int value = julLevel.intValue();
         if (value <= Level.FINEST.intValue()) {
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 2060d5bb79..77fc8b1eb8 100644
--- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java
+++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java
@@ -104,14 +104,12 @@ protected String renderLevel(int level) {
     protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) {
         LogSink sink = logSink;
         if (sink != null) {
-            // Build the full formatted output including throwable rendering,
-            // reusing the existing writeThrowable/printStackTrace methods
-            // to keep a single rendering path for throwables.
+            // Build the full formatted output including throwable rendering
             String formatted = formattedBuf.toString();
             if (t != null) {
                 StringBuilder full = new StringBuilder(formatted);
                 full.append(System.lineSeparator());
-                writeThrowable(t, line -> full.append(line).append(System.lineSeparator()));
+                appendFormattedThrowable(full, t, "");
                 formatted = full.toString();
             }
             sink.accept(level, loggerName, cleanMessage, formatted, t);
@@ -120,6 +118,60 @@ protected void write(int level, String loggerName, String cleanMessage, StringBu
         }
     }
 
+    /**
+     * Append a colorized throwable rendering to the given builder.
+     * Reuses the existing formatting logic for consistency with console output.
+     */
+    private void appendFormattedThrowable(StringBuilder sb, Throwable t, String prefix) {
+        MessageBuilder builder = builder().a(prefix).failure(t.getClass().getName());
+        if (t.getMessage() != null) {
+            builder.a(": ").failure(t.getMessage());
+        }
+        sb.append(builder.toString()).append(System.lineSeparator());
+        appendStackTrace(sb, t, prefix);
+    }
+
+    private void appendStackTrace(StringBuilder sb, Throwable t, String prefix) {
+        MessageBuilder builder = builder();
+        for (StackTraceElement e : t.getStackTrace()) {
+            builder.a(prefix);
+            builder.a("    ");
+            builder.strong("at");
+            builder.a(" ");
+            builder.a(e.getClassName());
+            builder.a(".");
+            builder.a(e.getMethodName());
+            builder.a("(");
+            builder.strong(getLocation(e));
+            builder.a(")");
+            sb.append(builder.toString()).append(System.lineSeparator());
+            builder.setLength(0);
+        }
+        for (Throwable se : t.getSuppressed()) {
+            builder.a(prefix)
+                    .a("    ")
+                    .strong("Suppressed")
+                    .a(": ")
+                    .a(se.getClass().getName());
+            if (se.getMessage() != null) {
+                builder.a(": ").failure(se.getMessage());
+            }
+            sb.append(builder.toString()).append(System.lineSeparator());
+            builder.setLength(0);
+            appendStackTrace(sb, se, prefix + "    ");
+        }
+        Throwable cause = t.getCause();
+        if (cause != null && t != cause) {
+            builder.a(prefix).strong("Caused by").a(": ").a(cause.getClass().getName());
+            if (cause.getMessage() != null) {
+                builder.a(": ").failure(cause.getMessage());
+            }
+            sb.append(builder.toString()).append(System.lineSeparator());
+            builder.setLength(0);
+            appendStackTrace(sb, cause, prefix);
+        }
+    }
+
     @Override
     protected void writeThrowable(Throwable t, PrintStream stream) {
         writeThrowable(t, stream::println);
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
deleted file mode 100644
index 7ea78b4efe..0000000000
--- a/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java
+++ /dev/null
@@ -1,151 +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.slf4j;
-
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.util.logging.Level;
-
-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;
-import static org.junit.jupiter.api.Assertions.assertNull;
-
-/**
- * Tests for {@link MavenJulHandler}, focused on the JUL→SLF4J level
- * mapping and metadata lifecycle.
- */
-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 JUL metadata is null when no log event is being processed.
-     */
-    @Test
-    void julMetadataIsNullOutsidePublish() {
-        assertNull(MavenJulHandler.getJulMetadata(), "JUL metadata should be null outside a publish() call");
-    }
-
-    /**
-     * 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() throws Exception {
-        MavenJulHandler handler = new MavenJulHandler();
-        java.util.logging.LogRecord outerRecord = new java.util.logging.LogRecord(Level.INFO, "outer");
-
-        // Install a custom SLF4J logger that fires a second JUL event
-        // (simulating StyleResolver's internal JUL debug call) when its
-        // info() method is called.
-        java.util.logging.Logger julLogger =
-                java.util.logging.LogManager.getLogManager().getLogger("");
-        java.util.logging.Handler[] saved = julLogger.getHandlers();
-        for (java.util.logging.Handler h : saved) {
-            julLogger.removeHandler(h);
-        }
-
-        // The test verifies that publish() does not throw.
-        // We can't easily simulate the full SLF4J pipeline here, so we just
-        // call publish() with a null-logger-name record (which returns early
-        // before reaching SLF4J) after setting IN_PUBLISH to true, verifying
-        // the guard works.
-        java.lang.reflect.Field inPublishField = MavenJulHandler.class.getDeclaredField("IN_PUBLISH");
-        inPublishField.setAccessible(true);
-        @SuppressWarnings("unchecked")
-        ThreadLocal<Boolean> inPublish = (ThreadLocal<Boolean>) inPublishField.get(null);
-
-        // Simulate being inside publish()
-        inPublish.set(Boolean.TRUE);
-        try {
-            // A nested call should be dropped without throwing
-            handler.publish(outerRecord);
-            // If we reach here, the guard worked correctly
-        } finally {
-            inPublish.remove();
-            // Restore handlers
-            for (java.util.logging.Handler h : saved) {
-                julLogger.addHandler(h);
-            }
-        }
-    }
-
-    /**
-     * 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();
-        }
-    }
-}