diff --git a/apache-maven/pom.xml b/apache-maven/pom.xml index c39740903729..d1ce8a50506f 100644 --- a/apache-maven/pom.xml +++ b/apache-maven/pom.xml @@ -78,13 +78,6 @@ under the License. ${slf4jVersion} runtime - - - org.slf4j - jul-to-slf4j - ${slf4jVersion} - runtime - org.apache.maven.resolver maven-resolver-connector-basic 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 000000000000..7cd7975b8871 --- /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. + *

+ * 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 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 modules(); + + /** + * Failures that occurred during the build, if any. + * + * @return the failure reports, never {@code null}; empty if the build succeeded + */ + @Nonnull + List 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 problems(); + + /** + * Structured log events captured outside of any module's lifecycle — + * Maven startup messages, reactor ordering, and the final reactor summary. + *

+ * 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 output(); + + /** + * Find a module report by its GAV identifier. + *

+ * 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 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 findModule(FailureReport failure) { + Objects.requireNonNull(failure); + return findModule(failure.module()); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java similarity index 56% rename from impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java rename to api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java index bbd487fa5f87..1ee25f8cb3a6 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java @@ -16,29 +16,29 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.maven.cling.logging.impl; +package org.apache.maven.api.build.report; -import org.apache.maven.cling.logging.BaseSlf4jConfiguration; +import org.apache.maven.api.annotations.Experimental; /** - * Configuration for slf4j-log4j2. + * The outcome of a build, module, or mojo execution. * - * @since 3.1.0 + * @since 4.1.0 */ -public class Log4j2Configuration extends BaseSlf4jConfiguration { - @Override - public void setRootLoggerLevel(Level level) { - String value = - switch (level) { - case DEBUG -> "debug"; - case INFO -> "info"; - default -> "error"; - }; - System.setProperty("maven.logging.root.level", value); - } +@Experimental +public enum BuildStatus { + /** + * Completed successfully. + */ + SUCCESS, - @Override - public void activate() { - // no op - } + /** + * 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 000000000000..20e5d95b8aea --- /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"}). + *

+ * 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 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. + *

+ * For per-mojo events see {@link MojoReport#output()}. + * + * @return the captured log events, never {@code null}; may be empty + */ + @Nonnull + List output(); + + /** + * The module identifier formatted as {@code "groupId:artifactId:version"}. + *

+ * 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 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 000000000000..76001babbd6b --- /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. + *

+ * 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 output(); + + /** + * The mojo identifier formatted as {@code "artifactId:version:goal"}. + *

+ * 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 content) {} @@ -98,8 +103,7 @@ default void trace(Supplier content) {} *

* 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 content, Throwable error) {} @@ -111,10 +115,9 @@ default void trace(Supplier content, Throwable error) {} /** * Sends a message to the user in the debug error level. *

- * 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 content, Throwable error) {} /** * Returns a child logger whose name is derived from this logger's name - * by appending a dot and the given suffix. - * - *

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.

+ * by appending {@code "." + name}. This allows plugins to create + * sub-loggers for different concerns while keeping hierarchical level + * control (e.g. setting the level for the parent silences the children). + *

+ * 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. commons-cli - - ch.qos.logback - logback-classic - true - org.jline jansi-core diff --git a/impl/maven-cli/pom.xml b/impl/maven-cli/pom.xml index 5d7304af5e3a..56ac62ed6f3e 100644 --- a/impl/maven-cli/pom.xml +++ b/impl/maven-cli/pom.xml @@ -195,19 +195,10 @@ under the License. org.slf4j slf4j-api - - org.slf4j - jul-to-slf4j - commons-cli commons-cli - - ch.qos.logback - logback-classic - true - org.junit.jupiter diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java index d594307815eb..cf8341e34054 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java @@ -84,6 +84,7 @@ import org.apache.maven.logging.ProjectBuildLogAppender; import org.apache.maven.logging.SimpleBuildEventListener; import org.apache.maven.logging.api.LogLevelRecorder; +import org.apache.maven.slf4j.MavenJulHandler; import org.apache.maven.slf4j.MavenSimpleLogger; import org.codehaus.plexus.PlexusContainer; import org.jline.terminal.Terminal; @@ -92,7 +93,6 @@ import org.jline.terminal.spi.TerminalExt; import org.jline.utils.OSUtils; import org.slf4j.LoggerFactory; -import org.slf4j.bridge.SLF4JBridgeHandler; import org.slf4j.spi.LocationAwareLogger; import static java.util.Objects.requireNonNull; @@ -447,9 +447,8 @@ protected Consumer doDetermineWriter(C context) { } protected void activateLogging(C context) throws Exception { - if (!SLF4JBridgeHandler.isInstalled()) { - SLF4JBridgeHandler.removeHandlersForRootLogger(); - SLF4JBridgeHandler.install(); + if (!MavenJulHandler.isInstalled()) { + MavenJulHandler.install(); } context.slf4jConfiguration.activate(); diff --git a/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties b/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties index 369b0e6a266b..9580d5071ac9 100644 --- a/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties +++ b/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties @@ -19,5 +19,3 @@ # value = corresponding o.a.m.cli.logging.Slf4jConfiguration class org.slf4j.impl.SimpleLoggerFactory=org.apache.maven.cling.logging.impl.MavenSimpleConfiguration org.apache.maven.slf4j.MavenLoggerFactory=org.apache.maven.cling.logging.impl.MavenSimpleConfiguration -org.apache.logging.slf4j.Log4jLoggerFactory=org.apache.maven.cling.logging.impl.Log4j2Configuration -ch.qos.logback.classic.LoggerContext=org.apache.maven.cling.logging.impl.LogbackConfiguration diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java new file mode 100644 index 000000000000..89970a2f0ec3 --- /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. + *

+ * 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> mojoTimings = new ConcurrentHashMap<>(); + + /** Per-project start instants for duration computation. */ + private final Map projectStartTimes = new ConcurrentHashMap<>(); + + /** Per-mojo start instants for duration computation. */ + private final Map 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 currentMojoByThread = new ConcurrentHashMap<>(); + + /** Per-mojo log buffers: mojo key -> captured log events. */ + private final Map> 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 currentProjectByThread = new ConcurrentHashMap<>(); + + /** Per-module log buffers: project key -> events captured outside any mojo. */ + private final Map> moduleLogBuffers = new ConcurrentHashMap<>(); + + /** Build-level log buffer: events captured outside any module lifecycle. */ + private final List 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 logBuffer = mojoLogBuffers.remove(mKey); + List 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 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 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 moduleReports = new ArrayList<>(); + for (MavenProject project : endSession.getProjects()) { + moduleReports.add(buildModuleReport(project, endSession)); + } + + // Collect failures + List 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 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 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 timings = mojoTimings.getOrDefault(key, Collections.emptyList()); + List 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 moduleLogBuffer = moduleLogBuffers.getOrDefault(key, Collections.emptyList()); + List 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 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 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 000000000000..2eeb7c744fac --- /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. + *

+ * 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 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 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 000000000000..c0522681a5b2 --- /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 goals, + String project, + boolean multiModule, + int threads, + List modules, + List failures, + List problems, + List output) + implements BuildReport { + + private static final int FORMAT_VERSION = 1; + + @Override + public int formatVersion() { + return FORMAT_VERSION; + } + + @Override + public List modules() { + return List.copyOf(modules); + } + + @Override + public List failures() { + return List.copyOf(failures); + } + + @Override + public List problems() { + return List.copyOf(problems); + } + + @Override + public List goals() { + return List.copyOf(goals); + } + + @Override + public List output() { + return List.copyOf(output); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java new file mode 100644 index 000000000000..b0bc075d34f3 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.time.Instant; + +import org.apache.maven.api.build.report.FailureReport; + +/** + * Internal immutable implementation of {@link FailureReport}. + */ +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/DefaultLogEvent.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java new file mode 100644 index 000000000000..95951d8e7ac6 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.time.Instant; + +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; + +/** + * Immutable implementation of {@link LogEvent}. + *

+ * 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 mojos, + List output) + implements ModuleReport { + @Override - public void setRootLoggerLevel(Level level) { - ch.qos.logback.classic.Level value = - switch (level) { - case DEBUG -> ch.qos.logback.classic.Level.DEBUG; - case INFO -> ch.qos.logback.classic.Level.INFO; - default -> ch.qos.logback.classic.Level.ERROR; - }; - ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).setLevel(value); + public List mojos() { + return List.copyOf(mojos); } @Override - public void activate() { - // no op + public List output() { + return List.copyOf(output); } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java new file mode 100644 index 000000000000..5270d9c11fe3 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java @@ -0,0 +1,49 @@ +/* + * 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.MojoReport; + +/** + * Internal immutable implementation of {@link MojoReport}. + */ +record DefaultMojoReport( + String groupId, + String artifactId, + String version, + String goal, + String executionId, + String phase, + BuildStatus status, + Instant startTime, + Duration duration, + List output) + implements MojoReport { + + @Override + public List 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 b1cf40cc4059..581227c76d62 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java @@ -18,6 +18,7 @@ */ package org.apache.maven.internal.impl; +import java.lang.StackWalker.StackFrame; import java.util.function.Supplier; import org.apache.maven.api.plugin.Log; @@ -27,12 +28,64 @@ import static java.util.Objects.requireNonNull; public class DefaultLog implements Log { + + /** + * Metadata captured from Log API calls, mirroring the JUL metadata + * pattern in {@code MavenJulHandler}. + * + * @param sourceClassName the fully qualified class name of the caller + * @param sourceMethodName the method that issued the log call + * @param threadId the originating thread ID + */ + public record LogApiMetadata(String sourceClassName, String sourceMethodName, long threadId) {} + + private static final ThreadLocal LOG_API_METADATA = new ThreadLocal<>(); + private static final StackWalker WALKER = StackWalker.getInstance(); + private static final String THIS_CLASS = DefaultLog.class.getName(); + + /** + * Returns the Log API metadata for the current log event being processed, + * or {@code null} if the current event did not originate from the Log API. + *

+ * 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 content) { if (isTraceEnabled()) { - logger.trace(content.get()); + withMetadata(() -> logger.trace(content.get())); } } @Override public void trace(Supplier content, Throwable error) { if (isTraceEnabled()) { - logger.trace(content.get(), error); + withMetadata(() -> logger.trace(content.get(), error)); } } @Override public void debug(CharSequence content) { if (isDebugEnabled()) { - logger.debug(toString(content)); + withMetadata(() -> logger.debug(toString(content))); } } @Override public void debug(CharSequence content, Throwable error) { if (isDebugEnabled()) { - logger.debug(toString(content), error); + withMetadata(() -> logger.debug(toString(content), error)); } } @Override public void debug(Throwable error) { if (isDebugEnabled()) { - logger.debug("", error); + withMetadata(() -> logger.debug("", error)); } } @Override public void debug(Supplier content) { if (isDebugEnabled()) { - logger.debug(content.get()); + withMetadata(() -> logger.debug(content.get())); } } @Override public void debug(Supplier content, Throwable error) { if (isDebugEnabled()) { - logger.debug(content.get(), error); + withMetadata(() -> logger.debug(content.get(), error)); } } @Override public void info(CharSequence content) { if (isInfoEnabled()) { - logger.info(toString(content)); + withMetadata(() -> logger.info(toString(content))); } } @Override public void info(CharSequence content, Throwable error) { if (isInfoEnabled()) { - logger.info(toString(content), error); + withMetadata(() -> logger.info(toString(content), error)); } } @Override public void info(Throwable error) { if (isInfoEnabled()) { - logger.info("", error); + withMetadata(() -> logger.info("", error)); } } @Override public void info(Supplier content) { if (isInfoEnabled()) { - logger.info(content.get()); + withMetadata(() -> logger.info(content.get())); } } @Override public void info(Supplier content, Throwable error) { if (isInfoEnabled()) { - logger.info(content.get(), error); + withMetadata(() -> logger.info(content.get(), error)); } } @Override public void warn(CharSequence content) { if (isWarnEnabled()) { - logger.warn(toString(content)); + withMetadata(() -> logger.warn(toString(content))); } } @Override public void warn(CharSequence content, Throwable error) { if (isWarnEnabled()) { - logger.warn(toString(content), error); + withMetadata(() -> logger.warn(toString(content), error)); } } @Override public void warn(Throwable error) { if (isWarnEnabled()) { - logger.warn("", error); + withMetadata(() -> logger.warn("", error)); } } @Override public void warn(Supplier content) { if (isWarnEnabled()) { - logger.warn(content.get()); + withMetadata(() -> logger.warn(content.get())); } } @Override public void warn(Supplier content, Throwable error) { if (isWarnEnabled()) { - logger.warn(content.get(), error); + withMetadata(() -> logger.warn(content.get(), error)); } } @Override public void error(CharSequence content) { if (isErrorEnabled()) { - logger.error(toString(content)); + withMetadata(() -> logger.error(toString(content))); } } @Override public void error(CharSequence content, Throwable error) { if (isErrorEnabled()) { - logger.error(toString(content), error); + withMetadata(() -> logger.error(toString(content), error)); } } @Override public void error(Throwable error) { if (isErrorEnabled()) { - logger.error("", error); + withMetadata(() -> logger.error("", error)); } } @Override public void error(Supplier content) { if (isErrorEnabled()) { - logger.error(content.get()); + withMetadata(() -> logger.error(content.get())); } } @Override public void error(Supplier content, Throwable error) { if (isErrorEnabled()) { - logger.error(content.get(), error); + withMetadata(() -> logger.error(content.get(), error)); } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java index 39573d061cd0..c1d771b5c11b 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java @@ -18,6 +18,7 @@ */ package org.apache.maven.logging; +import org.apache.maven.api.build.report.LogEvent; import org.apache.maven.execution.ExecutionEvent; import org.eclipse.aether.transfer.TransferEvent; @@ -30,7 +31,7 @@ public interface BuildEventListener { void projectStarted(String projectId); - void projectLogMessage(String projectId, String event); + void projectLogMessage(String projectId, LogEvent event); void projectFinished(String projectId); diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java index 700834eb80df..62c387fb2fc2 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 dc82a2f89848..03485b8759a0 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java @@ -18,11 +18,29 @@ */ package org.apache.maven.logging; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.apache.maven.internal.impl.DefaultLog; +import org.apache.maven.slf4j.MavenJulHandler; import org.apache.maven.slf4j.MavenSimpleLogger; import org.slf4j.MDC; +import org.slf4j.spi.LocationAwareLogger; /** - * Forwards log messages to the client. + * Forwards log messages to the client as structured {@link LogEvent} objects. + *

+ * 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 PROJECT_ID = new InheritableThreadLocal<>(); private static final ThreadLocal MOJO_ID = new InheritableThreadLocal<>(); private static final ThreadLocal FORKING_PROJECT_ID = new InheritableThreadLocal<>(); - private static final ThreadLocal FORKING_MOJO_ID = new InheritableThreadLocal<>(); public static String getProjectId() { return PROJECT_ID.get(); @@ -61,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. *

* 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 reportCapture; + + /** + * Sets the report capture callback. + * + * @param capture the callback, or {@code null} to remove + */ + public static void setReportCapture(Consumer capture) { + ProjectBuildLogAppender.reportCapture = capture; + } + private final BuildEventListener buildEventListener; public ProjectBuildLogAppender(BuildEventListener buildEventListener) { @@ -125,13 +147,73 @@ public ProjectBuildLogAppender(BuildEventListener buildEventListener) { MavenSimpleLogger.setLogSink(this::accept); } - protected void accept(String message) { + protected void accept( + int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable) { String projectId = MDC.get(KEY_PROJECT_ID); - buildEventListener.projectLogMessage(projectId, message); + Instant timestamp = MonotonicClock.now(); + LogLevel logLevel = toLogLevel(level); + String stackTrace = throwable != null ? formatStackTrace(throwable) : null; + + long seq = SEQUENCE.getAndIncrement(); + + // Read source metadata: JUL events carry it via MavenJulHandler, + // Log API events carry it via DefaultLog's ThreadLocal. + MavenJulHandler.JulMetadata julMeta = MavenJulHandler.getJulMetadata(); + DefaultLog.LogApiMetadata logApiMeta = DefaultLog.getLogApiMetadata(); + String sourceClassName; + String sourceMethodName; + long threadId; + if (julMeta != null) { + sourceClassName = julMeta.sourceClassName(); + sourceMethodName = julMeta.sourceMethodName(); + threadId = julMeta.threadId(); + } else if (logApiMeta != null) { + sourceClassName = logApiMeta.sourceClassName(); + sourceMethodName = logApiMeta.sourceMethodName(); + threadId = logApiMeta.threadId(); + } else { + sourceClassName = null; + sourceMethodName = null; + threadId = -1; + } + LogEvent event = new DefaultLogEvent( + timestamp, + logLevel, + cleanMessage, + loggerName, + stackTrace, + formattedMessage, + sourceClassName, + sourceMethodName, + threadId, + seq); + buildEventListener.projectLogMessage(projectId, event); + + // Forward to build report collector (if active) + Consumer capture = reportCapture; + if (capture != null) { + capture.accept(event); + } } @Override public void close() throws Exception { MavenSimpleLogger.setLogSink(null); } + + private static LogLevel toLogLevel(int level) { + return switch (level) { + case LocationAwareLogger.TRACE_INT -> LogLevel.TRACE; + case LocationAwareLogger.DEBUG_INT -> LogLevel.DEBUG; + case LocationAwareLogger.INFO_INT -> LogLevel.INFO; + case LocationAwareLogger.WARN_INT -> LogLevel.WARN; + default -> LogLevel.ERROR; + }; + } + + private static String formatStackTrace(Throwable t) { + StringWriter sw = new StringWriter(); + t.printStackTrace(new PrintWriter(sw)); + return sw.toString(); + } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java index 87f7baa1fd59..37c49a92c1c4 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java @@ -20,6 +20,7 @@ import java.util.function.Consumer; +import org.apache.maven.api.build.report.LogEvent; import org.apache.maven.execution.ExecutionEvent; import org.eclipse.aether.transfer.TransferEvent; @@ -38,8 +39,9 @@ public void sessionStarted(ExecutionEvent event) {} public void projectStarted(String projectId) {} @Override - public void projectLogMessage(String projectId, String event) { - log(event); + public void projectLogMessage(String projectId, LogEvent event) { + String formatted = event.formattedMessage(); + log(formatted != null ? formatted : event.message()); } @Override diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java new file mode 100644 index 000000000000..433c166e1e2e --- /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 000000000000..cc48ed27d7e7 --- /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. + *

+ * 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 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 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 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-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java index eb290734fb98..05f875eab605 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java @@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -31,7 +32,8 @@ import static org.mockito.Mockito.when; /** - * Tests for {@link DefaultLog}. + * Tests for {@link DefaultLog}, focused on verifying the bug fix for + * {@code warn(Supplier, Throwable)} and the Log API metadata contract. */ class DefaultLogTest { @@ -43,6 +45,7 @@ class DefaultLogTest { void warnWithSupplierAndThrowableDelegatesToWarn() { Logger mockLogger = mock(Logger.class); when(mockLogger.isWarnEnabled()).thenReturn(true); + when(mockLogger.getName()).thenReturn("test.logger"); DefaultLog log = new DefaultLog(mockLogger); RuntimeException ex = new RuntimeException("test"); @@ -51,6 +54,23 @@ void warnWithSupplierAndThrowableDelegatesToWarn() { verify(mockLogger).warn("warning message", ex); } + /** + * Verify that Log API metadata is set during the log call and + * cleared afterwards — no leakage across calls. + */ + @Test + void logApiMetadataIsClearedAfterCall() { + Logger mockLogger = mock(Logger.class); + when(mockLogger.isInfoEnabled()).thenReturn(true); + when(mockLogger.getName()).thenReturn("com.example.MyMojo"); + + DefaultLog log = new DefaultLog(mockLogger); + log.info("test message"); + + // After the call completes, metadata should be cleared + assertNull(DefaultLog.getLogApiMetadata(), "Log API metadata should be cleared after the log call"); + } + /** * Verify trace methods delegate to the SLF4J logger correctly. */ @@ -58,6 +78,7 @@ void warnWithSupplierAndThrowableDelegatesToWarn() { void traceMethodsDelegateToSlf4jTrace() { Logger mockLogger = mock(Logger.class); when(mockLogger.isTraceEnabled()).thenReturn(true); + when(mockLogger.getName()).thenReturn("test.logger"); DefaultLog log = new DefaultLog(mockLogger); log.trace("trace message"); @@ -77,6 +98,7 @@ void traceIsNoOpWhenDisabled() { log.trace("should not be logged"); verify(mockLogger).isTraceEnabled(); + // trace() should NOT have been called on the underlying logger verifyNoMoreInteractions(mockLogger); } diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java index 20a5e7ab666b..6faeba5feacd 100644 --- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java @@ -232,6 +232,23 @@ protected void write(StringBuilder buf, Throwable t) { } } + /** + * Context-aware write that includes the log level, logger name, and + * clean message alongside the formatted output. Subclasses can override + * to forward structured data to a log sink. + *

+ * 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}: + *

+ *     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 METADATA = new ThreadLocal<>(); + + /** + * Returns the JUL metadata for the current log event being processed, + * or {@code null} if the current log event did not originate from JUL. + *

+ * 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 logSink; + /** + * Structured log sink that receives level, logger name, clean message, + * formatted console output, and throwable for each log event. + *

+ * This replaces the previous {@code Consumer} sink to enable + * console renderers (e.g. rich mode) to filter by log level and access + * the clean message independently of ANSI formatting. + * + * @since 4.1.0 + */ + @FunctionalInterface + public interface LogSink { + void accept(int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable); + } + + static volatile LogSink logSink; public static final String DEFAULT_LOG_LEVEL_KEY = "org.slf4j.simpleLogger.defaultLogLevel"; - public static void setLogSink(Consumer logSink) { + /** + * Sets the structured log sink. + * + * @param logSink the sink, or {@code null} to remove + * @since 4.1.0 + */ + public static void setLogSink(LogSink logSink) { MavenSimpleLogger.logSink = logSink; } + /** + * Returns the current log sink, or {@code null} if none is set. + * + * @return the current log sink, or {@code null} + * @since 4.1.0 + */ + public static LogSink getLogSink() { + return logSink; + } + MavenSimpleLogger(String name) { super(name); } @@ -70,15 +101,74 @@ protected String renderLevel(int level) { } @Override - protected void write(StringBuilder buf, Throwable t) { - Consumer sink = logSink; + protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) { + LogSink sink = logSink; if (sink != null) { - sink.accept(buf.toString()); + // Build the full formatted output including throwable rendering + String formatted = formattedBuf.toString(); if (t != null) { - writeThrowable(t, sink); + StringBuilder full = new StringBuilder(formatted); + full.append(System.lineSeparator()); + appendFormattedThrowable(full, t, ""); + formatted = full.toString(); } + sink.accept(level, loggerName, cleanMessage, formatted, t); } else { - super.write(buf, t); + super.write(formattedBuf, t); + } + } + + /** + * 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); } } diff --git a/pom.xml b/pom.xml index ae7940108d5d..a7b561d6eff2 100644 --- a/pom.xml +++ b/pom.xml @@ -157,7 +157,6 @@ under the License. 1.37 6.1.3 1.4.0 - 1.6.3 5.23.0 1.6.0 1.30.0 @@ -508,17 +507,6 @@ under the License. ${slf4jVersion} true - - org.slf4j - jul-to-slf4j - ${slf4jVersion} - - - ch.qos.logback - logback-classic - ${logbackClassicVersion} - true - org.apache.maven.wagon