+ * 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
+ * For per-module events see {@link ModuleReport#output()}, and for
+ * per-mojo events see {@link MojoReport#output()}.
+ *
+ * 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
+ * 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
+ * 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/LogEvent.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java
new file mode 100644
index 000000000000..e8575ded3d01
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java
@@ -0,0 +1,167 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api.build.report;
+
+import java.time.Instant;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+
+/**
+ * A structured log event captured during the build.
+ *
+ * Each event carries the log level, timestamp, message, and optionally
+ * the logger name and a stack trace. This replaces raw log line strings
+ * in the build report, enabling programmatic filtering by level and
+ * correlation by timestamp.
+ *
+ * Events originating from the Maven Log API or from JUL
+ * ({@code java.util.logging}) carry additional source metadata: the
+ * source class name, source method name, and thread identifier.
+ * For Log API events the source class name is the mojo implementation
+ * FQCN; for JUL events it comes from {@code LogRecord}. Events from
+ * direct SLF4J logging have these fields set to {@code null}.
+ *
+ * @since 4.1.0
+ */
+@Experimental
+public interface LogEvent {
+
+ /**
+ * When this log event was produced (wall-clock time).
+ *
+ * @return the event instant, never {@code null}
+ */
+ @Nonnull
+ Instant timestamp();
+
+ /**
+ * The severity level of this log event.
+ *
+ * @return the log level, never {@code null}
+ */
+ @Nonnull
+ LogLevel level();
+
+ /**
+ * The log message, without level prefix or timestamp formatting.
+ *
+ * @return the formatted message, never {@code null}
+ */
+ @Nonnull
+ String message();
+
+ /**
+ * The name of the logger that produced this event
+ * (e.g. {@code "org.apache.maven.plugins.compiler.CompilerMojo"}).
+ *
+ * @return the logger name, or {@code null} if unavailable
+ */
+ @Nullable
+ String loggerName();
+
+ /**
+ * The stack trace associated with this event, if an exception was logged.
+ *
+ * The trace is formatted as a multi-line string and may be truncated
+ * for very deep stack traces.
+ *
+ * @return the stack trace string, or {@code null} if no exception was logged
+ */
+ @Nullable
+ String stackTrace();
+
+ /**
+ * The fully formatted log line as rendered for console output, including
+ * the level prefix, timestamp, and any ANSI styling applied by the logger.
+ *
+ * This is the string that would be printed to the terminal in verbose mode.
+ * Console renderers that just need pass-through output can use this directly,
+ * while renderers that apply custom formatting (e.g. rich mode) can use the
+ * structured fields ({@link #level()}, {@link #message()}) instead.
+ *
+ * May be {@code null} if the event was created outside the SLF4J pipeline
+ * (e.g. in tests or by programmatic construction).
+ *
+ * @return the formatted log line, or {@code null}
+ */
+ @Nullable
+ String formattedMessage();
+
+ // ---- Source metadata (populated for Log API and JUL events) ----
+
+ /**
+ * The fully qualified class name of the source that issued the log call.
+ *
+ * For Maven Log API events this is the mojo implementation class name.
+ * For JUL events it is the value from {@code LogRecord.getSourceClassName()}.
+ * For direct SLF4J logging it is {@code null}.
+ *
+ * @return the source class name, or {@code null}
+ * @since 4.1.0
+ */
+ @Nullable
+ default String sourceClassName() {
+ return null;
+ }
+
+ /**
+ * The method name of the source that issued the log call.
+ *
+ * For Maven Log API events this is resolved via {@link StackWalker}.
+ * For JUL events it is the value from {@code LogRecord.getSourceMethodName()}.
+ * For direct SLF4J logging it is {@code null}.
+ *
+ * @return the source method name, or {@code null}
+ * @since 4.1.0
+ */
+ @Nullable
+ default String sourceMethodName() {
+ return null;
+ }
+
+ /**
+ * The thread identifier from which this log event originated.
+ *
+ * Populated for both Log API and JUL events. Returns {@code -1}
+ * if the thread ID is not available (i.e. for direct SLF4J events).
+ *
+ * @return the thread ID, or {@code -1} if unavailable
+ * @since 4.1.0
+ */
+ default long threadId() {
+ return -1;
+ }
+
+ /**
+ * A monotonically increasing sequence number for total ordering of
+ * log events, useful when multiple events share the same timestamp.
+ *
+ * Assigned by the logging pipeline when the event is captured,
+ * providing a global ordering across all event sources (Log API,
+ * JUL, and direct SLF4J).
+ *
+ * @return the sequence number, always non-negative
+ * @since 4.1.0
+ */
+ default long sequenceNumber() {
+ return -1;
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java
new file mode 100644
index 000000000000..684ea610a5bc
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java
@@ -0,0 +1,36 @@
+/*
+ * 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 org.apache.maven.api.annotations.Experimental;
+
+/**
+ * Log severity levels, mirroring the standard SLF4J levels.
+ *
+ * @since 4.1.0
+ * @see LogEvent#level()
+ */
+@Experimental
+public enum LogLevel {
+ TRACE,
+ DEBUG,
+ INFO,
+ WARN,
+ ERROR
+}
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 000000000000..7746545a4217
--- /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
+ * For per-mojo events see {@link MojoReport#output()}.
+ *
+ * @return the captured log events, never {@code null}; may be empty
+ */
+ @Nonnull
+ List
+ * 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.
+ *
+ * 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
+ * The list may be truncated if the mojo produced excessive output.
+ *
+ * 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
+ * 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
new file mode 100644
index 000000000000..dd7d0572dd40
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+
+/**
+ * Structured build report data model.
+ *
+ * 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.
+ *
+ * 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
+ */
+@Experimental
+package org.apache.maven.api.build.report;
+
+import org.apache.maven.api.annotations.Experimental;
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
index 23175d07b1f7..412aafb47bfb 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
@@ -41,6 +41,8 @@ public interface Log {
*
* The default implementation returns {@code false} for backward
* compatibility with existing {@code Log} implementations.
+ *
+ * @since 4.1.0
*/
default boolean isTraceEnabled() {
return false;
@@ -55,20 +57,22 @@ default boolean isTraceEnabled() {
* messages that help users investigate their build
* (for instance, why a module was recompiled).
*
- * The default implementation is a no-op for backward compatibility.
+ * The default implementation is a no-op for backward compatibility
+ * with existing {@code Log} implementations.
*
* @param content the message to log
+ * @since 4.1.0
*/
default void trace(CharSequence content) {}
/**
* Sends a message (and accompanying exception) to the user at the trace error level.
- * The error's stacktrace will be output when this error level is enabled.
*
* The default implementation is a no-op for backward compatibility.
*
* @param content the message to log
* @param error the error that caused this log
+ * @since 4.1.0
*/
default void trace(CharSequence content, Throwable error) {}
@@ -79,6 +83,7 @@ default void trace(CharSequence content, Throwable error) {}
* The default implementation is a no-op for backward compatibility.
*
* @param error the error that caused this log
+ * @since 4.1.0
*/
default void trace(Throwable error) {}
@@ -88,7 +93,7 @@ default void trace(Throwable error) {}
*
* The default implementation is a no-op for backward compatibility.
*
- * @param content the message supplier
+ * @since 4.1.0
*/
default void trace(Supplier
* The default implementation is a no-op for backward compatibility.
*
- * @param content the message supplier
- * @param error the error that caused this log
+ * @since 4.1.0
*/
default void trace(Supplier
- * Debug is intended for messages that help users investigate
- * their build — for example, why a module was recompiled or what
- * classpath was resolved. For Maven core internals, use
- * {@link #trace(CharSequence)} instead.
+ * Debug is the recommended level for diagnostic output that helps
+ * plugin users troubleshoot build problems (e.g. resolved paths,
+ * computed values). For Maven core internals, prefer {@link #trace}.
*
* @param content the message to log
*/
@@ -242,22 +245,16 @@ default void trace(Supplier For example, if a plugin's logger is named
- * {@code "org.apache.maven.plugins.compiler.CompilerMojo"},
- * then {@code child("diagnostics")} returns a logger named
- * {@code "org.apache.maven.plugins.compiler.CompilerMojo.diagnostics"}.
- * This lets sub-components log under an independently filterable name
- * without requiring a separate injection point. The default implementation returns {@code this}, so existing
- * {@code Log} implementations continue to work without changes.
- * Implementations that wrap a hierarchical logging backend (such as
- * SLF4J) should override this to create a real child logger.
+ * The default implementation returns {@code this} so that existing
+ * implementations continue to work without changes.
*
- * @param name the suffix to append (must not be {@code null} or blank)
- * @return a child logger — never {@code null}
+ * @param name the child logger name segment (must not be {@code null})
+ * @return a child {@code Log}, never {@code null}
+ * @since 4.1.0
*/
default Log child(String name) {
return this;
diff --git a/compat/maven-embedder/pom.xml b/compat/maven-embedder/pom.xml
index 2df8588ec116..56730ccb4326 100644
--- a/compat/maven-embedder/pom.xml
+++ b/compat/maven-embedder/pom.xml
@@ -163,11 +163,6 @@ under the License.
+ * Registered as an {@link org.apache.maven.eventspy.EventSpy} via {@code @Named}/{@code @Singleton}, + * following the same pattern as {@code DefaultPluginValidationManager}. + *
+ * Thread-safe: concurrent module builds (with {@code -T}) each write to their + * own entry in a {@link ConcurrentHashMap}. + *
+ * 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
+ * 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
+ * Public to allow construction from other packages within the Maven
+ * implementation (e.g. {@code ProjectBuildLogAppender}).
+ *
+ * @param timestamp when the event was produced
+ * @param level the severity level
+ * @param message the clean log message (without level prefix or ANSI)
+ * @param loggerName the name of the logger, or {@code null}
+ * @param stackTrace the stack trace string, or {@code null}
+ * @param formattedMessage the fully formatted console line, or {@code null}
+ * @param sourceClassName the source class name (Log API mojo FQCN or JUL source), or {@code null}
+ * @param sourceMethodName the source method name (via StackWalker or JUL), or {@code null}
+ * @param threadId the originating thread ID, or {@code -1} if unavailable
+ * @param sequenceNumber the JUL sequence number for ordering, or {@code -1} if unavailable
+ */
+public record DefaultLogEvent(
+ Instant timestamp,
+ LogLevel level,
+ String message,
+ String loggerName,
+ String stackTrace,
+ String formattedMessage,
+ String sourceClassName,
+ String sourceMethodName,
+ long threadId,
+ long sequenceNumber)
+ implements LogEvent {
+
+ /**
+ * Convenience constructor for events without source metadata
+ * (i.e. direct SLF4J events).
+ */
+ public DefaultLogEvent(
+ Instant timestamp,
+ LogLevel level,
+ String message,
+ String loggerName,
+ String stackTrace,
+ String formattedMessage) {
+ this(timestamp, level, message, loggerName, stackTrace, formattedMessage, null, null, -1, -1);
+ }
+
+ /**
+ * Convenience constructor for events created without a formatted message
+ * (e.g. in tests or programmatic construction).
+ */
+ DefaultLogEvent(Instant timestamp, LogLevel level, String message, String loggerName, String stackTrace) {
+ this(timestamp, level, message, loggerName, stackTrace, null, null, null, -1, -1);
+ }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java
similarity index 51%
rename from impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java
rename to impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java
index 67ee429d82ab..64c79636ebd1 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java
@@ -16,31 +16,38 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.maven.cling.logging.impl;
+package org.apache.maven.internal.build;
-import org.apache.maven.cling.logging.BaseSlf4jConfiguration;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+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;
/**
- * Configuration for slf4j-logback.
- *
- * @since 3.1.0
+ * Internal immutable implementation of {@link ModuleReport}.
*/
-public class LogbackConfiguration extends BaseSlf4jConfiguration {
+record DefaultModuleReport(
+ String groupId,
+ String artifactId,
+ String version,
+ BuildStatus status,
+ Instant startTime,
+ Duration duration,
+ List
+ * Called from {@code ProjectBuildLogAppender.accept()} to populate
+ * {@code LogEvent.sourceClassName()} and {@code LogEvent.sourceMethodName()}.
+ *
+ * @return the current Log API metadata, or {@code null}
+ */
+ public static LogApiMetadata getLogApiMetadata() {
+ return LOG_API_METADATA.get();
+ }
+
private final Logger logger;
public DefaultLog(Logger logger) {
this.logger = requireNonNull(logger);
}
+ /**
+ * Wraps a logging call with Log API metadata: captures the caller's
+ * method name via {@link StackWalker}, sets the ThreadLocal, executes
+ * the actual SLF4J call, and clears the ThreadLocal.
+ *
+ * The source class name is taken from the SLF4J logger name (which
+ * is the mojo implementation FQCN, set at injection time). The
+ * source method name is resolved by walking the stack past this class
+ * to find the first external caller frame.
+ */
+ private void withMetadata(Runnable logAction) {
+ String callerMethodName = WALKER.walk(frames -> frames.dropWhile(f -> THIS_CLASS.equals(f.getClassName()))
+ .findFirst()
+ .map(StackFrame::getMethodName)
+ .orElse(null));
+ LOG_API_METADATA.set(new LogApiMetadata(
+ logger.getName(), callerMethodName, Thread.currentThread().getId()));
+ try {
+ logAction.run();
+ } finally {
+ LOG_API_METADATA.remove();
+ }
+ }
+
@Override
public boolean isTraceEnabled() {
return logger.isTraceEnabled();
@@ -41,175 +94,175 @@ public boolean isTraceEnabled() {
@Override
public void trace(CharSequence content) {
if (isTraceEnabled()) {
- logger.trace(toString(content));
+ withMetadata(() -> logger.trace(toString(content)));
}
}
@Override
public void trace(CharSequence content, Throwable error) {
if (isTraceEnabled()) {
- logger.trace(toString(content), error);
+ withMetadata(() -> logger.trace(toString(content), error));
}
}
@Override
public void trace(Throwable error) {
if (isTraceEnabled()) {
- logger.trace("", error);
+ withMetadata(() -> logger.trace("", error));
}
}
@Override
public void trace(Supplier
+ * Installs itself as a {@link MavenSimpleLogger.LogSink} to intercept all
+ * SLF4J log output, enrich it with structured metadata (level, logger name,
+ * clean message, formatted output), and forward to the active
+ * {@link BuildEventListener}.
*/
public class ProjectBuildLogAppender implements AutoCloseable {
@@ -31,7 +49,6 @@ public class ProjectBuildLogAppender implements AutoCloseable {
private static final ThreadLocal
* Format: {@code "prefix:goal@executionId"}
* (e.g. {@code "compiler:compile@default-compile"}).
@@ -74,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);
}
}
@@ -94,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) {
@@ -118,6 +114,32 @@ public static void updateMdc() {
}
}
+ /**
+ * Global sequence counter for total ordering of log events across
+ * all sources (Log API, JUL, direct SLF4J). Incremented atomically
+ * in {@link #accept} which is called synchronously on the logging thread.
+ */
+ private static final AtomicLong SEQUENCE = new AtomicLong();
+
+ /**
+ * Callback for build report log capture. Receives the fully-formed
+ * {@link LogEvent} produced by {@link #accept}, eliminating the need
+ * for a second capture pipeline in {@code MavenSimpleLogger}.
+ *
+ * Set by {@code BuildReportCollector} at session start, cleared at
+ * session end. The callback runs synchronously on the logging thread.
+ */
+ private static volatile Consumer
+ * 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 000000000000..119b19194897
--- /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
+ * The default implementation delegates to {@link #write(StringBuilder, Throwable)}.
+ *
+ * @param level the SLF4J log level constant
+ * @param loggerName the name of the logger
+ * @param cleanMessage the formatted message without level/timestamp prefix
+ * @param formattedBuf the fully formatted log line
+ * @param t the throwable, may be {@code null}
+ */
+ protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) {
+ write(formattedBuf, t);
+ }
+
protected void writeThrowable(Throwable t, PrintStream targetStream) {
if (t != null) {
t.printStackTrace(targetStream);
@@ -375,7 +392,7 @@ private void innerHandleNormalizedLoggingCall(
// Append the message
buf.append(formattedMessage);
- write(buf, t);
+ write(level.toInt(), name, formattedMessage, buf, t);
}
protected String renderLevel(int levelInt) {
diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java
new file mode 100644
index 000000000000..9be299faf0fd
--- /dev/null
+++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java
@@ -0,0 +1,249 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.slf4j;
+
+import java.text.MessageFormat;
+import java.util.MissingResourceException;
+import java.util.ResourceBundle;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogManager;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+
+import org.apache.maven.api.services.MessageBuilder;
+import org.slf4j.LoggerFactory;
+import org.slf4j.spi.LocationAwareLogger;
+
+import static org.apache.maven.jline.MessageUtils.builder;
+
+/**
+ * A JUL {@link Handler} that routes {@code java.util.logging} events into
+ * Maven's structured logging pipeline, preserving the rich {@link LogRecord}
+ * metadata that the standard {@code SLF4JBridgeHandler} silently drops
+ * (source class name, source method name, thread ID).
+ *
+ * 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}.
+ *
+ * When no LogSink is installed (e.g. during early bootstrap), the handler
+ * falls back to routing through SLF4J for console output.
+ *
+ * Usage — replace the standard SLF4J bridge in {@code LookupInvoker}:
+ *
+ * This method is intended to be called from within a
+ * {@link MavenSimpleLogger.LogSink} callback (e.g. in
+ * {@code ProjectBuildLogAppender.accept()}).
+ *
+ * @return the current JUL metadata, or {@code null}
+ */
+ public static JulMetadata getJulMetadata() {
+ return METADATA.get();
+ }
+
+ /**
+ * Installs this handler on the JUL root logger, removing any
+ * previously installed handlers. This replaces the standard
+ * {@code SLF4JBridgeHandler.install()} call.
+ */
+ public static void install() {
+ Logger rootLogger = LogManager.getLogManager().getLogger("");
+ // Remove all existing handlers (including any SLF4JBridgeHandler)
+ for (Handler handler : rootLogger.getHandlers()) {
+ rootLogger.removeHandler(handler);
+ }
+ rootLogger.addHandler(new MavenJulHandler());
+ // Accept all levels — filtering is done by SLF4J
+ rootLogger.setLevel(Level.ALL);
+ }
+
+ /**
+ * Returns {@code true} if a {@code MavenJulHandler} is installed
+ * on the JUL root logger.
+ */
+ public static boolean isInstalled() {
+ Logger rootLogger = LogManager.getLogManager().getLogger("");
+ for (Handler handler : rootLogger.getHandlers()) {
+ if (handler instanceof MavenJulHandler) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public void publish(LogRecord record) {
+ if (record == null) {
+ return;
+ }
+
+ String loggerName = record.getLoggerName();
+ org.slf4j.Logger slf4jLogger = LoggerFactory.getLogger(loggerName);
+ int slf4jLevel = julLevelToSlf4j(record.getLevel());
+
+ // Quick exit if this level is not enabled
+ if (!isLevelEnabled(slf4jLogger, slf4jLevel)) {
+ return;
+ }
+
+ String message = formatMessage(record);
+ Throwable throwable = record.getThrown();
+
+ // If a LogSink is installed, bypass SLF4J entirely: call the sink
+ // directly with the JUL metadata so no information is lost in transit.
+ MavenSimpleLogger.LogSink sink = MavenSimpleLogger.getLogSink();
+ if (sink != null) {
+ METADATA.set(new JulMetadata(
+ record.getSourceClassName(), record.getSourceMethodName(), record.getLongThreadID()));
+ try {
+ String formatted = formatForConsole(slf4jLevel, message);
+ sink.accept(slf4jLevel, loggerName, message, formatted, throwable);
+ } finally {
+ METADATA.remove();
+ }
+ } else {
+ // No LogSink — fall through to SLF4J for console output
+ logToSlf4j(slf4jLogger, slf4jLevel, message, throwable);
+ }
+ }
+
+ @Override
+ public void flush() {
+ // nothing to flush
+ }
+
+ @Override
+ public void close() throws SecurityException {
+ // nothing to close
+ }
+
+ /**
+ * Formats the log message, applying i18n resource bundle lookup and
+ * {@link MessageFormat} parameter substitution, matching the behavior
+ * of {@code SLF4JBridgeHandler}.
+ */
+ private static String formatMessage(LogRecord record) {
+ String message = record.getMessage();
+ if (message == null) {
+ return "";
+ }
+
+ // Try resource bundle lookup
+ ResourceBundle bundle = record.getResourceBundle();
+ if (bundle != null) {
+ try {
+ message = bundle.getString(message);
+ } catch (MissingResourceException e) {
+ // use raw message
+ }
+ }
+
+ // Apply MessageFormat parameters
+ Object[] params = record.getParameters();
+ if (params != null && params.length > 0) {
+ try {
+ message = MessageFormat.format(message, params);
+ } catch (IllegalArgumentException e) {
+ // use message as-is if formatting fails
+ }
+ }
+
+ return message;
+ }
+
+ /**
+ * Formats a JUL message for console output, matching the {@code [LEVEL] message}
+ * style used by MavenSimpleLogger with ANSI coloring when available.
+ */
+ private static String formatForConsole(int level, String message) {
+ MessageBuilder mb = builder();
+ String levelStr =
+ switch (level) {
+ case LocationAwareLogger.TRACE_INT -> mb.trace("TRACE").build();
+ case LocationAwareLogger.DEBUG_INT -> mb.debug("DEBUG").build();
+ case LocationAwareLogger.INFO_INT -> mb.info("INFO").build();
+ case LocationAwareLogger.WARN_INT -> mb.warning("WARNING").build();
+ default -> mb.error("ERROR").build();
+ };
+ return "[" + levelStr + "] " + message;
+ }
+
+ private static int julLevelToSlf4j(Level julLevel) {
+ int value = julLevel.intValue();
+ if (value <= Level.FINEST.intValue()) {
+ return LocationAwareLogger.TRACE_INT;
+ } else if (value <= Level.FINE.intValue()) {
+ return LocationAwareLogger.DEBUG_INT;
+ } else if (value <= Level.INFO.intValue()) {
+ return LocationAwareLogger.INFO_INT;
+ } else if (value <= Level.WARNING.intValue()) {
+ return LocationAwareLogger.WARN_INT;
+ } else {
+ return LocationAwareLogger.ERROR_INT;
+ }
+ }
+
+ private static boolean isLevelEnabled(org.slf4j.Logger logger, int level) {
+ return switch (level) {
+ case LocationAwareLogger.TRACE_INT -> logger.isTraceEnabled();
+ case LocationAwareLogger.DEBUG_INT -> logger.isDebugEnabled();
+ case LocationAwareLogger.INFO_INT -> logger.isInfoEnabled();
+ case LocationAwareLogger.WARN_INT -> logger.isWarnEnabled();
+ default -> logger.isErrorEnabled();
+ };
+ }
+
+ private static void logToSlf4j(org.slf4j.Logger logger, int level, String message, Throwable throwable) {
+ switch (level) {
+ case LocationAwareLogger.TRACE_INT -> logger.trace(message, throwable);
+ case LocationAwareLogger.DEBUG_INT -> logger.debug(message, throwable);
+ case LocationAwareLogger.INFO_INT -> logger.info(message, throwable);
+ case LocationAwareLogger.WARN_INT -> logger.warn(message, throwable);
+ default -> logger.error(message, throwable);
+ }
+ }
+}
diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java
index 02767987e2a1..77fc8b1eb846 100644
--- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java
+++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java
@@ -39,14 +39,45 @@ public class MavenSimpleLogger extends MavenBaseLogger {
private String warnRenderedLevel;
private String errorRenderedLevel;
- static Consumer
+ * This replaces the previous {@code Consumer
+ * MavenJulHandler.install();
+ *
+ *
+ * @since 4.1.0
+ * @see #install()
+ * @see #getJulMetadata()
+ */
+public class MavenJulHandler extends Handler {
+
+ /**
+ * JUL metadata captured from a {@link LogRecord} that would otherwise
+ * be lost when bridging to SLF4J.
+ *
+ * @param sourceClassName the source class, or {@code null}
+ * @param sourceMethodName the source method, or {@code null}
+ * @param threadId the originating thread ID
+ */
+ public record JulMetadata(String sourceClassName, String sourceMethodName, long threadId) {}
+
+ private static final ThreadLocal